Compare commits
10 Commits
4873449e16
...
f8e426542d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8e426542d | ||
|
|
19ee02ac6c | ||
|
|
eef058027d | ||
|
|
fa3129bb1a | ||
|
|
4d09d7e5b4 | ||
|
|
3ee1a87d58 | ||
|
|
1b7e4b94b5 | ||
|
|
828bb301d6 | ||
|
|
1f140b6884 | ||
|
|
b1c8a4ae6c |
28
.codex/config.toml
Normal file
28
.codex/config.toml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
model = "gpt-5.3-codex"
|
||||||
|
model_reasoning_effort = "high"
|
||||||
|
approval_policy = "never" # values: untrusted | on-failure | on-request | never
|
||||||
|
sandbox_mode = "workspace-write"
|
||||||
|
|
||||||
|
developer_instructions = """
|
||||||
|
Work in phases.
|
||||||
|
- At the start of each phase: state the goal + plan briefly.
|
||||||
|
- During the phase: edit files and run commands as needed.
|
||||||
|
- End of each phase: summarize what changed, show key diffs/paths touched, and stop for review.
|
||||||
|
Do not proceed to the next phase until the user says "continue".
|
||||||
|
""" :contentReference[oaicite:3]{index=3}
|
||||||
|
|
||||||
|
[sandbox_workspace_write]
|
||||||
|
# Keep network off (commands that need internet will fail instead of prompting).
|
||||||
|
network_access = false :contentReference[oaicite:4]{index=4}
|
||||||
|
|
||||||
|
# Tighten writes to be “workspace only” by removing temp-dir write roots.
|
||||||
|
# (Workspace-write normally includes temp dirs; these reduce that surface area.)
|
||||||
|
exclude_slash_tmp = true
|
||||||
|
exclude_tmpdir_env_var = true :contentReference[oaicite:5]{index=5}
|
||||||
|
|
||||||
|
|
||||||
|
[projects.'C:\Users\Nico\Desktop\Projects\fiddy-finance-buddy-app']
|
||||||
|
trust_level = "trusted"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
elevated_windows_sandbox = true
|
||||||
93
.gitea/workflows/deploy-dokploy.yml
Normal file
93
.gitea/workflows/deploy-dokploy.yml
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
name: Build & Deploy Fiddy (Dokploy)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.nicosaya.com/nalalangan/fiddy
|
||||||
|
|
||||||
|
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:main -f docker/Dockerfile .
|
||||||
|
|
||||||
|
- name: Build Scheduler Image
|
||||||
|
run: |
|
||||||
|
docker build -t $REGISTRY/scheduler:${{ github.sha }} -t $REGISTRY/scheduler:main -f docker/Dockerfile.scheduler .
|
||||||
|
|
||||||
|
- name: Push Web Image
|
||||||
|
run: |
|
||||||
|
docker push $REGISTRY/web:${{ github.sha }}
|
||||||
|
docker push $REGISTRY/web:main
|
||||||
|
|
||||||
|
- name: Push Scheduler Image
|
||||||
|
run: |
|
||||||
|
docker push $REGISTRY/scheduler:${{ github.sha }}
|
||||||
|
docker push $REGISTRY/scheduler:main
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Trigger Dokploy Deploy
|
||||||
|
env:
|
||||||
|
DOKPLOY_DEPLOY_HOOK: ${{ secrets.DOKPLOY_DEPLOY_HOOK }}
|
||||||
|
IMAGE_TAG: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$DOKPLOY_DEPLOY_HOOK" ]; then
|
||||||
|
echo "Missing DOKPLOY_DEPLOY_HOOK secret"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
curl -fsS -X POST "$DOKPLOY_DEPLOY_HOOK" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"imageTag\":\"$IMAGE_TAG\"}"
|
||||||
|
|
||||||
|
- name: Trigger Dokploy Scheduler Deploy
|
||||||
|
env:
|
||||||
|
DOKPLOY_SCHEDULER_DEPLOY_HOOK: ${{ secrets.DOKPLOY_SCHEDULER_DEPLOY_HOOK }}
|
||||||
|
IMAGE_TAG: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$DOKPLOY_SCHEDULER_DEPLOY_HOOK" ]; then
|
||||||
|
echo "DOKPLOY_SCHEDULER_DEPLOY_HOOK not set; skipping scheduler deploy trigger"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
curl -fsS -X POST "$DOKPLOY_SCHEDULER_DEPLOY_HOOK" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"imageTag\":\"$IMAGE_TAG\"}"
|
||||||
|
|
||||||
|
- name: Wait for Ready Health Check
|
||||||
|
env:
|
||||||
|
HEALTH_URL: ${{ secrets.DOKPLOY_HEALTHCHECK_URL }}
|
||||||
|
MAX_ATTEMPTS: "30"
|
||||||
|
SLEEP_SECONDS: "10"
|
||||||
|
run: |
|
||||||
|
if [ -z "$HEALTH_URL" ]; then
|
||||||
|
echo "Missing DOKPLOY_HEALTHCHECK_URL secret"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
bash scripts/wait-for-health.sh
|
||||||
@ -1,69 +0,0 @@
|
|||||||
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
|
|
||||||
148
.github/copilot-instructions.md
vendored
148
.github/copilot-instructions.md
vendored
@ -1,123 +1,41 @@
|
|||||||
# Copilot Instructions — Fiddy (External DB)
|
# Copilot Instructions — Fiddy (External DB)
|
||||||
|
|
||||||
## Source of truth
|
## Authority
|
||||||
- Always consult PROJECT_INSTRUCTIONS.md at the repo root.
|
- **Source of truth:** `PROJECT_INSTRUCTIONS.md` (repo root). If conflict, follow it.
|
||||||
- If any guidance conflicts, PROJECT_INSTRUCTIONS.md takes precedence.
|
- **Bugfix work:** follow `DEBUGGING_INSTRUCTIONS.md` (repo root).
|
||||||
- Keep this file focused on architecture/stack; keep checklists and project workflow rules in PROJECT_INSTRUCTIONS.md.
|
- Keep this file short: it’s a guide for Copilot behavior, not the full spec.
|
||||||
- When asked to fix bugs, follow DEBUGGING_INSTRUCTIONS.md at the repo root.
|
|
||||||
|
|
||||||
## Stack
|
## High-level behavior
|
||||||
- Monorepo (npm workspaces)
|
- Make the **smallest change** that resolves the bug or request.
|
||||||
- Next.js (App Router) + TypeScript + Tailwind
|
- **Scan the repo first** for existing patterns (don’t invent files/endpoints unless necessary).
|
||||||
- External Postgres (on-prem server) via node-postgres (pg). No ORM.
|
- Respect layering: **route → server service → client wrapper → hook → UI**.
|
||||||
- Docker Compose dev/prod
|
- Keep diffs tight; avoid large refactors unless required.
|
||||||
- Gitea + act-runner CI/CD
|
|
||||||
|
|
||||||
## Environment
|
## Hard rules (do not violate)
|
||||||
- Dev and Prod must use the same schema/migrations (`packages/db/migrations`).
|
- External DB: `DATABASE_URL` points to on-prem Postgres (NOT a container).
|
||||||
- `DATABASE_URL` points to the external DB server (NOT a container).
|
- No cron/worker jobs.
|
||||||
|
- Server-side RBAC only; client checks are UX only.
|
||||||
|
- Never log secrets, receipt bytes, or full invite codes (invite codes = **last4 only**).
|
||||||
|
- Entries list endpoints must never return receipt bytes.
|
||||||
|
|
||||||
## Auth
|
## Architecture quick map (follow existing patterns)
|
||||||
- Custom email/password auth.
|
- API routes: `app/api/**/route.ts` (thin parse/validate + call service)
|
||||||
- Use HttpOnly session cookies backed by DB table `sessions`.
|
- Server services: `lib/server/*` (DB + authz, must include `import "server-only";`)
|
||||||
- NEVER trust client-side RBAC checks.
|
- Client wrappers: `lib/client/*` (typed fetch + error normalization, credentials included)
|
||||||
|
- Hooks: `hooks/use-*.ts` (UI-facing API layer; components avoid raw `fetch()`)
|
||||||
|
|
||||||
## Receipts
|
## API conventions
|
||||||
- Store receipt images in Postgres `bytea` table `receipts`.
|
- Prefer error shape: `{ error: { code, message }, request_id? }`
|
||||||
- Entries list endpoints must not return image bytes.
|
- Validate input at the route boundary; authorize in services.
|
||||||
- Image bytes only fetched by separate endpoint when inspecting a single item.
|
|
||||||
|
|
||||||
## UI
|
## Next.js dynamic route params (required)
|
||||||
|
- In `app/api/**/[param]/route.ts`, treat `context.params` as async:
|
||||||
|
- `const { id } = await context.params;`
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- When changing API behavior, add/update tests.
|
||||||
|
- Prefer including negative cases: unauthorized / not-a-member / invalid input.
|
||||||
|
|
||||||
|
## UI expectations
|
||||||
- Dark mode, minimal, mobile-first.
|
- Dark mode, minimal, mobile-first.
|
||||||
- Dodger Blue accent (#1E90FF).
|
- Navbar layout: left nav dropdown, middle group selector, right user menu.
|
||||||
- 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.
|
|
||||||
|
|||||||
53
AGENTS.md
Normal file
53
AGENTS.md
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# AGENTS.md — Fiddy (External DB)
|
||||||
|
|
||||||
|
## Authority
|
||||||
|
- Source of truth: `PROJECT_INSTRUCTIONS.md` (repo root). If conflict, follow it.
|
||||||
|
- Bugfix protocol: `DEBUGGING_INSTRUCTIONS.md` (repo root).
|
||||||
|
- Do not implement features unless required to fix the bug.
|
||||||
|
|
||||||
|
## Non-negotiables
|
||||||
|
- External DB: `DATABASE_URL` points to on-prem Postgres (NOT a container).
|
||||||
|
- Dev/Prod share schema via migrations in `packages/db/migrations`.
|
||||||
|
- No cron/worker jobs. Fixes must work without background tasks.
|
||||||
|
- Server-side RBAC only. Client checks are UX only.
|
||||||
|
|
||||||
|
## Security / logging (hard rules)
|
||||||
|
- Never log secrets (passwords/tokens/cookies).
|
||||||
|
- Never log receipt bytes.
|
||||||
|
- Never log full invite codes; logs/audit store last4 only.
|
||||||
|
|
||||||
|
## Non-regression contracts
|
||||||
|
- Sessions are DB-backed (`sessions` table) and cookies are HttpOnly.
|
||||||
|
- Receipt images stored in `receipts` (`bytea`).
|
||||||
|
- Entries list endpoints must NEVER return receipt bytes.
|
||||||
|
- API responses must include `request_id`; audit logs must include `request_id`.
|
||||||
|
|
||||||
|
## Architecture boundaries (follow existing patterns; don’t invent)
|
||||||
|
1) API routes: `app/api/**/route.ts`
|
||||||
|
- Thin: parse/validate + call service, return JSON.
|
||||||
|
2) Server services: `lib/server/*`
|
||||||
|
- Own DB + authz. Must include `import "server-only";`.
|
||||||
|
3) Client wrappers: `lib/client/*`
|
||||||
|
- Typed fetch + error normalization; always send credentials.
|
||||||
|
4) Hooks: `hooks/use-*.ts`
|
||||||
|
- Primary UI-facing API layer; components avoid raw `fetch()`.
|
||||||
|
|
||||||
|
## Next.js dynamic route params (required)
|
||||||
|
- In `app/api/**/[param]/route.ts`, treat `context.params` as async:
|
||||||
|
- `const { id } = await context.params;`
|
||||||
|
|
||||||
|
## Working style
|
||||||
|
- Scan repo first; don’t guess file names or patterns.
|
||||||
|
- Make the smallest change that resolves the issue.
|
||||||
|
- Keep touched files free of TS warnings and lint errors.
|
||||||
|
- Add/update tests when API behavior changes (include negative cases).
|
||||||
|
- Keep text encoding clean (no mojibake).
|
||||||
|
|
||||||
|
## Response icon legend
|
||||||
|
Use the same status icons defined in `PROJECT_INSTRUCTIONS.md` section "Agent Response Legend (required)":
|
||||||
|
- `🔄` in progress
|
||||||
|
- `✅` completed
|
||||||
|
- `🧪` verification/test result
|
||||||
|
- `⚠️` risk/blocker/manual action
|
||||||
|
- `❌` failure
|
||||||
|
- `🧭` recommendation/next step
|
||||||
@ -1,9 +1,114 @@
|
|||||||
# Project Instructions — Fiddy (External DB)
|
# Project Instructions — Fiddy (External DB)
|
||||||
|
|
||||||
## Core expectation
|
## 1) Core expectation
|
||||||
This project connects to an external Postgres instance (on-prem server). Dev and Prod must share the same schema through migrations.
|
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)
|
## 2) Authority & doc order
|
||||||
|
1) **PROJECT_INSTRUCTIONS.md** (this file) is the source of truth.
|
||||||
|
2) **DEBUGGING_INSTRUCTIONS.md** (repo root) is required for bugfix work.
|
||||||
|
3) Other instruction files (e.g. `.github/copilot-instructions.md`) must not conflict with this doc.
|
||||||
|
|
||||||
|
If anything conflicts, follow **this** doc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Non-negotiables (hard rules)
|
||||||
|
|
||||||
|
### External DB + migrations
|
||||||
|
- `DATABASE_URL` points to **on-prem Postgres** (**NOT** a container).
|
||||||
|
- Dev/Prod share schema via migrations in: `packages/db/migrations`.
|
||||||
|
- Active migration runbook: `docs/DB_MIGRATION_WORKFLOW.md` (active set + status commands).
|
||||||
|
|
||||||
|
### No background jobs
|
||||||
|
- **No cron/worker jobs**. Any fix must work without background tasks.
|
||||||
|
|
||||||
|
### Security / logging
|
||||||
|
- **Never log secrets** (passwords, tokens, session cookies).
|
||||||
|
- **Never log receipt bytes**.
|
||||||
|
- **Never log full invite codes** — logs/audit store **last4 only**.
|
||||||
|
|
||||||
|
### Server-side authorization only
|
||||||
|
- **Server-side RBAC only.** Client checks are UX only and must not be trusted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Non-regression contracts (do not break)
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
- Custom email/password auth.
|
||||||
|
- Sessions are **DB-backed** and stored in table `sessions`.
|
||||||
|
- Session cookies are **HttpOnly**.
|
||||||
|
|
||||||
|
### Receipts
|
||||||
|
- Receipt images are stored in Postgres `bytea` table `receipts`.
|
||||||
|
- **Entries list endpoints must never return receipt image bytes.**
|
||||||
|
- Receipt bytes are fetched only via a **separate endpoint** when inspecting a single item.
|
||||||
|
|
||||||
|
### Request IDs + audit
|
||||||
|
- API must generate a **`request_id`** and return it in responses.
|
||||||
|
- Audit logs must include `request_id`.
|
||||||
|
- Audit logs must never store full invite codes (store **last4 only**).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Architecture contract (Backend ↔ Client ↔ Hooks ↔ UI)
|
||||||
|
|
||||||
|
### No-assumptions rule (required)
|
||||||
|
Before making structural changes, first scan the repo and identify:
|
||||||
|
- where `app/`, `components/`, `features/`, `hooks/`, `lib/` live
|
||||||
|
- existing API routes and helpers
|
||||||
|
- patterns already in use
|
||||||
|
Do not invent files/endpoints/conventions. If something is missing, add it **minimally** and **consistently**.
|
||||||
|
|
||||||
|
### Single mechanism rule (required)
|
||||||
|
For any cross-component state propagation concern, keep **one** canonical mechanism only:
|
||||||
|
- Context **OR** custom events **OR** cache invalidation
|
||||||
|
Do not keep old and new mechanisms in parallel. Remove superseded utilities/imports/files in the same PR.
|
||||||
|
|
||||||
|
### Layering (hard boundaries)
|
||||||
|
For every domain (auth, groups, entries, receipts, etc.) follow this flow:
|
||||||
|
|
||||||
|
1) **API Route Handlers** — `app/api/.../route.ts`
|
||||||
|
- Thin: parse/validate input, call a server service, return JSON.
|
||||||
|
- No direct DB queries in route files unless there is no existing server service.
|
||||||
|
|
||||||
|
2) **Server Services (DB + authorization)** — `lib/server/*`
|
||||||
|
- Own all DB access and authorization helpers.
|
||||||
|
- 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/receipts.ts`, `lib/server/session.ts`.
|
||||||
|
|
||||||
|
3) **Client API Wrappers** — `lib/client/*`
|
||||||
|
- Typed fetch helpers only (no React state).
|
||||||
|
- Centralize fetch + 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 is a strong reason.
|
||||||
|
|
||||||
|
### API conventions
|
||||||
|
- Prefer consistent JSON error shape:
|
||||||
|
- `{ error: { code: string, message: string }, request_id?: string }`
|
||||||
|
- Validate inputs at the route boundary (shape/type), authorize in server services.
|
||||||
|
- Mirror existing REST style used in the project.
|
||||||
|
|
||||||
|
### Next.js route params checklist (required)
|
||||||
|
For `app/api/**/[param]/route.ts`:
|
||||||
|
- Treat `context.params` as **async** and `await` it before reading properties.
|
||||||
|
- Example: `const { id } = await context.params;`
|
||||||
|
|
||||||
|
### Frontend structure preference
|
||||||
|
- Prefer domain-first structure: `features/<domain>/...` + `shared/...`.
|
||||||
|
- Use `components/*` only for compatibility shims during migrations (remove them after imports are migrated).
|
||||||
|
|
||||||
|
### Maintainability thresholds (refactor triggers)
|
||||||
|
- Component files > **400 lines** should be split into container/presentational parts.
|
||||||
|
- Hook files > **150 lines** should extract helper functions/services.
|
||||||
|
- Functions with more than **3 nested branches** should be extracted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Decisions / constraints (Group Settings)
|
||||||
- Add `GROUP_OWNER` role to group roles; migrate existing groups so the first admin becomes owner.
|
- 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`.
|
- 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.
|
- Both owner and admins can approve join requests and manage invite links.
|
||||||
@ -15,10 +120,11 @@ This project connects to an external Postgres instance (on-prem server). Dev and
|
|||||||
- Single-use links are deleted after successful use.
|
- Single-use links are deleted after successful use.
|
||||||
- Revive resets `used_at` and `revoked_at`, refreshes `expires_at`, and creates a new audit event.
|
- 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).
|
- 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.
|
- Group role icons must be consistent: 👑 owner, 🛡️ admin, 👤 member.
|
||||||
- Audit logs must never store full invite codes (store last4 only).
|
|
||||||
|
|
||||||
## Do first (vertical slice)
|
---
|
||||||
|
|
||||||
|
## 7) Do first (vertical slice)
|
||||||
1) DB migrate command + schema
|
1) DB migrate command + schema
|
||||||
2) Register/Login/Logout (custom sessions)
|
2) Register/Login/Logout (custom sessions)
|
||||||
3) Protected dashboard page
|
3) Protected dashboard page
|
||||||
@ -27,24 +133,57 @@ This project connects to an external Postgres instance (on-prem server). Dev and
|
|||||||
6) Receipt upload/download endpoints
|
6) Receipt upload/download endpoints
|
||||||
7) Settings + Reports
|
7) Settings + Reports
|
||||||
|
|
||||||
## Definition of done
|
---
|
||||||
- Works via docker-compose.dev.yml with external DB
|
|
||||||
|
## 8) Definition of done
|
||||||
|
- Works via `docker-compose.dev.yml` with external DB
|
||||||
- Migrations applied via `npm run db:migrate`
|
- Migrations applied via `npm run db:migrate`
|
||||||
- Tests + lint pass
|
- Tests + lint pass
|
||||||
- RBAC enforced server-side
|
- RBAC enforced server-side
|
||||||
- No large files
|
- No large files
|
||||||
- No TypeScript warnings or lint errors in touched files
|
- No TypeScript warnings or lint errors in touched files
|
||||||
- No new cron/worker dependencies unless explicitly approved
|
- No new cron/worker dependencies unless explicitly approved
|
||||||
|
- No orphaned utilities/hooks/contexts after refactors
|
||||||
|
- No duplicate mechanisms for the same state flow
|
||||||
|
- Text encoding remains clean in user-facing strings/docs
|
||||||
|
|
||||||
## Desktop + mobile UX checklist (required)
|
---
|
||||||
|
|
||||||
|
## 9) Desktop + mobile UX checklist (required)
|
||||||
- Touch: long-press affordance for item-level actions when no visible button.
|
- Touch: long-press affordance for item-level actions when no visible button.
|
||||||
- Mouse: hover affordance on interactive rows/cards.
|
- Mouse: hover affordance on interactive rows/cards.
|
||||||
- Tap targets remain >= 40px on mobile.
|
- Tap targets remain >= 40px on mobile.
|
||||||
- Modal overlays must close on outside click/tap.
|
- Modal overlays must close on outside click/tap.
|
||||||
- Use bubble notifications for main actions (create/update/delete/join).
|
- Use bubble notifications for main actions (create/update/delete/join).
|
||||||
- Add Playwright UI tests for new UI features and critical flows.
|
- 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.
|
## 10) Tests (required)
|
||||||
|
- Add/update tests for API behavior changes (auth, groups, entries, receipts).
|
||||||
|
- Include negative cases where applicable:
|
||||||
|
- unauthorized
|
||||||
|
- not-a-member
|
||||||
|
- invalid input
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11) Agent Response Legend (required)
|
||||||
|
Use emoji/icons in agent progress and final responses so status is obvious at a glance.
|
||||||
|
|
||||||
|
Legend:
|
||||||
|
- `🔄` in progress
|
||||||
|
- `✅` completed
|
||||||
|
- `🧪` test/lint/verification result
|
||||||
|
- `📄` documentation update
|
||||||
|
- `🗄️` database or migration change
|
||||||
|
- `🚀` deploy/release step
|
||||||
|
- `⚠️` risk, blocker, or manual operator action needed
|
||||||
|
- `❌` failed command or unsuccessful attempt
|
||||||
|
- `ℹ️` informational context
|
||||||
|
- `🧭` recommendation or next-step option
|
||||||
|
|
||||||
|
Usage rules:
|
||||||
|
- Include at least one status icon in each substantive agent response.
|
||||||
|
- Use one icon per bullet/line; avoid icon spam.
|
||||||
|
- Keep icon meaning consistent with this legend.
|
||||||
|
|||||||
13
apps/scheduler/package.json
Normal file
13
apps/scheduler/package.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "@fiddy/scheduler",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "tsx src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"pg": "^8.13.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
168
apps/scheduler/src/index.ts
Normal file
168
apps/scheduler/src/index.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
type ScheduleRow = {
|
||||||
|
id: number;
|
||||||
|
group_id: number;
|
||||||
|
created_by: number;
|
||||||
|
entry_type: "SPENDING" | "INCOME";
|
||||||
|
amount_dollars: string | number;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
purchase_type: string;
|
||||||
|
notes: string | null;
|
||||||
|
next_run_on: string;
|
||||||
|
frequency: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY";
|
||||||
|
interval_count: number;
|
||||||
|
end_condition: "NEVER" | "AFTER_COUNT" | "BY_DATE";
|
||||||
|
end_count: number | null;
|
||||||
|
end_date: string | null;
|
||||||
|
run_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_POLL_MS = 60_000;
|
||||||
|
const DEFAULT_BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
function getPollMs() {
|
||||||
|
const value = Number(process.env.SCHEDULER_POLL_MS || DEFAULT_POLL_MS);
|
||||||
|
if (!Number.isFinite(value) || value < 1_000) return DEFAULT_POLL_MS;
|
||||||
|
return Math.floor(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBatchSize() {
|
||||||
|
const value = Number(process.env.SCHEDULER_BATCH_SIZE || DEFAULT_BATCH_SIZE);
|
||||||
|
if (!Number.isFinite(value) || value < 1) return DEFAULT_BATCH_SIZE;
|
||||||
|
return Math.floor(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInterval(dateIso: string, frequency: ScheduleRow["frequency"], intervalCount: number) {
|
||||||
|
const safeInterval = Math.max(1, Number(intervalCount || 1));
|
||||||
|
const date = new Date(`${dateIso}T00:00:00Z`);
|
||||||
|
if (frequency === "DAILY") date.setUTCDate(date.getUTCDate() + safeInterval);
|
||||||
|
else if (frequency === "WEEKLY") date.setUTCDate(date.getUTCDate() + safeInterval * 7);
|
||||||
|
else if (frequency === "MONTHLY") date.setUTCMonth(date.getUTCMonth() + safeInterval);
|
||||||
|
else date.setUTCFullYear(date.getUTCFullYear() + safeInterval);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldDeactivate(input: { endCondition: ScheduleRow["end_condition"]; endCount: number | null; endDate: string | null; runCount: number; nextRunOn: string }) {
|
||||||
|
if (input.endCondition === "AFTER_COUNT" && input.endCount != null) {
|
||||||
|
return input.runCount >= input.endCount;
|
||||||
|
}
|
||||||
|
if (input.endCondition === "BY_DATE" && input.endDate) {
|
||||||
|
return input.nextRunOn > input.endDate;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOnce(pool: pg.Pool) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
const batchSize = getBatchSize();
|
||||||
|
let processed = 0;
|
||||||
|
try {
|
||||||
|
await client.query("begin");
|
||||||
|
const dueRows = (await client.query<ScheduleRow>(
|
||||||
|
`select id, group_id, created_by, entry_type, amount_dollars, necessity, purchase_type, notes,
|
||||||
|
next_run_on, frequency, interval_count, end_condition, end_count, end_date, run_count
|
||||||
|
from schedules
|
||||||
|
where is_active=true and next_run_on <= (now() at time zone 'UTC')::date
|
||||||
|
order by next_run_on asc, id asc
|
||||||
|
for update skip locked
|
||||||
|
limit $1`,
|
||||||
|
[batchSize]
|
||||||
|
)).rows;
|
||||||
|
|
||||||
|
for (const row of dueRows) {
|
||||||
|
const runOn = row.next_run_on;
|
||||||
|
const entryInsert = await client.query<{ id: number }>(
|
||||||
|
`insert into entries(group_id, created_by, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, source_schedule_id)
|
||||||
|
values($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||||
|
on conflict (source_schedule_id, occurred_at) do nothing
|
||||||
|
returning id`,
|
||||||
|
[
|
||||||
|
row.group_id,
|
||||||
|
row.created_by,
|
||||||
|
row.entry_type,
|
||||||
|
Number(row.amount_dollars),
|
||||||
|
runOn,
|
||||||
|
row.necessity,
|
||||||
|
row.purchase_type,
|
||||||
|
row.notes,
|
||||||
|
row.id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (entryInsert.rows[0]?.id) {
|
||||||
|
await client.query(
|
||||||
|
`insert into entry_tags(entry_id, tag_id)
|
||||||
|
select $1, st.tag_id
|
||||||
|
from schedule_tags st
|
||||||
|
where st.schedule_id=$2
|
||||||
|
on conflict do nothing`,
|
||||||
|
[entryInsert.rows[0].id, row.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRunOn = addInterval(runOn, row.frequency, row.interval_count);
|
||||||
|
const nextRunCount = Number(row.run_count || 0) + 1;
|
||||||
|
const deactivate = shouldDeactivate({
|
||||||
|
endCondition: row.end_condition,
|
||||||
|
endCount: row.end_count,
|
||||||
|
endDate: row.end_date,
|
||||||
|
runCount: nextRunCount,
|
||||||
|
nextRunOn
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`update schedules
|
||||||
|
set next_run_on=$1,
|
||||||
|
last_run_on=$2,
|
||||||
|
run_count=$3,
|
||||||
|
is_active=$4,
|
||||||
|
updated_at=now()
|
||||||
|
where id=$5`,
|
||||||
|
[nextRunOn, runOn, nextRunCount, !deactivate, row.id]
|
||||||
|
);
|
||||||
|
processed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("commit");
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("rollback");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required");
|
||||||
|
|
||||||
|
|
||||||
|
const pool = new pg.Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
ssl: process.env.DATABASE_SSL === "false" ? false : { rejectUnauthorized: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
const pollMs = getPollMs();
|
||||||
|
console.log(`[scheduler] started, poll_ms=${pollMs}, batch_size=${getBatchSize()}`);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const processed = await runOnce(pool);
|
||||||
|
if (processed > 0) console.log(`[scheduler] processed=${processed}`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`[scheduler] cycle_failed=${message}`);
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, pollMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`[scheduler] fatal=${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
11
apps/scheduler/tsconfig.json
Normal file
11
apps/scheduler/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@ -5,10 +5,10 @@ import { calculateBucketUsage } from "../lib/shared/bucket-usage";
|
|||||||
const today = "2026-02-11";
|
const today = "2026-02-11";
|
||||||
|
|
||||||
test("calculateBucketUsage matches tag subset", () => {
|
test("calculateBucketUsage matches tag subset", () => {
|
||||||
const bucket = { tags: ["groceries", "weekly"], necessity: "BOTH", windowDays: 30 };
|
const bucket = { tags: ["groceries", "weekly"], necessity: "BOTH" as const, windowDays: 30 };
|
||||||
const entries = [
|
const entries = [
|
||||||
{ amountDollars: 20, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["groceries", "weekly", "extra"], isRecurring: false, entryType: "SPENDING" as const },
|
{ amountDollars: 20, occurredAt: "2026-02-10", necessity: "NECESSARY" as const, tags: ["groceries", "weekly", "extra"], entryType: "SPENDING" as const },
|
||||||
{ amountDollars: 15, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["groceries"], isRecurring: false, entryType: "SPENDING" as const }
|
{ amountDollars: 15, occurredAt: "2026-02-10", necessity: "NECESSARY" as const, tags: ["groceries"], entryType: "SPENDING" as const }
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = calculateBucketUsage(bucket, entries, today);
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
@ -16,10 +16,10 @@ test("calculateBucketUsage matches tag subset", () => {
|
|||||||
assert.equal(result.matchedCount, 1);
|
assert.equal(result.matchedCount, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("calculateBucketUsage excludes recurring entries", () => {
|
test("calculateBucketUsage ignores non-spending entries", () => {
|
||||||
const bucket = { tags: ["rent"], necessity: "BOTH", windowDays: 30 };
|
const bucket = { tags: ["rent"], necessity: "BOTH" as const, windowDays: 30 };
|
||||||
const entries = [
|
const entries = [
|
||||||
{ amountDollars: 500, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["rent"], isRecurring: true, entryType: "SPENDING" as const }
|
{ amountDollars: 500, occurredAt: "2026-02-10", necessity: "NECESSARY" as const, tags: ["rent"], entryType: "INCOME" as const }
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = calculateBucketUsage(bucket, entries, today);
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
@ -28,11 +28,11 @@ test("calculateBucketUsage excludes recurring entries", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("calculateBucketUsage applies windowDays filtering", () => {
|
test("calculateBucketUsage applies windowDays filtering", () => {
|
||||||
const bucket = { tags: [], necessity: "BOTH", windowDays: 3 };
|
const bucket = { tags: [], necessity: "BOTH" as const, windowDays: 3 };
|
||||||
const entries = [
|
const entries = [
|
||||||
{ amountDollars: 10, occurredAt: "2026-02-11", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const },
|
{ amountDollars: 10, occurredAt: "2026-02-11", necessity: "NECESSARY" as const, tags: [], entryType: "SPENDING" as const },
|
||||||
{ amountDollars: 10, occurredAt: "2026-02-09", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const },
|
{ amountDollars: 10, occurredAt: "2026-02-09", necessity: "NECESSARY" as const, tags: [], entryType: "SPENDING" as const },
|
||||||
{ amountDollars: 10, occurredAt: "2026-02-08", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const }
|
{ amountDollars: 10, occurredAt: "2026-02-08", necessity: "NECESSARY" as const, tags: [], entryType: "SPENDING" as const }
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = calculateBucketUsage(bucket, entries, today);
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
@ -41,10 +41,10 @@ test("calculateBucketUsage applies windowDays filtering", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("calculateBucketUsage accepts all when bucket necessity is BOTH", () => {
|
test("calculateBucketUsage accepts all when bucket necessity is BOTH", () => {
|
||||||
const bucket = { tags: [], necessity: "BOTH", windowDays: 30 };
|
const bucket = { tags: [], necessity: "BOTH" as const, windowDays: 30 };
|
||||||
const entries = [
|
const entries = [
|
||||||
{ amountDollars: 12, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const },
|
{ amountDollars: 12, occurredAt: "2026-02-10", necessity: "NECESSARY" as const, tags: [], entryType: "SPENDING" as const },
|
||||||
{ amountDollars: 8, occurredAt: "2026-02-10", necessity: "UNNECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const }
|
{ amountDollars: 8, occurredAt: "2026-02-10", necessity: "UNNECESSARY" as const, tags: [], entryType: "SPENDING" as const }
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = calculateBucketUsage(bucket, entries, today);
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
@ -53,10 +53,10 @@ test("calculateBucketUsage accepts all when bucket necessity is BOTH", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("calculateBucketUsage halves BOTH entries when bucket not BOTH", () => {
|
test("calculateBucketUsage halves BOTH entries when bucket not BOTH", () => {
|
||||||
const bucket = { tags: [], necessity: "NECESSARY", windowDays: 30 };
|
const bucket = { tags: [], necessity: "NECESSARY" as const, windowDays: 30 };
|
||||||
const entries = [
|
const entries = [
|
||||||
{ amountDollars: 10, occurredAt: "2026-02-10", necessity: "BOTH", tags: [], isRecurring: false, entryType: "SPENDING" as const },
|
{ amountDollars: 10, occurredAt: "2026-02-10", necessity: "BOTH" as const, tags: [], entryType: "SPENDING" as const },
|
||||||
{ amountDollars: 10, occurredAt: "2026-02-10", necessity: "UNNECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const }
|
{ amountDollars: 10, occurredAt: "2026-02-10", necessity: "UNNECESSARY" as const, tags: [], entryType: "SPENDING" as const }
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = calculateBucketUsage(bucket, entries, today);
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
|
|||||||
@ -75,7 +75,7 @@ test("buckets CRUD", async t => {
|
|||||||
assert.equal(updated?.name, "Groceries+");
|
assert.equal(updated?.name, "Groceries+");
|
||||||
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
|
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
|
||||||
|
|
||||||
await deleteBucket({ id: bucket.id, groupId });
|
await deleteBucket({ id: bucket.id, groupId, userId });
|
||||||
const listAfter = await listBuckets(groupId);
|
const listAfter = await listBuckets(groupId);
|
||||||
assert.equal(listAfter.length, 0);
|
assert.equal(listAfter.length, 0);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
14
apps/web/__tests__/errors-response.test.ts
Normal file
14
apps/web/__tests__/errors-response.test.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { ApiError, toErrorResponse } from "../lib/server/errors";
|
||||||
|
|
||||||
|
test("toErrorResponse includes request_id alias", async () => {
|
||||||
|
const prevDebug = process.env.DEBUG_API;
|
||||||
|
process.env.DEBUG_API = "0";
|
||||||
|
const { status, body } = toErrorResponse(new ApiError("UNAUTHORIZED"), "GET /api/example", "req_test_1");
|
||||||
|
assert.equal(status, 401);
|
||||||
|
assert.equal(body.requestId, "req_test_1");
|
||||||
|
assert.equal(body.request_id, "req_test_1");
|
||||||
|
assert.equal(body.error.code, "UNAUTHORIZED");
|
||||||
|
process.env.DEBUG_API = prevDebug;
|
||||||
|
});
|
||||||
@ -104,7 +104,7 @@ test("group settings require admin", async t => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => setGroupSettings({ userId: memberId!, groupId, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" }),
|
() => setGroupSettings({ userId: memberId!, groupId: groupId!, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" }),
|
||||||
{ message: "FORBIDDEN" }
|
{ message: "FORBIDDEN" }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -61,11 +61,11 @@ test("invite link auto-accept adds member", async t => {
|
|||||||
const result = await acceptInviteLink({ userId: memberId!, token: link.token, requestId: "test-accept" });
|
const result = await acceptInviteLink({ userId: memberId!, token: link.token, requestId: "test-accept" });
|
||||||
assert.equal(result.status, "JOINED");
|
assert.equal(result.status, "JOINED");
|
||||||
|
|
||||||
const { rowCount } = await client.query(
|
const queryResult = await client.query(
|
||||||
"select 1 from group_members where group_id=$1 and user_id=$2",
|
"select 1 from group_members where group_id=$1 and user_id=$2",
|
||||||
[groupId, memberId]
|
[groupId, memberId]
|
||||||
);
|
);
|
||||||
assert.equal(rowCount, 1);
|
assert.equal(queryResult.rows.length, 1);
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
|
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
|
||||||
client.release();
|
client.release();
|
||||||
|
|||||||
139
apps/web/__tests__/rate-limit.test.ts
Normal file
139
apps/web/__tests__/rate-limit.test.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
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 { ApiError } from "../lib/server/errors";
|
||||||
|
import { enforceAuthRateLimit, enforceIpRateLimit, enforceUserWriteRateLimit } from "../lib/server/rate-limit";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
async function ensureRateLimitTable() {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.query(`
|
||||||
|
create table if not exists rate_limits(
|
||||||
|
key text primary key,
|
||||||
|
window_start timestamptz not null,
|
||||||
|
count integer not null default 0,
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("auth rate limit blocks when threshold is exceeded", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
await ensureRateLimitTable();
|
||||||
|
const pool = getPool();
|
||||||
|
const marker = Date.now();
|
||||||
|
const ip = `test-ip-${marker}`;
|
||||||
|
const identifier = `rate_limit_${marker}@example.com`;
|
||||||
|
try {
|
||||||
|
await enforceAuthRateLimit({
|
||||||
|
route: "login",
|
||||||
|
ip,
|
||||||
|
identifier,
|
||||||
|
ipLimit: 1,
|
||||||
|
identifierLimit: 1,
|
||||||
|
windowMs: 60_000
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => enforceAuthRateLimit({
|
||||||
|
route: "login",
|
||||||
|
ip,
|
||||||
|
identifier,
|
||||||
|
ipLimit: 1,
|
||||||
|
identifierLimit: 1,
|
||||||
|
windowMs: 60_000
|
||||||
|
}),
|
||||||
|
(error: unknown) => error instanceof ApiError && error.code === "RATE_LIMITED"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await pool.query("delete from rate_limits where key like $1 or key like $2", [
|
||||||
|
`auth:login:ip:${ip}%`,
|
||||||
|
`auth:login:identifier:${identifier}%`
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("user write rate limit blocks when threshold is exceeded", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
await ensureRateLimitTable();
|
||||||
|
const pool = getPool();
|
||||||
|
const userId = 987654;
|
||||||
|
const scope = `test_scope_${Date.now()}`;
|
||||||
|
try {
|
||||||
|
await enforceUserWriteRateLimit({
|
||||||
|
userId,
|
||||||
|
scope,
|
||||||
|
limit: 1,
|
||||||
|
windowMs: 60_000
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => enforceUserWriteRateLimit({
|
||||||
|
userId,
|
||||||
|
scope,
|
||||||
|
limit: 1,
|
||||||
|
windowMs: 60_000
|
||||||
|
}),
|
||||||
|
(error: unknown) => error instanceof ApiError && error.code === "RATE_LIMITED"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await pool.query("delete from rate_limits where key = $1", [
|
||||||
|
`write:user:${userId}:scope:${scope}`
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ip rate limit blocks when threshold is exceeded", async t => {
|
||||||
|
if (!hasDb) {
|
||||||
|
t.skip("DATABASE_URL not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
|
||||||
|
|
||||||
|
await ensureRateLimitTable();
|
||||||
|
const pool = getPool();
|
||||||
|
const ip = `203.0.113.${Math.floor(Math.random() * 200)}`;
|
||||||
|
const scope = `test_ip_scope_${Date.now()}`;
|
||||||
|
const normalizedScope = scope.toLowerCase();
|
||||||
|
const normalizedIp = ip.toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await enforceIpRateLimit({
|
||||||
|
scope,
|
||||||
|
ip,
|
||||||
|
limit: 1,
|
||||||
|
windowMs: 60_000
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => enforceIpRateLimit({
|
||||||
|
scope,
|
||||||
|
ip,
|
||||||
|
limit: 1,
|
||||||
|
windowMs: 60_000
|
||||||
|
}),
|
||||||
|
(error: unknown) => error instanceof ApiError && error.code === "RATE_LIMITED"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await pool.query("delete from rate_limits where key = $1", [
|
||||||
|
`ip:scope:${normalizedScope}:ip:${normalizedIp}`
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -56,11 +56,9 @@ test("recurring entries list", async t => {
|
|||||||
purchaseType: "Rent",
|
purchaseType: "Rent",
|
||||||
notes: "Monthly rent",
|
notes: "Monthly rent",
|
||||||
tags: ["rent"],
|
tags: ["rent"],
|
||||||
isRecurring: true,
|
|
||||||
frequency: "MONTHLY",
|
frequency: "MONTHLY",
|
||||||
intervalCount: 1,
|
intervalCount: 1,
|
||||||
endCondition: "NEVER",
|
endCondition: "NEVER"
|
||||||
nextRunAt: "2026-02-01"
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const list = await listRecurringEntries(groupId);
|
const list = await listRecurringEntries(groupId);
|
||||||
@ -69,7 +67,7 @@ test("recurring entries list", async t => {
|
|||||||
} finally {
|
} finally {
|
||||||
if (groupId) {
|
if (groupId) {
|
||||||
const list = await listRecurringEntries(groupId);
|
const list = await listRecurringEntries(groupId);
|
||||||
for (const entry of list) await deleteRecurringEntry({ id: entry.id, groupId });
|
for (const entry of list) await deleteRecurringEntry({ id: entry.id, groupId, userId: userId! });
|
||||||
}
|
}
|
||||||
await cleanupTestData(client, { userIds: [userId], groupId });
|
await cleanupTestData(client, { userIds: [userId], groupId });
|
||||||
client.release();
|
client.release();
|
||||||
|
|||||||
100
apps/web/__tests__/schedules.test.ts
Normal file
100
apps/web/__tests__/schedules.test.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
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 { createSchedule, deleteSchedule, listSchedules, updateSchedule } from "../lib/server/schedules";
|
||||||
|
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("schedules 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",
|
||||||
|
[`schedule_${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",
|
||||||
|
["Schedules Test", uniqueInviteCode("Q"), 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", "home"] });
|
||||||
|
|
||||||
|
const created = await createSchedule({
|
||||||
|
groupId,
|
||||||
|
userId,
|
||||||
|
entryType: "SPENDING",
|
||||||
|
amountDollars: 1200,
|
||||||
|
necessity: "NECESSARY",
|
||||||
|
purchaseType: "Rent",
|
||||||
|
notes: "Monthly rent",
|
||||||
|
tags: ["rent", "home"],
|
||||||
|
startsOn: "2026-03-01",
|
||||||
|
frequency: "MONTHLY",
|
||||||
|
intervalCount: 1,
|
||||||
|
endCondition: "NEVER",
|
||||||
|
createEntryNow: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const listed = await listSchedules(groupId);
|
||||||
|
assert.equal(listed.length, 1);
|
||||||
|
assert.equal(listed[0].id, created.id);
|
||||||
|
assert.equal(listed[0].frequency, "MONTHLY");
|
||||||
|
assert.deepEqual(listed[0].tags.sort(), ["home", "rent"]);
|
||||||
|
|
||||||
|
const updated = await updateSchedule({
|
||||||
|
id: created.id,
|
||||||
|
groupId,
|
||||||
|
userId,
|
||||||
|
entryType: "SPENDING",
|
||||||
|
amountDollars: 1300,
|
||||||
|
necessity: "NECESSARY",
|
||||||
|
purchaseType: "Rent",
|
||||||
|
notes: "Updated rent",
|
||||||
|
tags: ["rent"],
|
||||||
|
startsOn: "2026-03-01",
|
||||||
|
frequency: "MONTHLY",
|
||||||
|
intervalCount: 1,
|
||||||
|
endCondition: "AFTER_COUNT",
|
||||||
|
endCount: 3,
|
||||||
|
nextRunOn: "2026-04-01",
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
assert.ok(updated);
|
||||||
|
assert.equal(updated?.amountDollars, 1300);
|
||||||
|
assert.equal(updated?.endCondition, "AFTER_COUNT");
|
||||||
|
assert.equal(updated?.endCount, 3);
|
||||||
|
assert.deepEqual(updated?.tags, ["rent"]);
|
||||||
|
|
||||||
|
await deleteSchedule({ id: created.id, groupId, userId });
|
||||||
|
const afterDelete = await listSchedules(groupId);
|
||||||
|
assert.equal(afterDelete.length, 0);
|
||||||
|
} finally {
|
||||||
|
await cleanupTestData(client, { userIds: [userId], groupId });
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -55,9 +55,7 @@ test("entries CRUD", async t => {
|
|||||||
necessity: "NECESSARY",
|
necessity: "NECESSARY",
|
||||||
purchaseType: "Groceries",
|
purchaseType: "Groceries",
|
||||||
notes: "Test",
|
notes: "Test",
|
||||||
tags: ["groceries", "weekly"],
|
tags: ["groceries", "weekly"]
|
||||||
isRecurring: false,
|
|
||||||
intervalCount: 1
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const list = await listEntries(groupId);
|
const list = await listEntries(groupId);
|
||||||
@ -76,19 +74,14 @@ test("entries CRUD", async t => {
|
|||||||
necessity: "BOTH",
|
necessity: "BOTH",
|
||||||
purchaseType: "Groceries",
|
purchaseType: "Groceries",
|
||||||
notes: "Updated",
|
notes: "Updated",
|
||||||
tags: ["groceries"],
|
tags: ["groceries"]
|
||||||
isRecurring: true,
|
|
||||||
frequency: "MONTHLY",
|
|
||||||
intervalCount: 1,
|
|
||||||
endCondition: "NEVER",
|
|
||||||
nextRunAt: "2026-02-02"
|
|
||||||
});
|
});
|
||||||
assert.ok(updated);
|
assert.ok(updated);
|
||||||
assert.equal(updated?.amountDollars, 15);
|
assert.equal(updated?.amountDollars, 15);
|
||||||
assert.equal(updated?.entryType, "INCOME");
|
assert.equal(updated?.entryType, "INCOME");
|
||||||
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
|
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
|
||||||
|
|
||||||
await deleteEntry({ id: entry.id, groupId });
|
await deleteEntry({ id: entry.id, groupId, userId });
|
||||||
const listAfter = await listEntries(groupId);
|
const listAfter = await listEntries(groupId);
|
||||||
assert.equal(listAfter.length, 0);
|
assert.equal(listAfter.length, 0);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -32,6 +32,15 @@ export async function cleanupTestData(client: PoolClient, args: CleanupArgs) {
|
|||||||
"delete from entry_tags where entry_id in (select id from entries where group_id=$1)",
|
"delete from entry_tags where entry_id in (select id from entries where group_id=$1)",
|
||||||
[groupId]
|
[groupId]
|
||||||
);
|
);
|
||||||
|
await safeQuery(
|
||||||
|
"delete from schedule_tags where schedule_id in (select id from schedules where group_id=$1)",
|
||||||
|
[groupId]
|
||||||
|
);
|
||||||
|
await safeQuery(
|
||||||
|
"delete from entries where source_schedule_id in (select id from schedules where group_id=$1)",
|
||||||
|
[groupId]
|
||||||
|
);
|
||||||
|
await safeQuery("delete from schedules 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 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 buckets where group_id=$1", [groupId]);
|
||||||
await safeQuery("delete from entries where group_id=$1", [groupId]);
|
await safeQuery("delete from entries where group_id=$1", [groupId]);
|
||||||
@ -64,4 +73,3 @@ export async function cleanupTestDataFromPool(pool: Pool, args: CleanupArgs) {
|
|||||||
client.release();
|
client.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
45
apps/web/__tests__/user-settings.test.ts
Normal file
45
apps/web/__tests__/user-settings.test.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
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 { getUserSettings, setUserSettings } from "../lib/server/user-settings";
|
||||||
|
import { cleanupTestData } 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("user settings default and update", 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;
|
||||||
|
try {
|
||||||
|
const userRes = await client.query(
|
||||||
|
"insert into users(email, password_hash) values($1,$2) returning id",
|
||||||
|
[`user_settings_${Date.now()}@example.com`, "hash"]
|
||||||
|
);
|
||||||
|
userId = userRes.rows[0].id as number;
|
||||||
|
|
||||||
|
const initial = await getUserSettings(userId);
|
||||||
|
assert.equal(initial.entryPanelPageSize, 10);
|
||||||
|
|
||||||
|
const updated = await setUserSettings({ userId, entryPanelPageSize: 25 });
|
||||||
|
assert.equal(updated.entryPanelPageSize, 25);
|
||||||
|
|
||||||
|
const readBack = await getUserSettings(userId);
|
||||||
|
assert.equal(readBack.entryPanelPageSize, 25);
|
||||||
|
} finally {
|
||||||
|
await cleanupTestData(client, { userIds: [userId] });
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -2,25 +2,28 @@ import { NextResponse } from "next/server";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getSessionCookieName } from "@/lib/server/auth";
|
import { getSessionCookieName } from "@/lib/server/auth";
|
||||||
import { loginUser } from "@/lib/server/auth-service";
|
import { loginUser } from "@/lib/server/auth-service";
|
||||||
|
import { enforceAuthRateLimit } from "@/lib/server/rate-limit";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const { requestId, ip } = await getRequestMeta();
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const email = String(body?.email || "").trim().toLowerCase();
|
const email = String(body?.email || "").trim().toLowerCase();
|
||||||
const password = String(body?.password || "");
|
const password = String(body?.password || "");
|
||||||
const remember = Boolean(body?.remember ?? true);
|
const remember = Boolean(body?.remember ?? true);
|
||||||
|
|
||||||
if (!email || !password)
|
|
||||||
return NextResponse.json({ error: { code: "MISSING_CREDENTIALS", message: "Missing credentials" } }, { status: 400 });
|
|
||||||
|
|
||||||
let user;
|
let user;
|
||||||
let session;
|
let session;
|
||||||
try {
|
try {
|
||||||
|
await enforceAuthRateLimit({ route: "login", ip, identifier: email });
|
||||||
|
if (!email || !password)
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_CREDENTIALS", message: "Missing credentials" } }, { status: 400 });
|
||||||
const result = await loginUser({ email, password, remember });
|
const result = await loginUser({ email, password, remember });
|
||||||
user = result.user;
|
user = result.user;
|
||||||
session = result.session;
|
session = result.session;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/auth/login");
|
const { status, body } = toErrorResponse(e, "POST /api/auth/login", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
@ -32,5 +35,5 @@ export async function POST(req: Request) {
|
|||||||
path: "/"
|
path: "/"
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ user });
|
return NextResponse.json({ requestId, request_id: requestId, user });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,12 @@ import { NextResponse } from "next/server";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getSessionCookieName } from "@/lib/server/auth";
|
import { getSessionCookieName } from "@/lib/server/auth";
|
||||||
import { logoutUser } from "@/lib/server/auth-service";
|
import { logoutUser } from "@/lib/server/auth-service";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(getSessionCookieName())?.value;
|
const token = cookieStore.get(getSessionCookieName())?.value;
|
||||||
if (token)
|
if (token)
|
||||||
@ -16,5 +20,9 @@ export async function POST() {
|
|||||||
path: "/"
|
path: "/"
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/auth/logout", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,17 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getSessionUser } from "@/lib/server/session";
|
import { getSessionUser } from "@/lib/server/session";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user)
|
if (!user)
|
||||||
return NextResponse.json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } }, { status: 401 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "UNAUTHORIZED", message: "Unauthorized" } }, { status: 401 });
|
||||||
return NextResponse.json({ user });
|
return NextResponse.json({ requestId, request_id: requestId, user });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/auth/me", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,27 +2,30 @@ import { NextResponse } from "next/server";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getSessionCookieName, getSessionTtlMs } from "@/lib/server/auth";
|
import { getSessionCookieName, getSessionTtlMs } from "@/lib/server/auth";
|
||||||
import { registerUser } from "@/lib/server/auth-service";
|
import { registerUser } from "@/lib/server/auth-service";
|
||||||
|
import { enforceAuthRateLimit } from "@/lib/server/rate-limit";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const { requestId, ip } = await getRequestMeta();
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const email = String(body?.email || "").trim().toLowerCase();
|
const email = String(body?.email || "").trim().toLowerCase();
|
||||||
const password = String(body?.password || "");
|
const password = String(body?.password || "");
|
||||||
const displayName = String(body?.displayName || "").trim();
|
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 user;
|
||||||
let session;
|
let session;
|
||||||
try {
|
try {
|
||||||
|
await enforceAuthRateLimit({ route: "register", ip, identifier: email });
|
||||||
|
if (!email || !email.includes("@"))
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_EMAIL", message: "Invalid email" } }, { status: 400 });
|
||||||
|
if (password.length < 8)
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "PASSWORD_TOO_SHORT", message: "Password too short" } }, { status: 400 });
|
||||||
const result = await registerUser({ email, password, displayName });
|
const result = await registerUser({ email, password, displayName });
|
||||||
user = result.user;
|
user = result.user;
|
||||||
session = result.session;
|
session = result.session;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/auth/register");
|
const { status, body } = toErrorResponse(e, "POST /api/auth/register", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
@ -34,5 +37,5 @@ export async function POST(req: Request) {
|
|||||||
path: "/"
|
path: "/"
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ user });
|
return NextResponse.json({ requestId, request_id: requestId, user });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const name = String(body?.name || "").trim();
|
const name = String(body?.name || "").trim();
|
||||||
@ -30,13 +30,13 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
|
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
|
||||||
|
|
||||||
if (!name)
|
if (!name)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
|
||||||
if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0))
|
if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
|
||||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||||
if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365)
|
if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365)
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
|
||||||
|
|
||||||
const bucket = await updateBucket({
|
const bucket = await updateBucket({
|
||||||
id,
|
id,
|
||||||
@ -51,9 +51,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
windowDays
|
windowDays
|
||||||
});
|
});
|
||||||
if (!bucket) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
if (!bucket) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
||||||
|
|
||||||
return NextResponse.json({ requestId, bucket });
|
return NextResponse.json({ requestId, request_id: requestId, bucket });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "PATCH /api/buckets/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "PATCH /api/buckets/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -67,10 +67,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
await deleteBucket({ id, groupId });
|
await deleteBucket({ id, groupId, userId: user.id });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "DELETE /api/buckets/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "DELETE /api/buckets/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { createBucket, listBuckets, requireActiveGroup } from "@/lib/server/buckets";
|
import { createBucket, listBuckets, requireActiveGroup } from "@/lib/server/buckets";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -16,7 +16,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const buckets = await listBuckets(groupId);
|
const buckets = await listBuckets(groupId);
|
||||||
return NextResponse.json({ requestId, buckets });
|
return NextResponse.json({ requestId, request_id: requestId, buckets });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/buckets", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/buckets", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -38,13 +38,13 @@ export async function POST(req: Request) {
|
|||||||
const necessity = String(body?.necessity || "BOTH").toUpperCase();
|
const necessity = String(body?.necessity || "BOTH").toUpperCase();
|
||||||
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
|
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 (!name) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
|
||||||
if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0))
|
if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
|
||||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||||
if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365)
|
if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365)
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
|
||||||
|
|
||||||
const bucket = await createBucket({
|
const bucket = await createBucket({
|
||||||
groupId,
|
groupId,
|
||||||
@ -59,9 +59,10 @@ export async function POST(req: Request) {
|
|||||||
windowDays
|
windowDays
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ requestId, bucket });
|
return NextResponse.json({ requestId, request_id: requestId, bucket });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/buckets", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/buckets", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const amountDollars = Number(body?.amountDollars || 0);
|
const amountDollars = Number(body?.amountDollars || 0);
|
||||||
@ -27,30 +27,16 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
|
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
|
||||||
const notes = String(body?.notes || "").trim();
|
const notes = String(body?.notes || "").trim();
|
||||||
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
|
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;
|
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
||||||
|
|
||||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: 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 (!occurredAt) return NextResponse.json({ requestId, request_id: 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 (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||||
if (!['SPENDING', 'INCOME'].includes(entryType))
|
if (!['SPENDING', 'INCOME'].includes(entryType))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: 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({
|
const entry = await updateEntry({
|
||||||
id,
|
id,
|
||||||
groupId,
|
groupId,
|
||||||
@ -62,19 +48,12 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
purchaseType,
|
purchaseType,
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
tags,
|
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
|
bucketId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!entry) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
if (!entry) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
||||||
|
|
||||||
return NextResponse.json({ requestId, entry });
|
return NextResponse.json({ requestId, request_id: requestId, entry });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "PATCH /api/entries/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "PATCH /api/entries/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -88,10 +67,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
await deleteEntry({ id, groupId });
|
await deleteEntry({ id, groupId, userId: user.id });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "DELETE /api/entries/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "DELETE /api/entries/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { createEntry, listEntries, requireActiveGroup } from "@/lib/server/entries";
|
import { createEntry, listEntries, requireActiveGroup } from "@/lib/server/entries";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -16,7 +16,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const entries = await listEntries(groupId);
|
const entries = await listEntries(groupId);
|
||||||
return NextResponse.json({ requestId, entries });
|
return NextResponse.json({ requestId, request_id: requestId, entries });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/entries", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/entries", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -36,30 +36,16 @@ export async function POST(req: Request) {
|
|||||||
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
|
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
|
||||||
const notes = String(body?.notes || "").trim();
|
const notes = String(body?.notes || "").trim();
|
||||||
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
|
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;
|
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
||||||
|
|
||||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: 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 (!occurredAt) return NextResponse.json({ requestId, request_id: 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 (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||||
if (!['SPENDING', 'INCOME'].includes(entryType))
|
if (!['SPENDING', 'INCOME'].includes(entryType))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: 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({
|
const entry = await createEntry({
|
||||||
groupId,
|
groupId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@ -70,19 +56,13 @@ export async function POST(req: Request) {
|
|||||||
purchaseType,
|
purchaseType,
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
tags,
|
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
|
bucketId
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ requestId, entry });
|
return NextResponse.json({ requestId, request_id: requestId, entry });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/entries", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/entries", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { getActiveGroupId, listGroups, setActiveGroupForUser } from "@/lib/server/groups";
|
import { getActiveGroupId, listGroups, setActiveGroupForUser } from "@/lib/server/groups";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -11,7 +11,7 @@ export async function GET() {
|
|||||||
const groups = await listGroups(user.id);
|
const groups = await listGroups(user.id);
|
||||||
const activeGroupId = await getActiveGroupId(user.id);
|
const activeGroupId = await getActiveGroupId(user.id);
|
||||||
const active = groups.find(group => Number(group.id) === activeGroupId) || null;
|
const active = groups.find(group => Number(group.id) === activeGroupId) || null;
|
||||||
return NextResponse.json({ requestId, active });
|
return NextResponse.json({ requestId, request_id: requestId, active });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups/active", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups/active", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -24,12 +24,13 @@ export async function POST(req: Request) {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const groupId = Number(body?.groupId || 0);
|
const groupId = Number(body?.groupId || 0);
|
||||||
if (!groupId) return NextResponse.json({ requestId, error: { code: "MISSING_GROUP_ID", message: "groupId is required" } }, { status: 400 });
|
if (!groupId) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_GROUP_ID", message: "groupId is required" } }, { status: 400 });
|
||||||
|
|
||||||
await setActiveGroupForUser(user.id, groupId);
|
await setActiveGroupForUser(user.id, groupId);
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/active", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/active", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { listGroupAudit } from "@/lib/server/group-audit";
|
import { listGroupAudit } from "@/lib/server/group-audit";
|
||||||
@ -11,9 +11,10 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const events = await listGroupAudit({ userId: user.id, groupId });
|
const events = await listGroupAudit({ userId: user.id, groupId });
|
||||||
return NextResponse.json({ requestId, events });
|
return NextResponse.json({ requestId, request_id: requestId, events });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups/audit", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups/audit", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup, deleteGroup } from "@/lib/server/groups";
|
import { requireActiveGroup, deleteGroup } from "@/lib/server/groups";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -10,9 +10,10 @@ export async function POST() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
await deleteGroup({ userId: user.id, groupId, requestId, ip, userAgent });
|
await deleteGroup({ userId: user.id, groupId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/delete", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/delete", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { deleteInviteLink } from "@/lib/server/group-invites";
|
import { deleteInviteLink } from "@/lib/server/group-invites";
|
||||||
@ -13,11 +13,12 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const linkId = Number(body?.linkId || 0);
|
const linkId = Number(body?.linkId || 0);
|
||||||
if (!linkId)
|
if (!linkId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
||||||
await deleteInviteLink({ linkId, userId: user.id, groupId, requestId, ip, userAgent });
|
await deleteInviteLink({ linkId, userId: user.id, groupId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/delete", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/delete", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { reviveInviteLink } from "@/lib/server/group-invites";
|
import { reviveInviteLink } from "@/lib/server/group-invites";
|
||||||
@ -14,14 +14,15 @@ export async function POST(req: Request) {
|
|||||||
const linkId = Number(body?.linkId || 0);
|
const linkId = Number(body?.linkId || 0);
|
||||||
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
|
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
|
||||||
if (!linkId)
|
if (!linkId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
||||||
if (!ttlDays)
|
if (!ttlDays)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
|
||||||
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
|
||||||
await reviveInviteLink({ userId: user.id, groupId, linkId, expiresAt, requestId, ip, userAgent });
|
await reviveInviteLink({ userId: user.id, groupId, linkId, expiresAt, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revive", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revive", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { revokeInviteLink } from "@/lib/server/group-invites";
|
import { revokeInviteLink } from "@/lib/server/group-invites";
|
||||||
@ -13,11 +13,12 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const linkId = Number(body?.linkId || 0);
|
const linkId = Number(body?.linkId || 0);
|
||||||
if (!linkId)
|
if (!linkId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
||||||
await revokeInviteLink({ userId: user.id, groupId, linkId, requestId, ip, userAgent });
|
await revokeInviteLink({ userId: user.id, groupId, linkId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revoke", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revoke", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { createInviteLink, listInviteLinks } from "@/lib/server/group-invites";
|
import { createInviteLink, listInviteLinks } from "@/lib/server/group-invites";
|
||||||
@ -11,7 +11,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const links = await listInviteLinks({ userId: user.id, groupId });
|
const links = await listInviteLinks({ userId: user.id, groupId });
|
||||||
return NextResponse.json({ requestId, links });
|
return NextResponse.json({ requestId, request_id: requestId, links });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups/invites", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups/invites", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -30,12 +30,13 @@ export async function POST(req: Request) {
|
|||||||
const singleUse = Boolean(body?.singleUse);
|
const singleUse = Boolean(body?.singleUse);
|
||||||
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
|
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
|
||||||
if (!ttlDays)
|
if (!ttlDays)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
|
||||||
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
|
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 });
|
const link = await createInviteLink({ userId: user.id, groupId, policy, singleUse, expiresAt, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, link });
|
return NextResponse.json({ requestId, request_id: requestId, link });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/invites", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/invites", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,22 +1,25 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { joinGroup } from "@/lib/server/groups";
|
import { joinGroup } from "@/lib/server/groups";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
import { getRequestMeta } from "@/lib/server/request";
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
import { enforceIpRateLimit } from "@/lib/server/rate-limit";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const { requestId } = await getRequestMeta();
|
const { requestId, ip } = await getRequestMeta();
|
||||||
try {
|
try {
|
||||||
|
await enforceIpRateLimit({ scope: "groups:join:ip", ip, limit: 60 });
|
||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const inviteCode = String(body?.inviteCode || "").trim().toUpperCase();
|
const inviteCode = String(body?.inviteCode || "").trim().toUpperCase();
|
||||||
if (!inviteCode)
|
if (!inviteCode)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_INVITE_CODE", message: "Invite code is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_INVITE_CODE", message: "Invite code is required" } }, { status: 400 });
|
||||||
|
|
||||||
const group = await joinGroup(user.id, inviteCode);
|
const group = await joinGroup(user.id, inviteCode);
|
||||||
return NextResponse.json({ requestId, group });
|
return NextResponse.json({ requestId, request_id: requestId, group });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/join", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/join", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { approveJoinRequest } from "@/lib/server/group-members";
|
import { approveJoinRequest } from "@/lib/server/group-members";
|
||||||
@ -14,11 +14,12 @@ export async function POST(req: Request) {
|
|||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
const joinRequestId = Number(body?.requestId || 0);
|
const joinRequestId = Number(body?.requestId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: 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 });
|
await approveJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent, requestRowId: joinRequestId || undefined });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/approve", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/approve", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { demoteAdmin } from "@/lib/server/group-members";
|
import { demoteAdmin } from "@/lib/server/group-members";
|
||||||
@ -13,11 +13,12 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
await demoteAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
await demoteAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/demote", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/demote", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { denyJoinRequest } from "@/lib/server/group-members";
|
import { denyJoinRequest } from "@/lib/server/group-members";
|
||||||
@ -13,11 +13,12 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
await denyJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent });
|
await denyJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/deny", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/deny", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { kickMember } from "@/lib/server/group-members";
|
import { kickMember } from "@/lib/server/group-members";
|
||||||
@ -13,11 +13,12 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
await kickMember({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
await kickMember({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/kick", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/kick", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { leaveGroup } from "@/lib/server/group-members";
|
import { leaveGroup } from "@/lib/server/group-members";
|
||||||
@ -11,9 +11,10 @@ export async function POST() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
await leaveGroup({ userId: user.id, groupId, requestId, ip, userAgent });
|
await leaveGroup({ userId: user.id, groupId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/leave", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/leave", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { promoteToAdmin } from "@/lib/server/group-members";
|
import { promoteToAdmin } from "@/lib/server/group-members";
|
||||||
@ -13,11 +13,12 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
await promoteToAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
await promoteToAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/promote", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/promote", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { listGroupMembers, listJoinRequests } from "@/lib/server/group-members";
|
import { listGroupMembers, listJoinRequests } from "@/lib/server/group-members";
|
||||||
@ -12,9 +12,10 @@ export async function GET() {
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const members = await listGroupMembers(groupId);
|
const members = await listGroupMembers(groupId);
|
||||||
const requests = await listJoinRequests({ userId: user.id, groupId });
|
const requests = await listJoinRequests({ userId: user.id, groupId });
|
||||||
return NextResponse.json({ requestId, members, requests, currentUserId: user.id });
|
return NextResponse.json({ requestId, request_id: requestId, members, requests, currentUserId: user.id });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups/members", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups/members", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { transferOwnership } from "@/lib/server/group-members";
|
import { transferOwnership } from "@/lib/server/group-members";
|
||||||
@ -13,11 +13,12 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||||
await transferOwnership({ actorUserId: user.id, groupId, newOwnerUserId: userId, requestId, ip, userAgent });
|
await transferOwnership({ actorUserId: user.id, groupId, newOwnerUserId: userId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/transfer-owner", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/transfer-owner", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup, renameGroup } from "@/lib/server/groups";
|
import { requireActiveGroup, renameGroup } from "@/lib/server/groups";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -12,11 +12,12 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const name = String(body?.name || "").trim();
|
const name = String(body?.name || "").trim();
|
||||||
if (!name)
|
if (!name)
|
||||||
return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
|
||||||
await renameGroup({ userId: user.id, groupId, name, requestId, ip, userAgent });
|
await renameGroup({ userId: user.id, groupId, name, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/rename", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/rename", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { createGroup, listGroups } from "@/lib/server/groups";
|
import { createGroup, listGroups } from "@/lib/server/groups";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -9,7 +9,7 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groups = await listGroups(user.id);
|
const groups = await listGroups(user.id);
|
||||||
return NextResponse.json({ requestId, groups });
|
return NextResponse.json({ requestId, request_id: requestId, groups });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -22,12 +22,13 @@ export async function POST(req: Request) {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const name = String(body?.name || "").trim();
|
const name = String(body?.name || "").trim();
|
||||||
if (!name) return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
|
if (!name) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
|
||||||
|
|
||||||
const group = await createGroup(user.id, name);
|
const group = await createGroup(user.id, name);
|
||||||
return NextResponse.json({ requestId, group });
|
return NextResponse.json({ requestId, request_id: requestId, group });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { getGroupSettings, setGroupSettings } from "@/lib/server/group-settings";
|
import { getGroupSettings, setGroupSettings } from "@/lib/server/group-settings";
|
||||||
@ -11,7 +11,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const settings = await getGroupSettings(groupId);
|
const settings = await getGroupSettings(groupId);
|
||||||
return NextResponse.json({ requestId, settings });
|
return NextResponse.json({ requestId, request_id: requestId, settings });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups/settings", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups/settings", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -30,9 +30,10 @@ export async function POST(req: Request) {
|
|||||||
: "NOT_ACCEPTING";
|
: "NOT_ACCEPTING";
|
||||||
await setGroupSettings({ userId: user.id, groupId, allowMemberTagManage, joinPolicy });
|
await setGroupSettings({ userId: user.id, groupId, allowMemberTagManage, joinPolicy });
|
||||||
const settings = await getGroupSettings(groupId);
|
const settings = await getGroupSettings(groupId);
|
||||||
return NextResponse.json({ requestId, settings });
|
return NextResponse.json({ requestId, request_id: requestId, settings });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/settings", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/settings", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
apps/web/app/api/health/live/route.ts
Normal file
12
apps/web/app/api/health/live/route.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
return NextResponse.json({
|
||||||
|
requestId,
|
||||||
|
request_id: requestId,
|
||||||
|
ok: true,
|
||||||
|
status: "live"
|
||||||
|
});
|
||||||
|
}
|
||||||
25
apps/web/app/api/health/ready/route.ts
Normal file
25
apps/web/app/api/health/ready/route.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import getPool from "@/lib/server/db";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.query("select 1");
|
||||||
|
return NextResponse.json({
|
||||||
|
requestId,
|
||||||
|
request_id: requestId,
|
||||||
|
ok: true,
|
||||||
|
status: "ready"
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/health/ready", requestId);
|
||||||
|
return NextResponse.json({
|
||||||
|
...body,
|
||||||
|
ok: false,
|
||||||
|
status: "not_ready"
|
||||||
|
}, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,10 +3,12 @@ import { getSessionUser, requireSessionUser } from "@/lib/server/session";
|
|||||||
import { apiError, toErrorResponse } from "@/lib/server/errors";
|
import { apiError, toErrorResponse } from "@/lib/server/errors";
|
||||||
import { getRequestMeta } from "@/lib/server/request";
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
import { acceptInviteLink, getInviteLinkSummaryByToken, getInviteViewerStatus } from "@/lib/server/group-invites";
|
import { acceptInviteLink, getInviteLinkSummaryByToken, getInviteViewerStatus } from "@/lib/server/group-invites";
|
||||||
|
import { enforceIpRateLimit } from "@/lib/server/rate-limit";
|
||||||
|
|
||||||
export async function GET(_: Request, context: { params: Promise<{ token: string }> }) {
|
export async function GET(_: Request, context: { params: Promise<{ token: string }> }) {
|
||||||
const { requestId } = await getRequestMeta();
|
const { requestId, ip } = await getRequestMeta();
|
||||||
try {
|
try {
|
||||||
|
await enforceIpRateLimit({ scope: "invite-links:get:ip", ip, limit: 120 });
|
||||||
const { token } = await context.params;
|
const { token } = await context.params;
|
||||||
const normalized = String(token || "").trim();
|
const normalized = String(token || "").trim();
|
||||||
if (!normalized) apiError("INVITE_NOT_FOUND");
|
if (!normalized) apiError("INVITE_NOT_FOUND");
|
||||||
@ -16,9 +18,9 @@ export async function GET(_: Request, context: { params: Promise<{ token: string
|
|||||||
if (user) {
|
if (user) {
|
||||||
const viewerStatus = await getInviteViewerStatus({ userId: user.id, groupId: link.groupId });
|
const viewerStatus = await getInviteViewerStatus({ userId: user.id, groupId: link.groupId });
|
||||||
if (viewerStatus)
|
if (viewerStatus)
|
||||||
return NextResponse.json({ requestId, link: { ...link, viewerStatus } });
|
return NextResponse.json({ requestId, request_id: requestId, link: { ...link, viewerStatus } });
|
||||||
}
|
}
|
||||||
return NextResponse.json({ requestId, link });
|
return NextResponse.json({ requestId, request_id: requestId, link });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/invite-links/[token]", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/invite-links/[token]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -28,12 +30,13 @@ export async function GET(_: Request, context: { params: Promise<{ token: string
|
|||||||
export async function POST(_: Request, context: { params: Promise<{ token: string }> }) {
|
export async function POST(_: Request, context: { params: Promise<{ token: string }> }) {
|
||||||
const { requestId, ip, userAgent } = await getRequestMeta();
|
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||||
try {
|
try {
|
||||||
|
await enforceIpRateLimit({ scope: "invite-links:accept:ip", ip, limit: 60 });
|
||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const { token } = await context.params;
|
const { token } = await context.params;
|
||||||
const normalized = String(token || "").trim();
|
const normalized = String(token || "").trim();
|
||||||
if (!normalized) apiError("INVITE_NOT_FOUND");
|
if (!normalized) apiError("INVITE_NOT_FOUND");
|
||||||
const result = await acceptInviteLink({ userId: user.id, token: normalized, requestId, ip, userAgent });
|
const result = await acceptInviteLink({ userId: user.id, token: normalized, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, result });
|
return NextResponse.json({ requestId, request_id: requestId, result });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/invite-links/[token]", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/invite-links/[token]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const amountDollars = Number(body?.amountDollars || 0);
|
const amountDollars = Number(body?.amountDollars || 0);
|
||||||
@ -33,22 +33,21 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||||
const endDate = body?.endDate ? String(body.endDate) : null;
|
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||||
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
|
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
|
||||||
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
|
||||||
|
|
||||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: 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 (!occurredAt) return NextResponse.json({ requestId, request_id: 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 (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||||
if (!['SPENDING', 'INCOME'].includes(entryType))
|
if (!['SPENDING', 'INCOME'].includes(entryType))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
||||||
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
||||||
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
|
if (frequency && !['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
|
||||||
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
|
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
|
||||||
|
|
||||||
const entry = await updateRecurringEntry({
|
const entry = await updateRecurringEntry({
|
||||||
id,
|
id,
|
||||||
@ -61,19 +60,17 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
purchaseType,
|
purchaseType,
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
tags,
|
tags,
|
||||||
isRecurring: true,
|
frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null,
|
||||||
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
|
|
||||||
intervalCount,
|
intervalCount,
|
||||||
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
||||||
endCount,
|
endCount,
|
||||||
endDate,
|
endDate,
|
||||||
nextRunAt,
|
nextRunAt
|
||||||
bucketId
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!entry) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
if (!entry) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
||||||
|
|
||||||
return NextResponse.json({ requestId, entry });
|
return NextResponse.json({ requestId, request_id: requestId, entry });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "PATCH /api/recurring-entries/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "PATCH /api/recurring-entries/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -87,10 +84,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
await deleteRecurringEntry({ id, groupId });
|
await deleteRecurringEntry({ id, groupId, userId: user.id });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "DELETE /api/recurring-entries/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "DELETE /api/recurring-entries/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const entries = await listRecurringEntries(groupId);
|
const entries = await listRecurringEntries(groupId);
|
||||||
return NextResponse.json({ requestId, entries });
|
return NextResponse.json({ requestId, request_id: requestId, entries });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/recurring-entries", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/recurring-entries", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -42,22 +42,21 @@ export async function POST(req: Request) {
|
|||||||
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||||
const endDate = body?.endDate ? String(body.endDate) : null;
|
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||||
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
|
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
|
||||||
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
|
||||||
|
|
||||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: 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 (!occurredAt) return NextResponse.json({ requestId, request_id: 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 (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||||
if (!['SPENDING', 'INCOME'].includes(entryType))
|
if (!['SPENDING', 'INCOME'].includes(entryType))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
||||||
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
||||||
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
|
if (frequency && !['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
|
||||||
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
|
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
|
||||||
return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
|
||||||
|
|
||||||
const entry = await createRecurringEntry({
|
const entry = await createRecurringEntry({
|
||||||
groupId,
|
groupId,
|
||||||
@ -69,19 +68,17 @@ export async function POST(req: Request) {
|
|||||||
purchaseType,
|
purchaseType,
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
tags,
|
tags,
|
||||||
isRecurring: true,
|
frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null,
|
||||||
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
|
|
||||||
intervalCount,
|
intervalCount,
|
||||||
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
||||||
endCount,
|
endCount,
|
||||||
endDate,
|
endDate
|
||||||
nextRunAt,
|
|
||||||
bucketId
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ requestId, entry });
|
return NextResponse.json({ requestId, request_id: requestId, entry });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/recurring-entries", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/recurring-entries", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
96
apps/web/app/api/schedules/[id]/route.ts
Normal file
96
apps/web/app/api/schedules/[id]/route.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { deleteSchedule, requireActiveGroup, updateSchedule } from "@/lib/server/schedules";
|
||||||
|
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, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const amountDollars = Number(body?.amountDollars || 0);
|
||||||
|
const startsOn = String(body?.startsOn || "");
|
||||||
|
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 = String(body?.frequency || "").toUpperCase();
|
||||||
|
const intervalCount = Number(body?.intervalCount || 1);
|
||||||
|
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : "NEVER";
|
||||||
|
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||||
|
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||||
|
const nextRunOn = body?.nextRunOn ? String(body.nextRunOn) : startsOn;
|
||||||
|
const isActive = body?.isActive != null ? Boolean(body.isActive) : true;
|
||||||
|
|
||||||
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
||||||
|
if (!startsOn) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_STARTS_ON", message: "startsOn is required" } }, { status: 400 });
|
||||||
|
if (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||||
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||||
|
if (!['SPENDING', 'INCOME'].includes(entryType))
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
||||||
|
if (!['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency))
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
|
||||||
|
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
||||||
|
if (!['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
|
||||||
|
|
||||||
|
const schedule = await updateSchedule({
|
||||||
|
id,
|
||||||
|
groupId,
|
||||||
|
userId: user.id,
|
||||||
|
entryType: entryType as "SPENDING" | "INCOME",
|
||||||
|
amountDollars,
|
||||||
|
startsOn,
|
||||||
|
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
|
purchaseType,
|
||||||
|
notes: notes || undefined,
|
||||||
|
tags,
|
||||||
|
frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY",
|
||||||
|
intervalCount,
|
||||||
|
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE",
|
||||||
|
endCount,
|
||||||
|
endDate,
|
||||||
|
nextRunOn,
|
||||||
|
isActive
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!schedule) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, schedule });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "PATCH /api/schedules/[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, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||||
|
|
||||||
|
await deleteSchedule({ id, groupId, userId: user.id });
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "DELETE /api/schedules/[id]", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
84
apps/web/app/api/schedules/route.ts
Normal file
84
apps/web/app/api/schedules/route.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { createSchedule, listSchedules, requireActiveGroup } from "@/lib/server/schedules";
|
||||||
|
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 schedules = await listSchedules(groupId);
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, schedules });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/schedules", 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 startsOn = String(body?.startsOn || "");
|
||||||
|
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 = String(body?.frequency || "").toUpperCase();
|
||||||
|
const intervalCount = Number(body?.intervalCount || 1);
|
||||||
|
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : "NEVER";
|
||||||
|
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||||
|
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||||
|
const createEntryNow = Boolean(body?.createEntryNow);
|
||||||
|
|
||||||
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
||||||
|
if (!startsOn) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_STARTS_ON", message: "startsOn is required" } }, { status: 400 });
|
||||||
|
if (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||||
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||||
|
if (!['SPENDING', 'INCOME'].includes(entryType))
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
||||||
|
if (!['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency))
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
|
||||||
|
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
||||||
|
if (!['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
|
||||||
|
|
||||||
|
const schedule = await createSchedule({
|
||||||
|
groupId,
|
||||||
|
userId: user.id,
|
||||||
|
entryType: entryType as "SPENDING" | "INCOME",
|
||||||
|
amountDollars,
|
||||||
|
startsOn,
|
||||||
|
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
|
purchaseType,
|
||||||
|
notes: notes || undefined,
|
||||||
|
tags,
|
||||||
|
frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY",
|
||||||
|
intervalCount,
|
||||||
|
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE",
|
||||||
|
endCount,
|
||||||
|
endDate,
|
||||||
|
createEntryNow
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, schedule });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/schedules", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ name: s
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { name } = await params;
|
const { name } = await params;
|
||||||
await deleteTagForGroup({ userId: user.id, groupId, name: decodeURIComponent(name) });
|
await deleteTagForGroup({ userId: user.id, groupId, name: decodeURIComponent(name) });
|
||||||
return NextResponse.json({ requestId, ok: true });
|
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "DELETE /api/tags/[name]", requestId);
|
const { status, body } = toErrorResponse(e, "DELETE /api/tags/[name]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/entries";
|
import { requireActiveGroup } from "@/lib/server/entries";
|
||||||
import { ensureTagsForGroup, listGroupTags } from "@/lib/server/tags";
|
import { ensureTagsForGroup, listGroupTags } from "@/lib/server/tags";
|
||||||
@ -11,7 +11,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const tags = await listGroupTags(groupId);
|
const tags = await listGroupTags(groupId);
|
||||||
return NextResponse.json({ requestId, tags });
|
return NextResponse.json({ requestId, request_id: requestId, tags });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/tags", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/tags", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -27,9 +27,10 @@ export async function POST(req: Request) {
|
|||||||
const tags = Array.isArray(body?.tags) ? body.tags.map((tag: unknown) => String(tag)) : [];
|
const tags = Array.isArray(body?.tags) ? body.tags.map((tag: unknown) => String(tag)) : [];
|
||||||
await ensureTagsForGroup({ userId: user.id, groupId, tags });
|
await ensureTagsForGroup({ userId: user.id, groupId, tags });
|
||||||
const list = await listGroupTags(groupId);
|
const list = await listGroupTags(groupId);
|
||||||
return NextResponse.json({ requestId, tags: list });
|
return NextResponse.json({ requestId, request_id: requestId, tags: list });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/tags", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/tags", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
apps/web/app/api/user/settings/route.ts
Normal file
33
apps/web/app/api/user/settings/route.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
|
import { getUserSettings, setUserSettings } from "@/lib/server/user-settings";
|
||||||
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { requestId } = await getRequestMeta();
|
||||||
|
try {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
const settings = await getUserSettings(user.id);
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, settings });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "GET /api/user/settings", 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 entryPanelPageSize = Number(body?.entryPanelPageSize);
|
||||||
|
if (!Number.isFinite(entryPanelPageSize) || entryPanelPageSize <= 0)
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_PAGE_SIZE", message: "entryPanelPageSize must be a positive number" } }, { status: 400 });
|
||||||
|
const settings = await setUserSettings({ userId: user.id, entryPanelPageSize });
|
||||||
|
return NextResponse.json({ requestId, request_id: requestId, settings });
|
||||||
|
} catch (e) {
|
||||||
|
const { status, body } = toErrorResponse(e, "POST /api/user/settings", requestId);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,14 +0,0 @@
|
|||||||
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} />;
|
|
||||||
}
|
|
||||||
@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useAuthContext } from "@/hooks/auth-context";
|
import { useAuthContext } from "@/hooks/auth-context";
|
||||||
import useInviteLink from "@/hooks/use-invite-link";
|
import useInviteLink from "@/features/groups/hooks/use-invite-link";
|
||||||
|
|
||||||
export default function InvitePage() {
|
export default function InvitePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@ -154,7 +154,7 @@ export default function InvitePage() {
|
|||||||
<div className="card-title">Invite details</div>
|
<div className="card-title">Invite details</div>
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-sm text-muted">Loading invite…</div>
|
<div className="text-sm text-muted">Loading invite...</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="text-sm text-red-400">{error}</div>
|
<div className="text-sm text-red-400">{error}</div>
|
||||||
) : link ? (
|
) : link ? (
|
||||||
@ -203,7 +203,7 @@ export default function InvitePage() {
|
|||||||
<div className="card-title">Join this group</div>
|
<div className="card-title">Join this group</div>
|
||||||
</div>
|
</div>
|
||||||
{checkingSession ? (
|
{checkingSession ? (
|
||||||
<div className="text-sm text-muted">Checking session…</div>
|
<div className="text-sm text-muted">Checking session...</div>
|
||||||
) : !hasSession ? (
|
) : !hasSession ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-sm text-muted">Sign in to accept this invite.</div>
|
<div className="text-sm text-muted">Sign in to accept this invite.</div>
|
||||||
@ -244,7 +244,7 @@ export default function InvitePage() {
|
|||||||
disabled={!link || Boolean(result) || accepting}
|
disabled={!link || Boolean(result) || accepting}
|
||||||
onClick={accept}
|
onClick={accept}
|
||||||
>
|
>
|
||||||
{accepting ? "Joining…" : actionLabel}
|
{accepting ? "Joining..." : actionLabel}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
@ -280,3 +280,4 @@ export default function InvitePage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
9
apps/web/app/settings/page.tsx
Normal file
9
apps/web/app/settings/page.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSessionUser } from "@/lib/server/session";
|
||||||
|
import SettingsContent from "@/components/settings-content";
|
||||||
|
|
||||||
|
export default async function SettingsPage() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
return <SettingsContent />;
|
||||||
|
}
|
||||||
@ -1,151 +0,0 @@
|
|||||||
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
|
|
||||||
));
|
|
||||||
92
apps/web/components/confirm-retype-modal.tsx
Normal file
92
apps/web/components/confirm-retype-modal.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
|
||||||
|
type ConfirmRetypeModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
expectedText: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRetype(text: string) {
|
||||||
|
return text.trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmRetypeModal({
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
expectedText,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
confirmLabel = "Confirm",
|
||||||
|
cancelLabel = "Cancel",
|
||||||
|
placeholder
|
||||||
|
}: ConfirmRetypeModalProps) {
|
||||||
|
const expectedNormalized = useMemo(() => normalizeRetype(expectedText), [expectedText]);
|
||||||
|
const canConfirm = normalizeRetype(value) === expectedNormalized;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" && !event.shiftKey && !event.defaultPrevented && canConfirm) {
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [canConfirm, isOpen, onClose, onConfirm]);
|
||||||
|
|
||||||
|
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()}
|
||||||
|
role="dialog"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<div className="text-lg font-semibold text-red-200">{title}</div>
|
||||||
|
{description ? <p className="mt-2 text-sm text-muted">{description}</p> : null}
|
||||||
|
<input
|
||||||
|
className={`mt-4 w-full input-base px-3 py-2 text-sm ${canConfirm ? "" : "border-red-400/70"}`}
|
||||||
|
value={value}
|
||||||
|
onChange={event => onChange(event.target.value)}
|
||||||
|
placeholder={placeholder || expectedText}
|
||||||
|
/>
|
||||||
|
<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={onClose}
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</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 disabled:opacity-40"
|
||||||
|
disabled={!canConfirm}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -20,23 +20,60 @@ export default function ConfirmSlideModal({
|
|||||||
onConfirm
|
onConfirm
|
||||||
}: ConfirmSlideModalProps) {
|
}: ConfirmSlideModalProps) {
|
||||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const endFlashTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const reachedEndRef = useRef(false);
|
||||||
const [dragX, setDragX] = useState(0);
|
const [dragX, setDragX] = useState(0);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const handleSize = 44;
|
const [isAtEnd, setIsAtEnd] = useState(false);
|
||||||
|
const [endFlash, setEndFlash] = useState(false);
|
||||||
|
const handleSize = 40;
|
||||||
|
|
||||||
|
function getDragPositionFromClientX(clientX: number) {
|
||||||
|
const track = trackRef.current;
|
||||||
|
if (!track) return 0;
|
||||||
|
const rect = track.getBoundingClientRect();
|
||||||
|
return Math.min(Math.max(0, clientX - rect.left - handleSize / 2), rect.width - handleSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEndPosition(position: number) {
|
||||||
|
const track = trackRef.current;
|
||||||
|
if (!track) return false;
|
||||||
|
const maxDrag = track.clientWidth - handleSize;
|
||||||
|
const endTolerancePx = 1;
|
||||||
|
return position >= maxDrag - endTolerancePx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerEndFeedback() {
|
||||||
|
setEndFlash(true);
|
||||||
|
if (endFlashTimeoutRef.current) clearTimeout(endFlashTimeoutRef.current);
|
||||||
|
endFlashTimeoutRef.current = setTimeout(() => setEndFlash(false), 140);
|
||||||
|
|
||||||
|
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
|
||||||
|
navigator.vibrate(16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handlePointerDown(event: React.PointerEvent<HTMLButtonElement>) {
|
function handlePointerDown(event: React.PointerEvent<HTMLButtonElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
|
reachedEndRef.current = false;
|
||||||
|
setIsAtEnd(false);
|
||||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerMove(event: React.PointerEvent<HTMLButtonElement>) {
|
function handlePointerMove(event: React.PointerEvent<HTMLButtonElement>) {
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
const track = trackRef.current;
|
const next = getDragPositionFromClientX(event.clientX);
|
||||||
if (!track) return;
|
const nextAtEnd = isEndPosition(next);
|
||||||
const rect = track.getBoundingClientRect();
|
|
||||||
const next = Math.min(Math.max(0, event.clientX - rect.left - handleSize / 2), rect.width - handleSize);
|
|
||||||
setDragX(next);
|
setDragX(next);
|
||||||
|
setIsAtEnd(prev => (prev === nextAtEnd ? prev : nextAtEnd));
|
||||||
|
|
||||||
|
if (nextAtEnd && !reachedEndRef.current) {
|
||||||
|
reachedEndRef.current = true;
|
||||||
|
triggerEndFeedback();
|
||||||
|
}
|
||||||
|
if (!nextAtEnd) reachedEndRef.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerUp(event: React.PointerEvent<HTMLButtonElement>) {
|
function handlePointerUp(event: React.PointerEvent<HTMLButtonElement>) {
|
||||||
@ -45,14 +82,35 @@ export default function ConfirmSlideModal({
|
|||||||
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
||||||
const track = trackRef.current;
|
const track = trackRef.current;
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
const threshold = (track.clientWidth - handleSize) * 0.8;
|
const releaseX = getDragPositionFromClientX(event.clientX);
|
||||||
if (dragX >= threshold) {
|
const releaseAtEnd = isEndPosition(releaseX);
|
||||||
|
|
||||||
|
setIsAtEnd(prev => (prev ? false : prev));
|
||||||
|
|
||||||
|
if (releaseAtEnd && !reachedEndRef.current) {
|
||||||
|
triggerEndFeedback();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releaseAtEnd) {
|
||||||
setDragX(0);
|
setDragX(0);
|
||||||
onConfirm();
|
onConfirm();
|
||||||
} else {
|
} else {
|
||||||
setDragX(0);
|
setDragX(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePointerCancel(event: React.PointerEvent<HTMLButtonElement>) {
|
||||||
|
if (!dragging) return;
|
||||||
|
setDragging(false);
|
||||||
|
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
||||||
|
setIsAtEnd(prev => (prev ? false : prev));
|
||||||
|
setDragX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (endFlashTimeoutRef.current) clearTimeout(endFlashTimeoutRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
@ -73,20 +131,26 @@ export default function ConfirmSlideModal({
|
|||||||
<div className="text-xs text-soft">Slide to confirm</div>
|
<div className="text-xs text-soft">Slide to confirm</div>
|
||||||
<div
|
<div
|
||||||
ref={trackRef}
|
ref={trackRef}
|
||||||
className="mt-2 h-11 rounded-full border border-accent-weak bg-surface relative overflow-hidden touch-none select-none"
|
className={`relative mx-auto mt-2 h-10 w-4/5 overflow-hidden rounded-full border touch-none select-none transition-colors ${isAtEnd || endFlash ? "border-accent bg-accent-soft" : "border-accent-weak bg-surface"}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-y-0 left-0 bg-accent-soft rounded-full"
|
className="absolute inset-y-0 left-0 rounded-full bg-accent-soft"
|
||||||
style={{ width: dragX + handleSize }}
|
style={{ width: dragX + handleSize }}
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-semibold text-[color:var(--color-accent)] transition-all ${isAtEnd || endFlash ? "scale-100 opacity-100" : "scale-90 opacity-0"}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
click
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="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"
|
className={`absolute left-0 top-0 h-10 w-10 rounded-full border bg-panel text-lg font-semibold leading-none text-[color:var(--color-text)] touch-none select-none will-change-transform transition-[border-color,box-shadow] duration-100 ${isAtEnd || endFlash ? "border-accent-strong shadow-[0_0_0_2px_var(--color-accent-focus)]" : "border-accent"}`}
|
||||||
style={{ transform: `translateX(${dragX}px)` }}
|
style={{ transform: `translateX(${dragX}px)` }}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerCancel={handlePointerUp}
|
onPointerCancel={handlePointerCancel}
|
||||||
aria-label="Slide to confirm"
|
aria-label="Slide to confirm"
|
||||||
>
|
>
|
||||||
▸
|
▸
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useGroupsContext } from "@/hooks/groups-context";
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
import EntriesPanel from "@/components/entries-panel";
|
import EntriesPanel from "@/features/entries/components/entries-panel";
|
||||||
import BucketsPanel from "@/components/buckets-panel";
|
import BucketsPanel from "@/features/buckets/components/buckets-panel";
|
||||||
import { EntryMutationProvider } from "@/hooks/entry-mutation-context";
|
import { EntryMutationProvider } from "@/hooks/entry-mutation-context";
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
51
apps/web/components/date-picker.tsx
Normal file
51
apps/web/components/date-picker.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
type DatePickerProps = {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
required?: boolean;
|
||||||
|
name?: string;
|
||||||
|
className?: string;
|
||||||
|
showWeekButtons?: boolean;
|
||||||
|
centerInput?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DatePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
required = false,
|
||||||
|
name,
|
||||||
|
className = "",
|
||||||
|
showWeekButtons = true,
|
||||||
|
centerInput = false
|
||||||
|
}: DatePickerProps) {
|
||||||
|
function shiftDate(days: number) {
|
||||||
|
const base = value ? new Date(value) : new Date();
|
||||||
|
if (Number.isNaN(base.getTime())) return;
|
||||||
|
base.setDate(base.getDate() + days);
|
||||||
|
onChange(base.toISOString().slice(0, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalid = required && !value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`inline-flex w-full items-center overflow-hidden rounded-full border ${invalid ? "border-red-400/70" : "border-accent-weak"} bg-panel ${className}`}>
|
||||||
|
{showWeekButtons ? (
|
||||||
|
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(-7)}>«</button>
|
||||||
|
) : null}
|
||||||
|
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(-1)}>‹</button>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
type="date"
|
||||||
|
className={`no-date-icon h-11 w-40 bg-transparent px-3 text-sm text-[color:var(--color-text)] outline-none ${centerInput ? "text-center" : ""}`}
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
required={required}
|
||||||
|
/>
|
||||||
|
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(1)}>›</button>
|
||||||
|
{showWeekButtons ? (
|
||||||
|
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(7)}>»</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,785 +0,0 @@
|
|||||||
"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();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -3,6 +3,8 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import TagInput from "@/components/tag-input";
|
import TagInput from "@/components/tag-input";
|
||||||
|
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||||
|
import DatePicker from "@/components/date-picker";
|
||||||
|
|
||||||
export type EntryDetailsForm = {
|
export type EntryDetailsForm = {
|
||||||
amountDollars: string;
|
amountDollars: string;
|
||||||
@ -11,12 +13,6 @@ export type EntryDetailsForm = {
|
|||||||
notes: string;
|
notes: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
entryType: "SPENDING" | "INCOME";
|
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 = {
|
type EntryDetailsModalProps = {
|
||||||
@ -81,7 +77,6 @@ export default function EntryDetailsModal({
|
|||||||
const notesChanged = form.notes !== baseline.notes;
|
const notesChanged = form.notes !== baseline.notes;
|
||||||
const changedInputClass = "border-2 border-[color:var(--color-accent)]";
|
const changedInputClass = "border-2 border-[color:var(--color-accent)]";
|
||||||
const formRef = useRef<HTMLFormElement | null>(null);
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
const touchStartX = useRef<number | null>(null);
|
const touchStartX = useRef<number | null>(null);
|
||||||
const touchDeltaX = useRef(0);
|
const touchDeltaX = useRef(0);
|
||||||
|
|
||||||
@ -117,10 +112,7 @@ export default function EntryDetailsModal({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={onClose}>
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="relative w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
|
className="relative w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
|
||||||
onClick={event => event.stopPropagation()}
|
onClick={event => event.stopPropagation()}
|
||||||
@ -129,26 +121,14 @@ export default function EntryDetailsModal({
|
|||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-3 items-center gap-3">
|
<div className="grid grid-cols-3 items-center gap-3">
|
||||||
<button
|
<button type="button" onClick={onPrev} className="flex w-20 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">
|
||||||
type="button"
|
<span aria-hidden>{loopHintPrev ? "o" : "<"}</span>
|
||||||
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>
|
<span className="text-xs text-soft">{loopHintPrev ? "To Last" : "Prev"}</span>
|
||||||
</button>
|
</button>
|
||||||
<h2 className="text-center text-lg font-semibold">Entry details</h2>
|
<h2 className="text-center text-lg font-semibold">Entry Details</h2>
|
||||||
<button
|
<button type="button" onClick={onNext} className="ml-auto flex w-20 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">
|
||||||
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 className="text-xs text-soft">{loopHintNext ? "To First" : "Next"}</span>
|
||||||
<span aria-hidden>{loopHintNext ? "↻" : "→"}</span>
|
<span aria-hidden>{loopHintNext ? "o" : ">"}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
@ -164,31 +144,16 @@ export default function EntryDetailsModal({
|
|||||||
className="mt-3 grid gap-3 md:grid-cols-2"
|
className="mt-3 grid gap-3 md:grid-cols-2"
|
||||||
>
|
>
|
||||||
<div className="md:col-span-2 flex flex-wrap items-center gap-2">
|
<div className="md:col-span-2 flex flex-wrap items-center gap-2">
|
||||||
<button
|
<ToggleButtonGroup
|
||||||
type="button"
|
value={form.entryType}
|
||||||
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"}`}
|
onChange={entryType => onChange({ entryType })}
|
||||||
onClick={() => onChange({ isRecurring: !form.isRecurring })}
|
ariaLabel="Entry type"
|
||||||
title="Toggle Recurring Entry"
|
sizeClassName="px-4 py-2.5 text-xs font-semibold"
|
||||||
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
|
options={[
|
||||||
>
|
{ value: "SPENDING", label: "Spending" },
|
||||||
<span aria-hidden>⟳</span>
|
{ value: "INCOME", label: "Income" }
|
||||||
</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>
|
</div>
|
||||||
<label className="text-sm text-muted">
|
<label className="text-sm text-muted">
|
||||||
Amount ($)
|
Amount ($)
|
||||||
@ -204,32 +169,27 @@ export default function EntryDetailsModal({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="text-sm text-muted">
|
<div className="text-sm text-muted">
|
||||||
<input
|
<DatePicker
|
||||||
name="occurredAt"
|
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}
|
value={form.occurredAt}
|
||||||
onChange={e => onChange({ occurredAt: e.target.value })}
|
onChange={occurredAt => onChange({ occurredAt })}
|
||||||
required
|
required
|
||||||
|
className={`mt-1 ${dateChanged ? changedInputClass : ""}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted">
|
<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">
|
<ToggleButtonGroup
|
||||||
{([
|
value={form.necessity}
|
||||||
{ value: "NECESSARY", label: "Necessary" },
|
onChange={necessity => onChange({ necessity })}
|
||||||
{ value: "BOTH", label: "Both" },
|
ariaLabel="Necessity"
|
||||||
{ value: "UNNECESSARY", label: "Unnecessary" }
|
className={`flex items-center gap-2 rounded-full border border-accent-weak bg-panel ${necessityChanged ? changedInputClass : ""}`}
|
||||||
] as const).map(option => (
|
sizeClassName="px-3 py-2.5 text-xs font-semibold"
|
||||||
<button
|
options={[
|
||||||
key={option.value}
|
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
|
||||||
type="button"
|
{ value: "BOTH", label: "Both", className: "flex-1" },
|
||||||
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
|
||||||
onClick={() => onChange({ necessity: option.value })}
|
]}
|
||||||
>
|
/>
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<TagInput
|
<TagInput
|
||||||
label="Tags"
|
label="Tags"
|
||||||
@ -246,80 +206,6 @@ export default function EntryDetailsModal({
|
|||||||
onEmptySuggestionClick={onEmptyTagAction}
|
onEmptySuggestionClick={onEmptyTagAction}
|
||||||
invalid={!currentTags.length}
|
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">
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
Notes
|
Notes
|
||||||
<textarea
|
<textarea
|
||||||
@ -332,7 +218,7 @@ export default function EntryDetailsModal({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="md:col-span-2 flex items-center justify-between">
|
<div className="md:col-span-2 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRevert}
|
onClick={onRevert}
|
||||||
@ -340,41 +226,16 @@ export default function EntryDetailsModal({
|
|||||||
aria-label="Revert changes"
|
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"
|
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
|
R
|
||||||
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>
|
||||||
<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={!isDirty}>
|
||||||
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
|
Save changes
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold" onClick={onRequestDelete}>
|
||||||
type="button"
|
|
||||||
className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold"
|
|
||||||
onClick={onRequestDelete}
|
|
||||||
>
|
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
<div className="flex-1 w-full" />
|
||||||
<button
|
<button type="button" onClick={onClose} className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold" aria-label="Close">
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="right rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,16 +2,18 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import useTags from "@/hooks/use-tags";
|
import useTags from "@/features/tags/hooks/use-tags";
|
||||||
import useGroupSettings from "@/hooks/use-group-settings";
|
import useGroupSettings from "@/features/groups/hooks/use-group-settings";
|
||||||
import useGroupMembers from "@/hooks/use-group-members";
|
import useGroupMembers from "@/features/groups/hooks/use-group-members";
|
||||||
import useGroupInvites from "@/hooks/use-group-invites";
|
import useGroupInvites from "@/features/groups/hooks/use-group-invites";
|
||||||
import useGroupAudit from "@/hooks/use-group-audit";
|
import useGroupAudit from "@/features/groups/hooks/use-group-audit";
|
||||||
import { useGroupsContext } from "@/hooks/groups-context";
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
import TagInput from "@/components/tag-input";
|
import TagInput from "@/components/tag-input";
|
||||||
import { useNotificationsContext } from "@/hooks/notifications-context";
|
import { useNotificationsContext } from "@/hooks/notifications-context";
|
||||||
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
||||||
|
import ConfirmRetypeModal from "@/components/confirm-retype-modal";
|
||||||
import { groupsDelete, groupsRename } from "@/lib/client/groups";
|
import { groupsDelete, groupsRename } from "@/lib/client/groups";
|
||||||
|
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||||
|
|
||||||
export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -267,6 +269,24 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|||||||
return { userId: top, count: topCount, name, searchValue };
|
return { userId: top, count: topCount, name, searchValue };
|
||||||
})();
|
})();
|
||||||
const mostActiveCount = mostActiveUser?.count ?? 0;
|
const mostActiveCount = mostActiveUser?.count ?? 0;
|
||||||
|
const renameDirty = Boolean(group && renameValue.trim() !== group.name);
|
||||||
|
const memberCount = members.length;
|
||||||
|
const showLeaveGroup = role !== "GROUP_OWNER" && !isLastAdmin;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!renameModalOpen && !tagModalOpen) return;
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
if (tagModalOpen) setTagModalOpen(false);
|
||||||
|
if (renameModalOpen) handleCloseRenameModal();
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" && !event.shiftKey && !event.defaultPrevented) {
|
||||||
|
if (renameModalOpen && renameDirty && isAdmin && renameValue.trim()) setConfirmRenameOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [renameModalOpen, tagModalOpen, renameDirty, isAdmin, renameValue, handleCloseRenameModal]);
|
||||||
|
|
||||||
async function handleDeleteGroup() {
|
async function handleDeleteGroup() {
|
||||||
const result = await groupsDelete();
|
const result = await groupsDelete();
|
||||||
@ -288,30 +308,9 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const renameDirty = renameValue.trim() !== group.name;
|
|
||||||
const visibleTags = showAllTags ? tags : tags.slice(0, 5);
|
const visibleTags = showAllTags ? tags : tags.slice(0, 5);
|
||||||
const hasMoreTags = tags.length > 5;
|
const hasMoreTags = tags.length > 5;
|
||||||
const tagsScrollable = showAllTags && tags.length > 15;
|
const tagsScrollable = showAllTags && tags.length > 15;
|
||||||
const memberCount = members.length;
|
|
||||||
const showLeaveGroup = role !== "GROUP_OWNER" && !isLastAdmin;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!renameModalOpen && !tagModalOpen && !confirmDeleteGroupOpen) return;
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
if (confirmDeleteGroupOpen) setConfirmDeleteGroupOpen(false);
|
|
||||||
if (tagModalOpen) setTagModalOpen(false);
|
|
||||||
if (renameModalOpen) handleCloseRenameModal();
|
|
||||||
}
|
|
||||||
if (event.key === "Enter" && !event.shiftKey && !event.defaultPrevented) {
|
|
||||||
if (renameModalOpen && renameDirty && isAdmin && renameValue.trim()) setConfirmRenameOpen(true);
|
|
||||||
// if (tagModalOpen && pendingTags.length && canManageTags) handleSaveTags();
|
|
||||||
if (confirmDeleteGroupOpen && deleteConfirmText.trim().toUpperCase() === "DELETE") handleDeleteGroup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [renameModalOpen, tagModalOpen, confirmDeleteGroupOpen, renameDirty, isAdmin, renameValue, pendingTags.length, canManageTags, deleteConfirmText, handleDeleteGroup, handleSaveTags, handleCloseRenameModal]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -425,23 +424,21 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="text-sm font-semibold">Join policy</div>
|
<div className="text-sm font-semibold">Join policy</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<ToggleButtonGroup
|
||||||
{[
|
value={localJoinPolicy}
|
||||||
|
onChange={policy => handleJoinPolicyChange(policy)}
|
||||||
|
ariaLabel="Join policy"
|
||||||
|
className="flex flex-wrap gap-2"
|
||||||
|
buttonBaseClassName="rounded-lg border"
|
||||||
|
sizeClassName="px-3 py-1.5 text-xs font-semibold transition"
|
||||||
|
activeClassName="border-accent bg-accent-soft"
|
||||||
|
inactiveClassName="border-accent-weak bg-panel hover:border-accent"
|
||||||
|
options={[
|
||||||
{ value: "NOT_ACCEPTING", label: "Disabled" },
|
{ value: "NOT_ACCEPTING", label: "Disabled" },
|
||||||
{ value: "AUTO_ACCEPT", label: "Auto" },
|
{ value: "AUTO_ACCEPT", label: "Auto" },
|
||||||
{ value: "APPROVAL_REQUIRED", label: "Manual" }
|
{ value: "APPROVAL_REQUIRED", label: "Manual" }
|
||||||
].map(option => (
|
]}
|
||||||
<button
|
/>
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
className={`rounded-lg border px-3 py-1.5 text-xs font-semibold transition ${localJoinPolicy === option.value ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel hover:border-accent"}`}
|
|
||||||
onClick={() => handleJoinPolicyChange(option.value as "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED")}
|
|
||||||
aria-pressed={localJoinPolicy === option.value}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="divider" />
|
<div className="divider" />
|
||||||
@ -518,42 +515,55 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|||||||
className={`rounded-lg border px-3 py-2 text-sm ${link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt) ? "border-red-400/60 bg-red-500/5" : "border-accent-weak bg-panel"}`}
|
className={`rounded-lg border px-3 py-2 text-sm ${link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt) ? "border-red-400/60 bg-red-500/5" : "border-accent-weak bg-panel"}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="text-xs text-soft">Token: {link.token.slice(0, 6)}…{link.token.slice(-4)}</div>
|
<div className="text-xs text-soft">Token: {link.token.slice(0, 6)}...{link.token.slice(-4)}</div>
|
||||||
<div className="flex items-center gap-2">
|
{(() => {
|
||||||
<button
|
const showRevive = link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt);
|
||||||
type="button"
|
const showRevoke = !showRevive && !link.revokedAt && !(link.singleUse && link.usedAt) && !isInviteExpired(link.expiresAt);
|
||||||
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
|
const options = [
|
||||||
onClick={() => handleCopyInvite(link.token)}
|
{
|
||||||
disabled={localJoinPolicy === "NOT_ACCEPTING"}
|
value: "COPY",
|
||||||
>
|
label: "Copy link",
|
||||||
Copy link
|
className: "btn-outline-accent",
|
||||||
</button>
|
disabled: localJoinPolicy === "NOT_ACCEPTING",
|
||||||
{(link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt)) ? (
|
onClick: () => handleCopyInvite(link.token)
|
||||||
<button
|
},
|
||||||
type="button"
|
...(showRevive
|
||||||
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
|
? [{
|
||||||
onClick={() => reviveInvite(link.id, inviteTtlDays)}
|
value: "REVIVE",
|
||||||
disabled={localJoinPolicy === "NOT_ACCEPTING"}
|
label: "Revive",
|
||||||
>
|
className: "btn-outline-accent",
|
||||||
Revive
|
disabled: localJoinPolicy === "NOT_ACCEPTING",
|
||||||
</button>
|
onClick: () => reviveInvite(link.id, inviteTtlDays)
|
||||||
) : (!link.revokedAt && !(link.singleUse && link.usedAt) && !isInviteExpired(link.expiresAt)) ? (
|
}]
|
||||||
<button
|
: showRevoke
|
||||||
type="button"
|
? [{
|
||||||
className="rounded-lg border border-red-400/60 bg-red-500/10 px-2 py-1 text-xs text-red-200"
|
value: "REVOKE",
|
||||||
onClick={() => revokeInvite(link.id)}
|
label: "Revoke",
|
||||||
>
|
className: "border border-red-400/60 bg-red-500/10 text-red-200",
|
||||||
Revoke
|
onClick: () => revokeInvite(link.id)
|
||||||
</button>
|
}]
|
||||||
) : null}
|
: []),
|
||||||
<button
|
{
|
||||||
type="button"
|
value: "DELETE",
|
||||||
className="rounded-lg border border-red-400/60 bg-red-500/10 px-2 py-1 text-xs text-red-200"
|
label: "Delete",
|
||||||
onClick={() => setConfirmDeleteInvite({ id: link.id, token: link.token })}
|
className: "border border-red-400/60 bg-red-500/10 text-red-200",
|
||||||
>
|
onClick: () => setConfirmDeleteInvite({ id: link.id, token: link.token })
|
||||||
Delete
|
}
|
||||||
</button>
|
];
|
||||||
</div>
|
|
||||||
|
return (
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={null}
|
||||||
|
ariaLabel="Invite actions"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
buttonBaseClassName="rounded-lg"
|
||||||
|
sizeClassName="px-2 py-1 text-xs"
|
||||||
|
activeClassName=""
|
||||||
|
inactiveClassName=""
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-3 text-[11px] text-soft">
|
<div className="mt-2 flex flex-wrap gap-3 text-[11px] text-soft">
|
||||||
<span>Expires {formatInviteExpiry(link.expiresAt)}</span>
|
<span>Expires {formatInviteExpiry(link.expiresAt)}</span>
|
||||||
@ -577,10 +587,10 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|||||||
{members.map(member => {
|
{members.map(member => {
|
||||||
const isSelf = member.userId === currentUserId;
|
const isSelf = member.userId === currentUserId;
|
||||||
const privilegeLabel = member.role === "GROUP_OWNER"
|
const privilegeLabel = member.role === "GROUP_OWNER"
|
||||||
? "👑 Owner · Full control"
|
? "Owner - Full control"
|
||||||
: member.role === "GROUP_ADMIN"
|
: member.role === "GROUP_ADMIN"
|
||||||
? "🛡️ Admin · Manage members"
|
? "Admin - Manage members"
|
||||||
: "👤 Member · Entries only";
|
: "Member - Entries only";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={member.userId}
|
key={member.userId}
|
||||||
@ -806,7 +816,7 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|||||||
onClick={handleCloseRenameModal}
|
onClick={handleCloseRenameModal}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
✕
|
x
|
||||||
</button>
|
</button>
|
||||||
<div className="text-lg font-semibold">Change group name</div>
|
<div className="text-lg font-semibold">Change group name</div>
|
||||||
<input
|
<input
|
||||||
@ -867,9 +877,7 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-lg font-semibold">Edit tags</div>
|
<div className="text-lg font-semibold">Edit tags</div>
|
||||||
<button type="button" className="rounded-lg btn-outline-accent px-2 py-1 text-sm" onClick={() => setTagModalOpen(false)} aria-label="Close">
|
<button type="button" className="rounded-lg btn-outline-accent px-2 py-1 text-sm" onClick={() => setTagModalOpen(false)} aria-label="Close">x</button>
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
<TagInput
|
<TagInput
|
||||||
@ -938,7 +946,7 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|||||||
<ConfirmSlideModal
|
<ConfirmSlideModal
|
||||||
isOpen={Boolean(confirmDeleteInvite)}
|
isOpen={Boolean(confirmDeleteInvite)}
|
||||||
title="Delete invite link"
|
title="Delete invite link"
|
||||||
description={confirmDeleteInvite ? `Delete invite ${confirmDeleteInvite.token.slice(0, 6)}…${confirmDeleteInvite.token.slice(-4)}?` : ""}
|
description={confirmDeleteInvite ? `Delete invite ${confirmDeleteInvite.token.slice(0, 6)}...${confirmDeleteInvite.token.slice(-4)}?` : ""}
|
||||||
confirmLabel="Delete link"
|
confirmLabel="Delete link"
|
||||||
onClose={() => setConfirmDeleteInvite(null)}
|
onClose={() => setConfirmDeleteInvite(null)}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
@ -1000,46 +1008,17 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|||||||
if (ok) notify({ title: "Ownership transferred", message: target.name });
|
if (ok) notify({ title: "Ownership transferred", message: target.name });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{confirmDeleteGroupOpen ? (
|
<ConfirmRetypeModal
|
||||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={() => setConfirmDeleteGroupOpen(false)}>
|
isOpen={confirmDeleteGroupOpen}
|
||||||
<div
|
title="Delete group"
|
||||||
className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5"
|
description="Type DELETE to confirm. This cannot be undone."
|
||||||
onClick={event => event.stopPropagation()}
|
expectedText="DELETE"
|
||||||
onKeyDown={event => {
|
|
||||||
if (event.key === "Escape") setConfirmDeleteGroupOpen(false);
|
|
||||||
if (event.key === "Enter" && deleteConfirmText.trim().toUpperCase() === "DELETE") handleDeleteGroup();
|
|
||||||
}}
|
|
||||||
role="dialog"
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
<div className="text-lg font-semibold text-red-200">Delete group</div>
|
|
||||||
<p className="mt-2 text-sm text-muted">Type DELETE to confirm. This cannot be undone.</p>
|
|
||||||
<input
|
|
||||||
className={`mt-4 w-full input-base px-3 py-2 text-sm ${deleteConfirmText.trim().toUpperCase() === "DELETE" ? "" : "border-red-400/70"}`}
|
|
||||||
value={deleteConfirmText}
|
value={deleteConfirmText}
|
||||||
onChange={e => setDeleteConfirmText(e.target.value)}
|
onChange={setDeleteConfirmText}
|
||||||
placeholder="DELETE"
|
confirmLabel="Delete"
|
||||||
|
onClose={() => setConfirmDeleteGroupOpen(false)}
|
||||||
|
onConfirm={handleDeleteGroup}
|
||||||
/>
|
/>
|
||||||
<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={() => setConfirmDeleteGroupOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</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 disabled:opacity-40"
|
|
||||||
disabled={deleteConfirmText.trim().toUpperCase() !== "DELETE"}
|
|
||||||
onClick={handleDeleteGroup}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -127,8 +127,7 @@ export default function Navbar() {
|
|||||||
className="w-full rounded-lg btn-outline-accent px-3 py-2 text-xs font-semibold"
|
className="w-full rounded-lg btn-outline-accent px-3 py-2 text-xs font-semibold"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUserMenuOpen(false);
|
setUserMenuOpen(false);
|
||||||
if (activeGroupId) router.push("/groups/settings");
|
router.push("/settings");
|
||||||
else router.push("/");
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Settings
|
Settings
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type React from "react";
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import TagInput from "@/components/tag-input";
|
import TagInput from "@/components/tag-input";
|
||||||
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
||||||
|
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||||
|
|
||||||
type BucketForm = {
|
type BucketForm = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -24,9 +25,11 @@ type NewBucketModalProps = {
|
|||||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||||
onChange: (next: Partial<BucketForm>) => void;
|
onChange: (next: Partial<BucketForm>) => void;
|
||||||
tagSuggestions: string[];
|
tagSuggestions: string[];
|
||||||
|
canDelete?: boolean;
|
||||||
|
onDelete?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NewBucketModal({ isOpen, title, form, error, onClose, onSubmit, onChange, tagSuggestions }: NewBucketModalProps) {
|
export default function NewBucketModal({ isOpen, title, form, error, onClose, onSubmit, onChange, tagSuggestions, canDelete = false, onDelete }: NewBucketModalProps) {
|
||||||
const [iconModalOpen, setIconModalOpen] = useState(false);
|
const [iconModalOpen, setIconModalOpen] = useState(false);
|
||||||
const [iconSearch, setIconSearch] = useState("");
|
const [iconSearch, setIconSearch] = useState("");
|
||||||
const formRef = useRef<HTMLFormElement | null>(null);
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
@ -125,22 +128,18 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="text-sm text-muted md:col-span-2">
|
<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">
|
<ToggleButtonGroup
|
||||||
{([
|
value={form.necessity}
|
||||||
{ value: "NECESSARY", label: "Necessary" },
|
onChange={necessity => onChange({ necessity })}
|
||||||
{ value: "BOTH", label: "Both" },
|
ariaLabel="Necessity"
|
||||||
{ value: "UNNECESSARY", label: "Unnecessary" }
|
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
|
||||||
] as const).map(option => (
|
sizeClassName="px-3 py-2.5 text-xs font-semibold"
|
||||||
<button
|
options={[
|
||||||
key={option.value}
|
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
|
||||||
type="button"
|
{ value: "BOTH", label: "Both", className: "flex-1" },
|
||||||
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
|
||||||
onClick={() => onChange({ necessity: option.value })}
|
]}
|
||||||
>
|
/>
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<label className="text-sm text-muted md:col-span-2">
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
Description
|
Description
|
||||||
@ -160,11 +159,22 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on
|
|||||||
onToggleTag={tag => onChange({ tags: form.tags.filter(item => item !== tag) })}
|
onToggleTag={tag => onChange({ tags: form.tags.filter(item => item !== tag) })}
|
||||||
onAddTag={tag => onChange({ tags: [...form.tags, tag] })}
|
onAddTag={tag => onChange({ tags: [...form.tags, tag] })}
|
||||||
/>
|
/>
|
||||||
<div className="md:col-span-2 flex items-center justify-between">
|
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
||||||
|
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold">
|
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold">
|
||||||
Save bucket
|
Save bucket
|
||||||
</button>
|
</button>
|
||||||
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
{canDelete ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg border border-red-400/70 bg-red-500/10 px-4 py-2 text-sm font-semibold text-red-200 hover:bg-red-500/15"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
Delete bucket
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import TagInput from "@/components/tag-input";
|
import TagInput from "@/components/tag-input";
|
||||||
|
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||||
|
import DatePicker from "@/components/date-picker";
|
||||||
|
|
||||||
type NewEntryForm = {
|
type NewEntryForm = {
|
||||||
amountDollars: string;
|
amountDollars: string;
|
||||||
@ -11,12 +13,6 @@ type NewEntryForm = {
|
|||||||
notes: string;
|
notes: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
entryType: "SPENDING" | "INCOME";
|
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 = {
|
type NewEntryModalProps = {
|
||||||
@ -35,7 +31,6 @@ type NewEntryModalProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit, onChange, tagSuggestions, emptyTagActionLabel, emptyTagActionDisabled = false, onEmptyTagAction, amountInputRef, tagsInputRef }: NewEntryModalProps) {
|
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 typeLabel = form.entryType === "INCOME" ? "Income" : "Expense";
|
||||||
const formRef = useRef<HTMLFormElement | null>(null);
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
@ -47,23 +42,12 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
|
|
||||||
if (!form.occurredAt) {
|
if (!form.occurredAt) {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
onChange({ occurredAt: today, endDate: form.endDate || today });
|
onChange({ occurredAt: today });
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [form.endDate, form.occurredAt, isOpen, onChange, onClose]);
|
}, [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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@ -77,44 +61,28 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
onClick={event => event.stopPropagation()}
|
onClick={event => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">New {recurrenceLabel} {typeLabel} Entry</h2>
|
<h2 className="text-lg font-semibold">New {typeLabel} Entry</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
✕
|
x
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<div
|
<ToggleButtonGroup
|
||||||
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel">
|
value={form.entryType}
|
||||||
<button
|
onChange={entryType => onChange({ entryType })}
|
||||||
type="button"
|
ariaLabel="Entry type"
|
||||||
className={`rounded-full mr-[-10px] px-4 py-2.5 text-xs font-semibold ${form.entryType === "SPENDING" ? "btn-accent" : "text-muted"}`}
|
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel"
|
||||||
onClick={() => onChange({ entryType: form.entryType === "SPENDING" ? "INCOME" : "SPENDING" })}
|
sizeClassName="px-4 py-2.5 text-xs font-semibold"
|
||||||
>
|
options={[
|
||||||
Spending
|
{ value: "SPENDING", label: "Spending", className: "mr-[-10px]" },
|
||||||
</button>
|
{ value: "INCOME", label: "Income" }
|
||||||
<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>
|
</div>
|
||||||
<form
|
<form
|
||||||
ref={formRef}
|
ref={formRef}
|
||||||
@ -129,10 +97,9 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
className="mt-3 grid gap-3 md:grid-cols-2"
|
className="mt-3 grid gap-3 md:grid-cols-2"
|
||||||
>
|
>
|
||||||
<label className="text-sm text-muted">
|
<label className="text-sm text-muted">
|
||||||
{/* Amount ($) */}
|
|
||||||
<div className="relative mt-1">
|
<div className="relative mt-1">
|
||||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-soft">
|
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-soft">
|
||||||
{form.entryType === "INCOME" ? "🤑 $" : "😭 $"}
|
{form.entryType === "INCOME" ? "$" : "$"}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
ref={amountInputRef}
|
ref={amountInputRef}
|
||||||
@ -148,41 +115,28 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<div className="text-sm text-muted">
|
<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`}>
|
<DatePicker
|
||||||
<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"
|
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}
|
value={form.occurredAt}
|
||||||
onChange={e => onChange({ occurredAt: e.target.value })}
|
onChange={occurredAt => onChange({ occurredAt })}
|
||||||
required
|
required
|
||||||
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
<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>
|
||||||
<div className="text-sm text-muted">
|
<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">
|
<ToggleButtonGroup
|
||||||
{([
|
value={form.necessity}
|
||||||
{ value: "NECESSARY", label: "Necessary" },
|
onChange={necessity => onChange({ necessity })}
|
||||||
{ value: "BOTH", label: "Both" },
|
ariaLabel="Necessity"
|
||||||
{ value: "UNNECESSARY", label: "Unnecessary" }
|
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
|
||||||
] as const).map(option => (
|
sizeClassName="px-3 py-2.5 text-xs font-semibold"
|
||||||
<button
|
options={[
|
||||||
key={option.value}
|
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
|
||||||
type="button"
|
{ value: "BOTH", label: "Both", className: "flex-1" },
|
||||||
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
|
||||||
onClick={() => onChange({ necessity: option.value })}
|
]}
|
||||||
>
|
/>
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* TAGS */}
|
|
||||||
<TagInput
|
<TagInput
|
||||||
label="Tags"
|
label="Tags"
|
||||||
tags={form.tags}
|
tags={form.tags}
|
||||||
@ -196,80 +150,6 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
invalid={!form.tags.length}
|
invalid={!form.tags.length}
|
||||||
inputRef={tagsInputRef}
|
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">
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
Notes
|
Notes
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
226
apps/web/components/new-schedule-modal.tsx
Normal file
226
apps/web/components/new-schedule-modal.tsx
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import DatePicker from "@/components/date-picker";
|
||||||
|
import TagInput from "@/components/tag-input";
|
||||||
|
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||||
|
import type { ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
|
||||||
|
|
||||||
|
export type NewScheduleForm = {
|
||||||
|
amountDollars: string;
|
||||||
|
startsOn: string;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
notes: string;
|
||||||
|
tags: string[];
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
frequency: ScheduleFrequency;
|
||||||
|
intervalCount: number;
|
||||||
|
endCondition: ScheduleEndCondition;
|
||||||
|
endCount: string;
|
||||||
|
endDate: string;
|
||||||
|
createEntryNow: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NewScheduleModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
form: NewScheduleForm;
|
||||||
|
error: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
onChange: (next: Partial<NewScheduleForm>) => void;
|
||||||
|
tagSuggestions: string[];
|
||||||
|
emptyTagActionLabel?: string;
|
||||||
|
emptyTagActionDisabled?: boolean;
|
||||||
|
onEmptyTagAction?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NewScheduleModal({
|
||||||
|
isOpen,
|
||||||
|
form,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
onChange,
|
||||||
|
tagSuggestions,
|
||||||
|
emptyTagActionLabel,
|
||||||
|
emptyTagActionDisabled = false,
|
||||||
|
onEmptyTagAction
|
||||||
|
}: NewScheduleModalProps) {
|
||||||
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
if (!form.startsOn) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
onChange({ startsOn: today });
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [form.startsOn, isOpen, onChange, 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="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 Schedule</h2>
|
||||||
|
<button type="button" onClick={onClose} className="rounded-lg btn-outline-accent px-2 py-1 text-sm" aria-label="Close">
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.entryType}
|
||||||
|
onChange={entryType => onChange({ entryType })}
|
||||||
|
ariaLabel="Entry type"
|
||||||
|
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel"
|
||||||
|
sizeClassName="px-4 py-2.5 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "SPENDING", label: "Spending", className: "mr-[-10px]" },
|
||||||
|
{ value: "INCOME", label: "Income" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.createEntryNow ? "NOW" : "NEXT"}
|
||||||
|
onChange={value => onChange({ createEntryNow: value === "NOW" })}
|
||||||
|
ariaLabel="Create behavior"
|
||||||
|
sizeClassName="px-3 py-2 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "NOW", label: "Create Entry Now" },
|
||||||
|
{ value: "NEXT", label: "Start Next Schedule" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</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 ($)
|
||||||
|
<input
|
||||||
|
name="amountDollars"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
className={`mt-1 w-full input-base px-3 py-2 text-sm ${form.amountDollars ? "" : "border-red-400/70"}`}
|
||||||
|
value={form.amountDollars}
|
||||||
|
onChange={e => onChange({ amountDollars: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
<DatePicker name="startsOn" value={form.startsOn} onChange={startsOn => onChange({ startsOn })} required className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.necessity}
|
||||||
|
onChange={necessity => onChange({ necessity })}
|
||||||
|
ariaLabel="Necessity"
|
||||||
|
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
|
||||||
|
sizeClassName="px-3 py-2.5 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
|
||||||
|
{ value: "BOTH", label: "Both", className: "flex-1" },
|
||||||
|
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-center gap-y-2">
|
||||||
|
<div className="text-sm text-muted mr-2">Every</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="mr-1 w-12 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-[100px] input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.frequency}
|
||||||
|
onChange={e => onChange({ frequency: e.target.value as ScheduleFrequency })}
|
||||||
|
>
|
||||||
|
<option value="DAILY">daily</option>
|
||||||
|
<option value="WEEKLY">weakly</option>
|
||||||
|
<option value="MONTHLY">monthly</option>
|
||||||
|
<option value="YEARLY">yearly</option>
|
||||||
|
</select>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.endCondition}
|
||||||
|
onChange={endCondition => onChange({ endCondition })}
|
||||||
|
ariaLabel="End condition"
|
||||||
|
className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel"
|
||||||
|
sizeClassName="px-3 py-3 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "NEVER", label: "Forever" },
|
||||||
|
{ value: "BY_DATE", label: "Until" },
|
||||||
|
{ value: "AFTER_COUNT", label: "After" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{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" ? (
|
||||||
|
<DatePicker
|
||||||
|
value={form.endDate}
|
||||||
|
onChange={endDate => onChange({ endDate })}
|
||||||
|
showWeekButtons={false}
|
||||||
|
centerInput
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
Save schedule
|
||||||
|
</button>
|
||||||
|
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,73 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
261
apps/web/components/schedule-details-modal.tsx
Normal file
261
apps/web/components/schedule-details-modal.tsx
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import DatePicker from "@/components/date-picker";
|
||||||
|
import TagInput from "@/components/tag-input";
|
||||||
|
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||||
|
import type { ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
|
||||||
|
|
||||||
|
export type ScheduleDetailsForm = {
|
||||||
|
amountDollars: string;
|
||||||
|
startsOn: string;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
notes: string;
|
||||||
|
tags: string[];
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
frequency: ScheduleFrequency;
|
||||||
|
intervalCount: number;
|
||||||
|
endCondition: ScheduleEndCondition;
|
||||||
|
endCount: string;
|
||||||
|
endDate: string;
|
||||||
|
nextRunOn: string;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ScheduleDetailsModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
form: ScheduleDetailsForm;
|
||||||
|
originalForm: ScheduleDetailsForm | null;
|
||||||
|
isDirty: boolean;
|
||||||
|
error: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
onRequestDelete: () => void;
|
||||||
|
onRevert: () => void;
|
||||||
|
onChange: (next: Partial<ScheduleDetailsForm>) => void;
|
||||||
|
onAddTag: (tag: string) => void;
|
||||||
|
onToggleTag: (tag: string) => void;
|
||||||
|
removedTags: string[];
|
||||||
|
tagSuggestions: string[];
|
||||||
|
emptyTagActionLabel?: string;
|
||||||
|
emptyTagActionDisabled?: boolean;
|
||||||
|
onEmptyTagAction?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ScheduleDetailsModal({
|
||||||
|
isOpen,
|
||||||
|
form,
|
||||||
|
originalForm,
|
||||||
|
isDirty,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
onRequestDelete,
|
||||||
|
onRevert,
|
||||||
|
onChange,
|
||||||
|
onAddTag,
|
||||||
|
onToggleTag,
|
||||||
|
removedTags,
|
||||||
|
tagSuggestions,
|
||||||
|
emptyTagActionLabel,
|
||||||
|
emptyTagActionDisabled = false,
|
||||||
|
onEmptyTagAction
|
||||||
|
}: ScheduleDetailsModalProps) {
|
||||||
|
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 addedTags = currentTags.filter(tag => !baselineTags.some(base => base.toLowerCase() === tag.toLowerCase()));
|
||||||
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
|
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="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">Schedule Details</h2>
|
||||||
|
<button type="button" onClick={onClose} className="rounded-lg btn-outline-accent px-2 py-1 text-sm" aria-label="Close">
|
||||||
|
x
|
||||||
|
</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 items-center gap-2">
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.entryType}
|
||||||
|
onChange={entryType => onChange({ entryType })}
|
||||||
|
ariaLabel="Entry type"
|
||||||
|
sizeClassName="px-4 py-2.5 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "SPENDING", label: "Spending" },
|
||||||
|
{ value: "INCOME", label: "Income" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.isActive ? "ACTIVE" : "PAUSED"}
|
||||||
|
onChange={value => onChange({ isActive: value === "ACTIVE" })}
|
||||||
|
ariaLabel="Schedule active status"
|
||||||
|
sizeClassName="px-3 py-2 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "ACTIVE", label: "Active" },
|
||||||
|
{ value: "PAUSED", label: "Paused" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
value={form.amountDollars}
|
||||||
|
onChange={e => onChange({ amountDollars: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
<DatePicker name="startsOn" value={form.startsOn} onChange={startsOn => onChange({ startsOn })} required className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.necessity}
|
||||||
|
onChange={necessity => onChange({ necessity })}
|
||||||
|
ariaLabel="Necessity"
|
||||||
|
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
|
||||||
|
sizeClassName="px-3 py-2.5 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
|
||||||
|
{ value: "BOTH", label: "Both", className: "flex-1" },
|
||||||
|
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-center gap-y-2">
|
||||||
|
<div className="text-sm text-muted mr-2">Every</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="mr-1 w-12 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-[100px] input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.frequency}
|
||||||
|
onChange={e => onChange({ frequency: e.target.value as ScheduleFrequency })}
|
||||||
|
>
|
||||||
|
<option value="DAILY">daily</option>
|
||||||
|
<option value="WEEKLY">weakly</option>
|
||||||
|
<option value="MONTHLY">monthly</option>
|
||||||
|
<option value="YEARLY">yearly</option>
|
||||||
|
</select>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.endCondition}
|
||||||
|
onChange={endCondition => onChange({ endCondition })}
|
||||||
|
ariaLabel="End condition"
|
||||||
|
className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel"
|
||||||
|
sizeClassName="px-3 py-3 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "NEVER", label: "Forever" },
|
||||||
|
{ value: "BY_DATE", label: "Until" },
|
||||||
|
{ value: "AFTER_COUNT", label: "After" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{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" ? (
|
||||||
|
<DatePicker
|
||||||
|
value={form.endDate}
|
||||||
|
onChange={endDate => onChange({ endDate })}
|
||||||
|
showWeekButtons={false}
|
||||||
|
centerInput
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
Next run
|
||||||
|
<DatePicker name="nextRunOn" value={form.nextRunOn} onChange={nextRunOn => onChange({ nextRunOn })} required className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<button type="button" onClick={onRevert} disabled={!isDirty} className="rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold disabled:opacity-40">
|
||||||
|
Revert
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold disabled:opacity-35" 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>
|
||||||
|
<div className="flex-1 w-full" />
|
||||||
|
<button type="button" onClick={onClose} className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
apps/web/components/settings-content.tsx
Normal file
58
apps/web/components/settings-content.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import useUserSettings from "@/hooks/use-user-settings";
|
||||||
|
import { useNotificationsContext } from "@/hooks/notifications-context";
|
||||||
|
|
||||||
|
export default function SettingsContent() {
|
||||||
|
const { settings, loading, error, updateSettings } = useUserSettings();
|
||||||
|
const { notify } = useNotificationsContext();
|
||||||
|
const [entryPanelPageSize, setEntryPanelPageSize] = useState("10");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEntryPanelPageSize(String(settings.entryPanelPageSize || 10));
|
||||||
|
}, [settings.entryPanelPageSize]);
|
||||||
|
|
||||||
|
async function handleSave(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const nextSize = Number(entryPanelPageSize);
|
||||||
|
if (!Number.isFinite(nextSize) || nextSize <= 0) return;
|
||||||
|
const ok = await updateSettings({ entryPanelPageSize: nextSize });
|
||||||
|
if (!ok) return;
|
||||||
|
notify({ title: "Settings saved", message: `Entry panel page size: ${nextSize}`, tone: "success" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="panel panel-accent p-4">
|
||||||
|
<div className="text-sm text-muted">Loading settings...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="panel panel-accent p-4">
|
||||||
|
<h1 className="text-xl font-semibold">User settings</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted">These settings apply to your account across groups.</p>
|
||||||
|
<form className="mt-4 space-y-3" onSubmit={handleSave}>
|
||||||
|
<label className="block text-sm text-muted">
|
||||||
|
Entry/Schedule cards per page
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={entryPanelPageSize}
|
||||||
|
onChange={event => setEntryPanelPageSize(event.target.value)}
|
||||||
|
className="mt-1 w-40 input-base px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -106,7 +106,6 @@ export default function TagInput({ label, labelAction, tags, suggestions, remove
|
|||||||
|
|
||||||
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
if (false && event.key === "Backspace" && !value && tags.length) {
|
if (false && event.key === "Backspace" && !value && tags.length) {
|
||||||
console.log("Backspace pressed with empty input, removing last tag");
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onToggleTag?.(tags[tags.length - 1]);
|
onToggleTag?.(tags[tags.length - 1]);
|
||||||
return;
|
return;
|
||||||
|
|||||||
77
apps/web/components/toggle-button-group.tsx
Normal file
77
apps/web/components/toggle-button-group.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
type ToggleButtonOption<T extends string> = {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
className?: string;
|
||||||
|
activeClassName?: string;
|
||||||
|
inactiveClassName?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToggleButtonGroupProps<T extends string> = {
|
||||||
|
value?: T | null;
|
||||||
|
options: ToggleButtonOption<T>[];
|
||||||
|
onChange?: (value: T) => void;
|
||||||
|
ariaLabel?: string;
|
||||||
|
role?: "group" | "radiogroup";
|
||||||
|
className?: string;
|
||||||
|
buttonBaseClassName?: string;
|
||||||
|
buttonClassName?: string;
|
||||||
|
activeClassName?: string;
|
||||||
|
inactiveClassName?: string;
|
||||||
|
sizeClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function joinClasses(parts: Array<string | undefined | null | false>) {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToggleButtonGroup<T extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
ariaLabel,
|
||||||
|
role = "group",
|
||||||
|
className = "flex items-center gap-0 rounded-full border border-accent-weak bg-panel",
|
||||||
|
buttonBaseClassName = "rounded-full",
|
||||||
|
buttonClassName,
|
||||||
|
activeClassName = "btn-accent",
|
||||||
|
inactiveClassName = "text-muted",
|
||||||
|
sizeClassName = "px-3 py-2 text-xs font-semibold"
|
||||||
|
}: ToggleButtonGroupProps<T>) {
|
||||||
|
return (
|
||||||
|
<div className={className} role={role} aria-label={ariaLabel}>
|
||||||
|
{options.map(option => {
|
||||||
|
const isActive = value != null && option.value === value;
|
||||||
|
const onClick = option.onClick
|
||||||
|
? option.onClick
|
||||||
|
: onChange
|
||||||
|
? () => onChange(option.value)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={joinClasses([
|
||||||
|
buttonBaseClassName,
|
||||||
|
sizeClassName,
|
||||||
|
buttonClassName,
|
||||||
|
isActive ? option.activeClassName ?? activeClassName : option.inactiveClassName ?? inactiveClassName,
|
||||||
|
option.className
|
||||||
|
])}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={option.disabled}
|
||||||
|
aria-pressed={value != null ? isActive : undefined}
|
||||||
|
aria-label={option.ariaLabel}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,7 +11,7 @@ test("login and register hide navbar", async ({ page }) => {
|
|||||||
test("login shows entries for seeded owner", async ({ page }) => {
|
test("login shows entries for seeded owner", async ({ page }) => {
|
||||||
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
await expect(page.getByRole("heading", { name: "Entries" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Entries" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("no-group user sees empty state", async ({ page }) => {
|
test("no-group user sees empty state", async ({ page }) => {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ test("group dropdown lists seeded groups", async ({ page }) => {
|
|||||||
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
const dropdown = page.getByRole("button", { name: /Group:/ });
|
const dropdown = page.getByRole("button", { name: /▼$/ });
|
||||||
await dropdown.click();
|
await dropdown.click();
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Alpha Household GROUP_ADMIN" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Alpha Household GROUP_ADMIN" })).toBeVisible();
|
||||||
@ -18,7 +18,7 @@ test("group settings show join requests and policy", async ({ page }) => {
|
|||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Group settings" }).click();
|
await page.getByRole("button", { name: "Group settings" }).click();
|
||||||
await expect(page).toHaveURL(/\/groups\/[0-9]+\/settings/);
|
await expect(page).toHaveURL(/\/groups\/settings/);
|
||||||
|
|
||||||
await expect(page.getByText("Join requests")).toBeVisible();
|
await expect(page.getByText("Join requests")).toBeVisible();
|
||||||
await expect(page.getByText("requester1@fiddy.dev")).toBeVisible();
|
await expect(page.getByText("requester1@fiddy.dev")).toBeVisible();
|
||||||
|
|||||||
@ -5,18 +5,18 @@ test("seeded entries render with tags and no-tag state", async ({ page }) => {
|
|||||||
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Entries" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Entries" })).toBeVisible();
|
||||||
await expect(page.getByText("$12.50")).toBeVisible();
|
await expect(page.getByText("$12.50").first()).toBeVisible();
|
||||||
await expect(page.getByText("#Food")).toBeVisible();
|
await expect(page.locator("span:visible", { hasText: "#Food" }).first()).toBeVisible();
|
||||||
await expect(page.getByText("#Travel")).toBeVisible();
|
await expect(page.locator("span:visible", { hasText: "#Travel" }).first()).toBeVisible();
|
||||||
await expect(page.getByText("No tags")).toBeVisible();
|
await expect(page.locator("span:visible", { hasText: "No tags" }).first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("entry details modal opens", async ({ page }) => {
|
test("entry details modal opens", async ({ page }) => {
|
||||||
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
await page.getByText("$12.50").click();
|
await page.getByText("$12.50").first().click();
|
||||||
await expect(page.getByRole("heading", { name: "Entry details" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Entry details" })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Close" }).click();
|
await page.getByRole("button", { name: "Close" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Entry details" })).toBeHidden();
|
await expect(page.getByRole("heading", { name: "Entry details" })).toBeHidden();
|
||||||
@ -26,13 +26,14 @@ test("empty tag callout shows contact admin for members", async ({ page }) => {
|
|||||||
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
const dropdown = page.getByRole("button", { name: /Group:/ });
|
const dropdown = page.getByRole("button", { name: /▼$/ });
|
||||||
await dropdown.click();
|
await dropdown.click();
|
||||||
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
||||||
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
||||||
await waitSetActive;
|
await waitSetActive;
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Add entry" }).click();
|
await page.getByRole("button", { name: "Add entry" }).click();
|
||||||
|
await page.getByPlaceholder("Add tags... (who, where, why, etc.)").fill("missing");
|
||||||
const callout = page.getByRole("button", { name: "No Tags Assigned Yet - Contact Your Group Admin" });
|
const callout = page.getByRole("button", { name: "No Tags Assigned Yet - Contact Your Group Admin" });
|
||||||
await expect(callout).toBeVisible();
|
await expect(callout).toBeVisible();
|
||||||
await expect(callout).toBeDisabled();
|
await expect(callout).toBeDisabled();
|
||||||
@ -42,13 +43,14 @@ test("empty tag callout navigates to settings for admins", async ({ page }) => {
|
|||||||
await login(page, "member1@fiddy.dev", "FiddyDev123!");
|
await login(page, "member1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
const dropdown = page.getByRole("button", { name: /Group:/ });
|
const dropdown = page.getByRole("button", { name: /▼$/ });
|
||||||
await dropdown.click();
|
await dropdown.click();
|
||||||
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
||||||
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
||||||
await waitSetActive;
|
await waitSetActive;
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Add entry" }).click();
|
await page.getByRole("button", { name: "Add entry" }).click();
|
||||||
|
await page.getByPlaceholder("Add tags... (who, where, why, etc.)").fill("missing");
|
||||||
await page.getByRole("button", { name: "No Tags Assigned Yet - Click To Assign Tags" }).click();
|
await page.getByRole("button", { name: "No Tags Assigned Yet - Click To Assign Tags" }).click();
|
||||||
await expect(page).toHaveURL(/\/groups\/[0-9]+\/settings/);
|
await expect(page).toHaveURL(/\/groups\/settings/);
|
||||||
});
|
});
|
||||||
|
|||||||
15
apps/web/eslint.config.mjs
Normal file
15
apps/web/eslint.config.mjs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
|
||||||
|
const config = [
|
||||||
|
...nextVitals,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"react-hooks/error-boundaries": "off",
|
||||||
|
"react-hooks/immutability": "off",
|
||||||
|
"react-hooks/purity": "off",
|
||||||
|
"react-hooks/set-state-in-effect": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default config;
|
||||||
9
apps/web/features/README.md
Normal file
9
apps/web/features/README.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Features
|
||||||
|
|
||||||
|
Domain-first frontend modules live here.
|
||||||
|
|
||||||
|
Current migrated domains:
|
||||||
|
- entries (components)
|
||||||
|
- buckets (components)
|
||||||
|
|
||||||
|
Future migrations should move domain-specific components/hooks/lib into these folders incrementally.
|
||||||
3
apps/web/features/auth/README.md
Normal file
3
apps/web/features/auth/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Auth Feature
|
||||||
|
|
||||||
|
Reserved for auth domain modules (components/hooks/lib) during incremental migration.
|
||||||
181
apps/web/features/buckets/components/bucket-card.tsx
Normal file
181
apps/web/features/buckets/components/bucket-card.tsx
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import type { Bucket } from "@/lib/client/buckets";
|
||||||
|
|
||||||
|
type BucketCardProps = {
|
||||||
|
bucket: Bucket;
|
||||||
|
icon?: string | null;
|
||||||
|
openEdit: (bucketId: number) => void;
|
||||||
|
limit: number;
|
||||||
|
usageLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TAG_GAP_PX = 8;
|
||||||
|
const TAG_CLASS = "shrink-0 rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]";
|
||||||
|
const TAG_MORE_CLASS = "shrink-0 rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-[11px] text-soft";
|
||||||
|
|
||||||
|
function BucketTagsRow({ tags }: { tags: string[] }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const moreRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const tagRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||||
|
const [visibleCount, setVisibleCount] = useState(tags.length);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tagRefs.current = tagRefs.current.slice(0, tags.length);
|
||||||
|
setVisibleCount(tags.length);
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tags.length) return;
|
||||||
|
|
||||||
|
function recomputeVisibleCount() {
|
||||||
|
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||||
|
const widths = tags.map((_, index) => tagRefs.current[index]?.offsetWidth ?? 0);
|
||||||
|
const moreProbe = moreRef.current;
|
||||||
|
|
||||||
|
if (!containerWidth || !moreProbe || widths.some(width => !width)) {
|
||||||
|
setVisibleCount(tags.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTagsWidth = widths.reduce((sum, width) => sum + width, 0) + TAG_GAP_PX * Math.max(widths.length - 1, 0);
|
||||||
|
if (totalTagsWidth <= containerWidth) {
|
||||||
|
setVisibleCount(tags.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextVisibleCount = 0;
|
||||||
|
let usedWidth = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < widths.length; index += 1) {
|
||||||
|
usedWidth += index === 0 ? widths[index] : TAG_GAP_PX + widths[index];
|
||||||
|
const remaining = widths.length - (index + 1);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
nextVisibleCount = widths.length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
moreProbe.textContent = `${remaining} more...`;
|
||||||
|
const moreWidth = moreProbe.offsetWidth;
|
||||||
|
const totalWithMore = usedWidth + TAG_GAP_PX + moreWidth;
|
||||||
|
if (totalWithMore <= containerWidth) nextVisibleCount = index + 1;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibleCount(nextVisibleCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
recomputeVisibleCount();
|
||||||
|
|
||||||
|
if (typeof ResizeObserver !== "undefined") {
|
||||||
|
const observer = new ResizeObserver(recomputeVisibleCount);
|
||||||
|
if (containerRef.current) observer.observe(containerRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("resize", recomputeVisibleCount);
|
||||||
|
return () => window.removeEventListener("resize", recomputeVisibleCount);
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
if (!tags.length) {
|
||||||
|
return <span className="text-[11px] text-soft">No tags</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleTags = tags.slice(0, visibleCount);
|
||||||
|
const hasOverflow = visibleCount < tags.length;
|
||||||
|
const remainingCount = tags.length - visibleCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<div ref={containerRef} className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||||
|
{visibleTags.map((tag, index) => (
|
||||||
|
<span key={`${tag}-${index}`} className={TAG_CLASS}>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{hasOverflow ? <span className={TAG_MORE_CLASS}>{remainingCount} more...</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute left-0 top-0 -z-10 opacity-0" aria-hidden="true">
|
||||||
|
{tags.map((tag, index) => (
|
||||||
|
<span
|
||||||
|
key={`${tag}-${index}`}
|
||||||
|
ref={element => {
|
||||||
|
tagRefs.current[index] = element;
|
||||||
|
}}
|
||||||
|
className={`${TAG_CLASS} inline-block`}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span ref={moreRef} className={`${TAG_MORE_CLASS} inline-block`}>
|
||||||
|
{tags.length} more...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BucketCard({
|
||||||
|
bucket,
|
||||||
|
icon,
|
||||||
|
openEdit,
|
||||||
|
limit,
|
||||||
|
usageLabel,
|
||||||
|
}: BucketCardProps) {
|
||||||
|
const spent = bucket.totalUsage || 0;
|
||||||
|
const rawPercent = limit > 0 ? (spent / limit) * 100 : 0;
|
||||||
|
const progressPercent = Math.max(0, Math.min(100, rawPercent));
|
||||||
|
const progressColor = rawPercent > 100 ? "#ef4444" : rawPercent >= 80 ? "#facc15" : "#4ade80";
|
||||||
|
const ringTrackColor = "rgba(148, 163, 184, 0.25)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full cursor-pointer rounded-lg border border-accent-weak bg-panel px-3 py-3 transition hover:border-accent"
|
||||||
|
onClick={() => openEdit(bucket.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
{limit > 0 ? (
|
||||||
|
<div className="relative h-11 w-11 shrink-0">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: `conic-gradient(${progressColor} 0% ${progressPercent}%, ${ringTrackColor} ${progressPercent}% 100%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-[5px] flex items-center justify-center rounded-full border border-accent-weak bg-surface text-lg">
|
||||||
|
{icon || "?"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-accent-weak bg-surface text-lg">
|
||||||
|
{icon || "?"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold">{bucket.name}</div>
|
||||||
|
{bucket.description ? (
|
||||||
|
<div className="text-xs text-soft">
|
||||||
|
{bucket.description}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 space-y-2 text-xs text-soft">
|
||||||
|
{limit > 0 ? <div>{usageLabel}</div> : null}
|
||||||
|
<div className="flex min-w-0 items-center">
|
||||||
|
<BucketTagsRow tags={bucket.tags || []} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(BucketCard, (prev, next) => (
|
||||||
|
prev.bucket === next.bucket
|
||||||
|
&& prev.icon === next.icon
|
||||||
|
&& prev.limit === next.limit
|
||||||
|
&& prev.usageLabel === next.usageLabel
|
||||||
|
));
|
||||||
@ -2,24 +2,24 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useGroupsContext } from "@/hooks/groups-context";
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
import useBuckets from "@/hooks/use-buckets";
|
import useBuckets from "@/features/buckets/hooks/use-buckets";
|
||||||
import useTags from "@/hooks/use-tags";
|
import useTags from "@/features/tags/hooks/use-tags";
|
||||||
import NewBucketModal from "@/components/new-bucket-modal";
|
import NewBucketModal from "@/components/new-bucket-modal";
|
||||||
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
||||||
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
||||||
import BucketCard from "./bucket-card";
|
import BucketCard from "./bucket-card";
|
||||||
import { useEntryMutation } from "@/hooks/entry-mutation-context";
|
import { useEntryMutation } from "@/hooks/entry-mutation-context";
|
||||||
|
import { useNotificationsContext } from "@/hooks/notifications-context";
|
||||||
|
|
||||||
export default function BucketsPanel() {
|
export default function BucketsPanel() {
|
||||||
const { activeGroupId } = useGroupsContext();
|
const { activeGroupId } = useGroupsContext();
|
||||||
const { buckets, loading, error, createBucket, updateBucket, deleteBucket, reload } = useBuckets(activeGroupId);
|
const { buckets, loading, error, createBucket, updateBucket, deleteBucket, reload } = useBuckets(activeGroupId);
|
||||||
const { mutationVersion } = useEntryMutation();
|
const { mutationVersion } = useEntryMutation();
|
||||||
|
const { notify } = useNotificationsContext();
|
||||||
const { tags: tagSuggestions } = useTags(activeGroupId);
|
const { tags: tagSuggestions } = useTags(activeGroupId);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editId, setEditId] = useState<number | null>(null);
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = 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({
|
const [form, setForm] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
@ -33,18 +33,6 @@ export default function BucketsPanel() {
|
|||||||
const iconMap = useMemo(() => new Map(bucketIcons.map(item => [item.key, item.icon])), []);
|
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]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!activeGroupId) return;
|
if (!activeGroupId) return;
|
||||||
if (mutationVersion === 0) return;
|
if (mutationVersion === 0) return;
|
||||||
@ -75,7 +63,6 @@ export default function BucketsPanel() {
|
|||||||
windowDays: bucket.windowDays ? String(bucket.windowDays) : "30"
|
windowDays: bucket.windowDays ? String(bucket.windowDays) : "30"
|
||||||
});
|
});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
setMenuOpenId(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
@ -97,7 +84,10 @@ export default function BucketsPanel() {
|
|||||||
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
windowDays
|
windowDays
|
||||||
});
|
});
|
||||||
if (ok) setModalOpen(false);
|
if (ok) {
|
||||||
|
notify({ title: "Bucket updated", message: form.name.trim(), tone: "success" });
|
||||||
|
setModalOpen(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const ok = await createBucket({
|
const ok = await createBucket({
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
@ -108,40 +98,17 @@ export default function BucketsPanel() {
|
|||||||
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
windowDays
|
windowDays
|
||||||
});
|
});
|
||||||
if (ok) setModalOpen(false);
|
if (ok) {
|
||||||
|
notify({ title: "Bucket created", message: form.name.trim(), tone: "success" });
|
||||||
|
setModalOpen(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleExpanded(bucketId: number) {
|
|
||||||
setExpandedIds(prev => prev.includes(bucketId)
|
|
||||||
? prev.filter(id => id !== bucketId)
|
|
||||||
: [...prev, bucketId]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function budgetUsage(bucket: typeof buckets[number]) {
|
function budgetUsage(bucket: typeof buckets[number]) {
|
||||||
const limit = bucket.budgetLimitDollars || 0;
|
const limit = bucket.budgetLimitDollars || 0;
|
||||||
const spent = bucket.totalUsage || 0;
|
const spent = bucket.totalUsage || 0;
|
||||||
const pct = limit > 0 ? (spent / limit) * 100 : 0;
|
return { limit, spent };
|
||||||
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 (
|
return (
|
||||||
@ -159,7 +126,7 @@ export default function BucketsPanel() {
|
|||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="mt-3 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(260px,1fr))]">
|
||||||
|
|
||||||
|
|
||||||
{!activeGroupId ? (
|
{!activeGroupId ? (
|
||||||
@ -181,21 +148,14 @@ export default function BucketsPanel() {
|
|||||||
orderedBuckets.map(bucket => {
|
orderedBuckets.map(bucket => {
|
||||||
const icon = bucket.iconKey ? iconMap.get(bucket.iconKey) : null;
|
const icon = bucket.iconKey ? iconMap.get(bucket.iconKey) : null;
|
||||||
const { limit, spent } = budgetUsage(bucket);
|
const { limit, spent } = budgetUsage(bucket);
|
||||||
const isExpanded = expandedIds.includes(bucket.id);
|
|
||||||
const usageLabel = limit ? `$${spent.toFixed(2)} / $${limit.toFixed(2)}` : "";
|
const usageLabel = limit ? `$${spent.toFixed(2)} / $${limit.toFixed(2)}` : "";
|
||||||
return <BucketCard
|
return <BucketCard
|
||||||
key={bucket.id}
|
key={bucket.id}
|
||||||
bucket={bucket}
|
bucket={bucket}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
isExpanded={isExpanded}
|
|
||||||
toggleExpanded={toggleExpanded}
|
|
||||||
isMenuOpen={menuOpenId === bucket.id}
|
|
||||||
setMenuOpenId={setMenuOpenId}
|
|
||||||
setConfirmDeleteId={setConfirmDeleteId}
|
|
||||||
openEdit={openEdit}
|
openEdit={openEdit}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
usageLabel={usageLabel}
|
usageLabel={usageLabel}
|
||||||
renderUsageBar={renderUsageBar}
|
|
||||||
/>
|
/>
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
@ -212,6 +172,11 @@ export default function BucketsPanel() {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onChange={next => setForm(prev => ({ ...prev, ...next }))}
|
onChange={next => setForm(prev => ({ ...prev, ...next }))}
|
||||||
tagSuggestions={tagSuggestions}
|
tagSuggestions={tagSuggestions}
|
||||||
|
canDelete={Boolean(editId)}
|
||||||
|
onDelete={() => {
|
||||||
|
if (!editId) return;
|
||||||
|
setConfirmDeleteId(editId);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<ConfirmSlideModal
|
<ConfirmSlideModal
|
||||||
isOpen={Boolean(confirmDeleteId)}
|
isOpen={Boolean(confirmDeleteId)}
|
||||||
@ -221,10 +186,21 @@ export default function BucketsPanel() {
|
|||||||
onClose={() => setConfirmDeleteId(null)}
|
onClose={() => setConfirmDeleteId(null)}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
if (!confirmDeleteId) return;
|
if (!confirmDeleteId) return;
|
||||||
|
const deletedBucket = buckets.find(bucket => bucket.id === confirmDeleteId) || null;
|
||||||
const ok = await deleteBucket(confirmDeleteId);
|
const ok = await deleteBucket(confirmDeleteId);
|
||||||
if (ok) setConfirmDeleteId(null);
|
if (ok) {
|
||||||
|
notify({
|
||||||
|
title: "Bucket deleted",
|
||||||
|
message: deletedBucket?.name || "Bucket removed",
|
||||||
|
tone: "danger"
|
||||||
|
});
|
||||||
|
setConfirmDeleteId(null);
|
||||||
|
setModalOpen(false);
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
type EntriesDiscardModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EntriesDiscardModal({ isOpen, onConfirm, onCancel }: EntriesDiscardModalProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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") onCancel();
|
||||||
|
}}
|
||||||
|
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={onCancel}
|
||||||
|
>
|
||||||
|
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={onConfirm}
|
||||||
|
>
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
apps/web/features/entries/components/entries-filter-modal.tsx
Normal file
185
apps/web/features/entries/components/entries-filter-modal.tsx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import TagInput from "@/components/tag-input";
|
||||||
|
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||||
|
|
||||||
|
export type EntriesFilters = {
|
||||||
|
amountMin: string;
|
||||||
|
amountMax: string;
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
necessity: "ANY" | "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
notesQuery: string;
|
||||||
|
tags: string[];
|
||||||
|
tagsMode: "ANY" | "ALL";
|
||||||
|
};
|
||||||
|
|
||||||
|
type EntriesFilterModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
filters: EntriesFilters;
|
||||||
|
setFilters: Dispatch<SetStateAction<EntriesFilters>>;
|
||||||
|
activeFilterCount: number;
|
||||||
|
tagSuggestions: string[];
|
||||||
|
canManageTags: boolean;
|
||||||
|
emptyTagActionLabel: string;
|
||||||
|
onEmptyTagAction: () => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
onFilterAddTag: (tag: string) => void;
|
||||||
|
onFilterToggleTag: (tag: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EntriesFilterModal({
|
||||||
|
isOpen,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
activeFilterCount,
|
||||||
|
tagSuggestions,
|
||||||
|
canManageTags,
|
||||||
|
emptyTagActionLabel,
|
||||||
|
onEmptyTagAction,
|
||||||
|
onClearFilters,
|
||||||
|
onFilterAddTag,
|
||||||
|
onFilterToggleTag,
|
||||||
|
onClose
|
||||||
|
}: EntriesFilterModalProps) {
|
||||||
|
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()}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === "Escape") onClose();
|
||||||
|
}}
|
||||||
|
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={onClose}
|
||||||
|
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</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">
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={filters.necessity}
|
||||||
|
onChange={necessity => setFilters(prev => ({ ...prev, necessity: prev.necessity === necessity ? "ANY" : necessity }))}
|
||||||
|
ariaLabel="Necessity"
|
||||||
|
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
|
||||||
|
sizeClassName="px-3 py-2 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "ANY", label: "Any" },
|
||||||
|
{ value: "NECESSARY", label: "Necessary" },
|
||||||
|
{ value: "BOTH", label: "Both" },
|
||||||
|
{ value: "UNNECESSARY", label: "Unnecessary" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</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={
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={filters.tagsMode}
|
||||||
|
onChange={tagsMode => setFilters(prev => ({ ...prev, tagsMode }))}
|
||||||
|
ariaLabel="Tags mode"
|
||||||
|
className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel"
|
||||||
|
sizeClassName="px-3 py-2 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "ANY", label: "Any" },
|
||||||
|
{ value: "ALL", label: "All" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
tags={filters.tags}
|
||||||
|
suggestions={tagSuggestions}
|
||||||
|
allowCustom={false}
|
||||||
|
onToggleTag={onFilterToggleTag}
|
||||||
|
onAddTag={onFilterAddTag}
|
||||||
|
emptySuggestionLabel={emptyTagActionLabel}
|
||||||
|
emptySuggestionDisabled={!canManageTags}
|
||||||
|
onEmptySuggestionClick={onEmptyTagAction}
|
||||||
|
/>
|
||||||
|
</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={onClearFilters}>
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
<button type="button" className="rounded-lg btn-accent px-3 py-2 text-xs" onClick={onClose}>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
apps/web/features/entries/components/entries-list.tsx
Normal file
232
apps/web/features/entries/components/entries-list.tsx
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { Entry } from "@/lib/shared/types";
|
||||||
|
|
||||||
|
type EntriesListProps = {
|
||||||
|
activeGroupId: number | null;
|
||||||
|
loading: boolean;
|
||||||
|
entries: Entry[];
|
||||||
|
visibleEntries: Entry[];
|
||||||
|
activeFilterCount: number;
|
||||||
|
onOpenDetails: (entry: Entry, index: number) => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TAG_GAP_PX = 8;
|
||||||
|
const TAG_CLASS = "shrink-0 rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs";
|
||||||
|
const TAG_MORE_CLASS = "shrink-0 rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-xs text-soft";
|
||||||
|
|
||||||
|
function NecessityIcon({ necessity }: { necessity: Entry["necessity"] }) {
|
||||||
|
if (necessity === "NECESSARY") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-full border border-accent-weak bg-accent-soft text-[color:var(--color-accent)]"
|
||||||
|
title="Necessary"
|
||||||
|
aria-label="Necessary"
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (necessity === "UNNECESSARY") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-full border border-red-400/60 bg-red-500/10 text-red-200"
|
||||||
|
title="Unnecessary"
|
||||||
|
aria-label="Unnecessary"
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="m18 6-12 12" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-full border border-amber-400/60 bg-amber-500/10 text-amber-200"
|
||||||
|
title="Both"
|
||||||
|
aria-label="Both"
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M9 9h6" />
|
||||||
|
<path d="M9 15h6" />
|
||||||
|
<path d="M12 6v6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryTagsRow({ tags }: { tags: string[] }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const moreRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const tagRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||||
|
const [visibleCount, setVisibleCount] = useState(tags.length);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tagRefs.current = tagRefs.current.slice(0, tags.length);
|
||||||
|
setVisibleCount(tags.length);
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tags.length) return;
|
||||||
|
|
||||||
|
function recomputeVisibleCount() {
|
||||||
|
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||||
|
const widths = tags.map((_, index) => tagRefs.current[index]?.offsetWidth ?? 0);
|
||||||
|
const moreProbe = moreRef.current;
|
||||||
|
|
||||||
|
if (!containerWidth || !moreProbe || widths.some(width => !width)) {
|
||||||
|
setVisibleCount(tags.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTagsWidth = widths.reduce((sum, width) => sum + width, 0) + TAG_GAP_PX * Math.max(widths.length - 1, 0);
|
||||||
|
if (totalTagsWidth <= containerWidth) {
|
||||||
|
setVisibleCount(tags.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextVisibleCount = 0;
|
||||||
|
let usedWidth = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < widths.length; index += 1) {
|
||||||
|
usedWidth += index === 0 ? widths[index] : TAG_GAP_PX + widths[index];
|
||||||
|
const remaining = widths.length - (index + 1);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
nextVisibleCount = widths.length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
moreProbe.textContent = `${remaining} more...`;
|
||||||
|
const moreWidth = moreProbe.offsetWidth;
|
||||||
|
const totalWithMore = usedWidth + TAG_GAP_PX + moreWidth;
|
||||||
|
if (totalWithMore <= containerWidth) nextVisibleCount = index + 1;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibleCount(nextVisibleCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
recomputeVisibleCount();
|
||||||
|
|
||||||
|
if (typeof ResizeObserver !== "undefined") {
|
||||||
|
const observer = new ResizeObserver(recomputeVisibleCount);
|
||||||
|
if (containerRef.current) observer.observe(containerRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("resize", recomputeVisibleCount);
|
||||||
|
return () => window.removeEventListener("resize", recomputeVisibleCount);
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
if (!tags.length) {
|
||||||
|
return <span className="rounded-full border border-accent-weak px-2 py-0.5 text-xs text-soft">No tags</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleTags = tags.slice(0, visibleCount);
|
||||||
|
const hasOverflow = visibleCount < tags.length;
|
||||||
|
const remainingCount = tags.length - visibleCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<div ref={containerRef} className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||||
|
{visibleTags.map((tag, index) => (
|
||||||
|
<span key={`${tag}-${index}`} className={TAG_CLASS}>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{hasOverflow ? <span className={TAG_MORE_CLASS}>{remainingCount} more...</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute left-0 top-0 -z-10 opacity-0" aria-hidden="true">
|
||||||
|
{tags.map((tag, index) => (
|
||||||
|
<span
|
||||||
|
key={`${tag}-${index}`}
|
||||||
|
ref={element => {
|
||||||
|
tagRefs.current[index] = element;
|
||||||
|
}}
|
||||||
|
className={`${TAG_CLASS} inline-block`}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span ref={moreRef} className={`${TAG_MORE_CLASS} inline-block`}>
|
||||||
|
{tags.length} more...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EntriesList({
|
||||||
|
activeGroupId,
|
||||||
|
loading,
|
||||||
|
entries,
|
||||||
|
visibleEntries,
|
||||||
|
activeFilterCount,
|
||||||
|
onOpenDetails,
|
||||||
|
onClearFilters
|
||||||
|
}: EntriesListProps) {
|
||||||
|
return (
|
||||||
|
<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 ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="flex min-h-[84px] cursor-pointer flex-col justify-between gap-2 rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm transition hover:border-accent hover:bg-accent-soft"
|
||||||
|
onClick={() => onOpenDetails(entry, index)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<NecessityIcon necessity={entry.necessity} />
|
||||||
|
<div className="text-base font-semibold">${entry.amountDollars.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted">{new Date(entry.occurredAt).toISOString().slice(0, 10)}</div>
|
||||||
|
</div>
|
||||||
|
<EntryTagsRow tags={tags} />
|
||||||
|
</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={onClearFilters}>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted">No entries yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
779
apps/web/features/entries/components/entries-panel.tsx
Normal file
779
apps/web/features/entries/components/entries-panel.tsx
Normal file
@ -0,0 +1,779 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import useEntries from "@/features/entries/hooks/use-entries";
|
||||||
|
import useSchedules from "@/features/entries/hooks/use-schedules";
|
||||||
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
|
import { useNotificationsContext } from "@/hooks/notifications-context";
|
||||||
|
import { useEntryMutation } from "@/hooks/entry-mutation-context";
|
||||||
|
import useTags from "@/features/tags/hooks/use-tags";
|
||||||
|
import useGroupSettings from "@/features/groups/hooks/use-group-settings";
|
||||||
|
import useUserSettings from "@/hooks/use-user-settings";
|
||||||
|
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||||
|
import NewEntryModal from "@/components/new-entry-modal";
|
||||||
|
import EntryDetailsModal from "@/components/entry-details-modal";
|
||||||
|
import NewScheduleModal, { type NewScheduleForm } from "@/components/new-schedule-modal";
|
||||||
|
import ScheduleDetailsModal, { type ScheduleDetailsForm } from "@/components/schedule-details-modal";
|
||||||
|
import EntriesList from "@/features/entries/components/entries-list";
|
||||||
|
import SchedulesList from "@/features/entries/components/schedules-list";
|
||||||
|
import EntriesFilterModal, { type EntriesFilters } from "@/features/entries/components/entries-filter-modal";
|
||||||
|
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
||||||
|
|
||||||
|
const EMPTY_FILTERS: EntriesFilters = {
|
||||||
|
amountMin: "",
|
||||||
|
amountMax: "",
|
||||||
|
dateFrom: "",
|
||||||
|
dateTo: "",
|
||||||
|
necessity: "ANY",
|
||||||
|
notesQuery: "",
|
||||||
|
tags: [],
|
||||||
|
tagsMode: "ANY"
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeTagList(tags: string[]) {
|
||||||
|
return tags.map(tag => tag.toLowerCase()).sort().join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditableTarget(target: EventTarget | null) {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
const tag = target.tagName;
|
||||||
|
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListProgressSignal({
|
||||||
|
hasMore,
|
||||||
|
shownCount,
|
||||||
|
totalCount,
|
||||||
|
noun
|
||||||
|
}: {
|
||||||
|
hasMore: boolean;
|
||||||
|
shownCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
noun: "entries" | "schedules";
|
||||||
|
}) {
|
||||||
|
if (totalCount <= 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mt-3 flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-xs ${hasMore ? "border-accent-weak bg-accent-soft text-[color:var(--color-text)]" : "border-accent-weak bg-panel text-soft"}`}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full border ${hasMore ? "border-accent bg-panel text-[color:var(--color-accent)]" : "border-accent-weak text-soft"}`}>
|
||||||
|
{hasMore ? "\u21e3" : "\u2713"}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{hasMore
|
||||||
|
? `Keep scrolling for more ${noun} (${shownCount} of ${totalCount})`
|
||||||
|
: `You have reached the end of ${noun} (${totalCount} total)`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EntriesPanel() {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const { groups, activeGroupId } = useGroupsContext();
|
||||||
|
const { entries, loading: entriesLoading, error: entriesError, createEntry, updateEntry, deleteEntry } = useEntries(activeGroupId);
|
||||||
|
const { schedules, loading: schedulesLoading, error: schedulesError, createSchedule, updateSchedule, deleteSchedule } = useSchedules(activeGroupId);
|
||||||
|
const { settings: userSettings } = useUserSettings();
|
||||||
|
const { notify } = useNotificationsContext();
|
||||||
|
const { notifyEntryMutation } = useEntryMutation();
|
||||||
|
const { tags: tagSuggestions } = useTags(activeGroupId);
|
||||||
|
const { settings: groupSettings } = useGroupSettings(activeGroupId);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const activeGroup = groups.find(group => group.id === activeGroupId) || null;
|
||||||
|
const canManageTags = Boolean(activeGroup && (activeGroup.role !== "MEMBER" || groupSettings.allowMemberTagManage));
|
||||||
|
const emptyTagActionLabel = canManageTags
|
||||||
|
? "No Tags Assigned Yet - Click To Assign Tags"
|
||||||
|
: "No Tags Assigned Yet - Contact Your Group Admin";
|
||||||
|
|
||||||
|
const pageSize = Math.max(1, Number(userSettings.entryPanelPageSize || 10));
|
||||||
|
|
||||||
|
const [entryTab, setEntryTab] = useState<"ENTRIES" | "SCHEDULES">("ENTRIES");
|
||||||
|
const [filters, setFilters] = useState<EntriesFilters>(EMPTY_FILTERS);
|
||||||
|
const [entryVisibleCount, setEntryVisibleCount] = useState(pageSize);
|
||||||
|
const [scheduleVisibleCount, setScheduleVisibleCount] = useState(pageSize);
|
||||||
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
|
||||||
|
const [newEntryOpen, setNewEntryOpen] = useState(false);
|
||||||
|
const [newScheduleOpen, setNewScheduleOpen] = useState(false);
|
||||||
|
const [entryDetailsOpen, setEntryDetailsOpen] = useState(false);
|
||||||
|
const [scheduleDetailsOpen, setScheduleDetailsOpen] = useState(false);
|
||||||
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<"ENTRY" | "SCHEDULE">("ENTRY");
|
||||||
|
|
||||||
|
const [selectedEntryId, setSelectedEntryId] = useState<number | null>(null);
|
||||||
|
const [selectedEntryIndex, setSelectedEntryIndex] = useState<number | null>(null);
|
||||||
|
const [selectedScheduleId, setSelectedScheduleId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [entryRemovedTags, setEntryRemovedTags] = useState<string[]>([]);
|
||||||
|
const [scheduleRemovedTags, setScheduleRemovedTags] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [entryForm, setEntryForm] = useState({
|
||||||
|
amountDollars: "",
|
||||||
|
occurredAt: today,
|
||||||
|
necessity: "NECESSARY",
|
||||||
|
notes: "",
|
||||||
|
tags: [] as string[],
|
||||||
|
entryType: "SPENDING" as "SPENDING" | "INCOME"
|
||||||
|
});
|
||||||
|
const [entryDetailsForm, setEntryDetailsForm] = useState({
|
||||||
|
amountDollars: "",
|
||||||
|
occurredAt: today,
|
||||||
|
necessity: "NECESSARY",
|
||||||
|
notes: "",
|
||||||
|
tags: [] as string[],
|
||||||
|
entryType: "SPENDING" as "SPENDING" | "INCOME"
|
||||||
|
});
|
||||||
|
const [entryDetailsOriginal, setEntryDetailsOriginal] = useState<typeof entryDetailsForm | null>(null);
|
||||||
|
|
||||||
|
const [scheduleForm, setScheduleForm] = useState<NewScheduleForm>({
|
||||||
|
amountDollars: "",
|
||||||
|
startsOn: today,
|
||||||
|
necessity: "NECESSARY",
|
||||||
|
notes: "",
|
||||||
|
tags: [],
|
||||||
|
entryType: "SPENDING",
|
||||||
|
frequency: "MONTHLY",
|
||||||
|
intervalCount: 1,
|
||||||
|
endCondition: "NEVER",
|
||||||
|
endCount: "",
|
||||||
|
endDate: "",
|
||||||
|
createEntryNow: false
|
||||||
|
});
|
||||||
|
const [scheduleDetailsForm, setScheduleDetailsForm] = useState<ScheduleDetailsForm>({
|
||||||
|
amountDollars: "",
|
||||||
|
startsOn: today,
|
||||||
|
necessity: "NECESSARY",
|
||||||
|
notes: "",
|
||||||
|
tags: [],
|
||||||
|
entryType: "SPENDING",
|
||||||
|
frequency: "MONTHLY",
|
||||||
|
intervalCount: 1,
|
||||||
|
endCondition: "NEVER",
|
||||||
|
endCount: "",
|
||||||
|
endDate: "",
|
||||||
|
nextRunOn: today,
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
const [scheduleDetailsOriginal, setScheduleDetailsOriginal] = useState<ScheduleDetailsForm | null>(null);
|
||||||
|
|
||||||
|
const amountInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const tagsInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const entriesLoadSentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const schedulesLoadSentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEntryVisibleCount(pageSize);
|
||||||
|
setScheduleVisibleCount(pageSize);
|
||||||
|
}, [pageSize]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const filteredEntries = useMemo(() => {
|
||||||
|
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;
|
||||||
|
const time = new Date(entry.occurredAt).getTime();
|
||||||
|
if (from != null && !Number.isNaN(from) && time < from) return false;
|
||||||
|
if (to != null && !Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false;
|
||||||
|
if (filters.necessity !== "ANY" && entry.necessity !== filters.necessity) return false;
|
||||||
|
if (query && !(entry.notes || "").toLowerCase().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 filteredSchedules = useMemo(() => {
|
||||||
|
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 schedules.filter(schedule => {
|
||||||
|
if (min != null && schedule.amountDollars < min) return false;
|
||||||
|
if (max != null && schedule.amountDollars > max) return false;
|
||||||
|
const time = new Date(schedule.startsOn).getTime();
|
||||||
|
if (from != null && !Number.isNaN(from) && time < from) return false;
|
||||||
|
if (to != null && !Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false;
|
||||||
|
if (filters.necessity !== "ANY" && schedule.necessity !== filters.necessity) return false;
|
||||||
|
if (query && !(schedule.notes || "").toLowerCase().includes(query)) return false;
|
||||||
|
if (tagsFilter.length) {
|
||||||
|
const scheduleTags = (schedule.tags || []).map(tag => tag.toLowerCase());
|
||||||
|
if (filters.tagsMode === "ALL") {
|
||||||
|
if (!tagsFilter.every(tag => scheduleTags.includes(tag))) return false;
|
||||||
|
} else if (!tagsFilter.some(tag => scheduleTags.includes(tag))) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [schedules, filters]);
|
||||||
|
|
||||||
|
const visibleEntries = useMemo(() => filteredEntries.slice(0, entryVisibleCount), [filteredEntries, entryVisibleCount]);
|
||||||
|
const visibleSchedules = useMemo(() => filteredSchedules.slice(0, scheduleVisibleCount), [filteredSchedules, scheduleVisibleCount]);
|
||||||
|
const hasMoreEntries = filteredEntries.length > visibleEntries.length;
|
||||||
|
const hasMoreSchedules = filteredSchedules.length > visibleSchedules.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (entryTab !== "ENTRIES" || !hasMoreEntries) return;
|
||||||
|
|
||||||
|
let touchY: number | null = null;
|
||||||
|
let lastLoadAt = 0;
|
||||||
|
let lastScrollY = window.scrollY;
|
||||||
|
|
||||||
|
function shouldLoadMore() {
|
||||||
|
const sentinel = entriesLoadSentinelRef.current;
|
||||||
|
if (!sentinel) return false;
|
||||||
|
const rect = sentinel.getBoundingClientRect();
|
||||||
|
return rect.top <= window.innerHeight + 48;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryLoadMore() {
|
||||||
|
if (!shouldLoadMore()) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastLoadAt < 150) return;
|
||||||
|
lastLoadAt = now;
|
||||||
|
setEntryVisibleCount(prev => {
|
||||||
|
if (prev >= filteredEntries.length) return prev;
|
||||||
|
return Math.min(prev + pageSize, filteredEntries.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(event: WheelEvent) {
|
||||||
|
if (event.deltaY <= 0) return;
|
||||||
|
tryLoadMore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
const nextY = window.scrollY;
|
||||||
|
if (nextY <= lastScrollY) {
|
||||||
|
lastScrollY = nextY;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastScrollY = nextY;
|
||||||
|
tryLoadMore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (isEditableTarget(event.target)) return;
|
||||||
|
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "End" || (event.key === " " && !event.shiftKey)) {
|
||||||
|
tryLoadMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchStart(event: TouchEvent) {
|
||||||
|
touchY = event.touches[0]?.clientY ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(event: TouchEvent) {
|
||||||
|
const nextY = event.touches[0]?.clientY;
|
||||||
|
if (touchY == null || nextY == null) return;
|
||||||
|
const delta = touchY - nextY;
|
||||||
|
touchY = nextY;
|
||||||
|
if (delta > 10) tryLoadMore();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("wheel", onWheel, { passive: true });
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
window.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||||
|
window.addEventListener("touchmove", onTouchMove, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("wheel", onWheel);
|
||||||
|
window.removeEventListener("scroll", onScroll);
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
window.removeEventListener("touchstart", onTouchStart);
|
||||||
|
window.removeEventListener("touchmove", onTouchMove);
|
||||||
|
};
|
||||||
|
}, [entryTab, hasMoreEntries, filteredEntries.length, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (entryTab !== "SCHEDULES" || !hasMoreSchedules) return;
|
||||||
|
|
||||||
|
let touchY: number | null = null;
|
||||||
|
let lastLoadAt = 0;
|
||||||
|
let lastScrollY = window.scrollY;
|
||||||
|
|
||||||
|
function shouldLoadMore() {
|
||||||
|
const sentinel = schedulesLoadSentinelRef.current;
|
||||||
|
if (!sentinel) return false;
|
||||||
|
const rect = sentinel.getBoundingClientRect();
|
||||||
|
return rect.top <= window.innerHeight + 48;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryLoadMore() {
|
||||||
|
if (!shouldLoadMore()) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastLoadAt < 150) return;
|
||||||
|
lastLoadAt = now;
|
||||||
|
setScheduleVisibleCount(prev => {
|
||||||
|
if (prev >= filteredSchedules.length) return prev;
|
||||||
|
return Math.min(prev + pageSize, filteredSchedules.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(event: WheelEvent) {
|
||||||
|
if (event.deltaY <= 0) return;
|
||||||
|
tryLoadMore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
const nextY = window.scrollY;
|
||||||
|
if (nextY <= lastScrollY) {
|
||||||
|
lastScrollY = nextY;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastScrollY = nextY;
|
||||||
|
tryLoadMore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (isEditableTarget(event.target)) return;
|
||||||
|
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "End" || (event.key === " " && !event.shiftKey)) {
|
||||||
|
tryLoadMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchStart(event: TouchEvent) {
|
||||||
|
touchY = event.touches[0]?.clientY ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(event: TouchEvent) {
|
||||||
|
const nextY = event.touches[0]?.clientY;
|
||||||
|
if (touchY == null || nextY == null) return;
|
||||||
|
const delta = touchY - nextY;
|
||||||
|
touchY = nextY;
|
||||||
|
if (delta > 10) tryLoadMore();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("wheel", onWheel, { passive: true });
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
window.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||||
|
window.addEventListener("touchmove", onTouchMove, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("wheel", onWheel);
|
||||||
|
window.removeEventListener("scroll", onScroll);
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
window.removeEventListener("touchstart", onTouchStart);
|
||||||
|
window.removeEventListener("touchmove", onTouchMove);
|
||||||
|
};
|
||||||
|
}, [entryTab, hasMoreSchedules, filteredSchedules.length, pageSize]);
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
setFilters(EMPTY_FILTERS);
|
||||||
|
setEntryVisibleCount(pageSize);
|
||||||
|
setScheduleVisibleCount(pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEmptyTagAction() {
|
||||||
|
if (!canManageTags) return;
|
||||||
|
router.push("/groups/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEntryChanges() {
|
||||||
|
if (!entryDetailsOriginal) return false;
|
||||||
|
const tags = entryDetailsForm.tags.filter(tag => !entryRemovedTags.includes(tag));
|
||||||
|
return (
|
||||||
|
entryDetailsForm.amountDollars !== entryDetailsOriginal.amountDollars ||
|
||||||
|
entryDetailsForm.occurredAt !== entryDetailsOriginal.occurredAt ||
|
||||||
|
entryDetailsForm.necessity !== entryDetailsOriginal.necessity ||
|
||||||
|
entryDetailsForm.notes !== entryDetailsOriginal.notes ||
|
||||||
|
entryDetailsForm.entryType !== entryDetailsOriginal.entryType ||
|
||||||
|
normalizeTagList(tags) !== normalizeTagList(entryDetailsOriginal.tags)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasScheduleChanges() {
|
||||||
|
if (!scheduleDetailsOriginal) return false;
|
||||||
|
const tags = scheduleDetailsForm.tags.filter(tag => !scheduleRemovedTags.includes(tag));
|
||||||
|
return (
|
||||||
|
scheduleDetailsForm.amountDollars !== scheduleDetailsOriginal.amountDollars ||
|
||||||
|
scheduleDetailsForm.startsOn !== scheduleDetailsOriginal.startsOn ||
|
||||||
|
scheduleDetailsForm.necessity !== scheduleDetailsOriginal.necessity ||
|
||||||
|
scheduleDetailsForm.notes !== scheduleDetailsOriginal.notes ||
|
||||||
|
scheduleDetailsForm.entryType !== scheduleDetailsOriginal.entryType ||
|
||||||
|
scheduleDetailsForm.frequency !== scheduleDetailsOriginal.frequency ||
|
||||||
|
scheduleDetailsForm.intervalCount !== scheduleDetailsOriginal.intervalCount ||
|
||||||
|
scheduleDetailsForm.endCondition !== scheduleDetailsOriginal.endCondition ||
|
||||||
|
scheduleDetailsForm.endCount !== scheduleDetailsOriginal.endCount ||
|
||||||
|
scheduleDetailsForm.endDate !== scheduleDetailsOriginal.endDate ||
|
||||||
|
scheduleDetailsForm.nextRunOn !== scheduleDetailsOriginal.nextRunOn ||
|
||||||
|
scheduleDetailsForm.isActive !== scheduleDetailsOriginal.isActive ||
|
||||||
|
normalizeTagList(tags) !== normalizeTagList(scheduleDetailsOriginal.tags)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitNewEntry(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!e.currentTarget.reportValidity()) return;
|
||||||
|
const amountDollars = Number(entryForm.amountDollars || 0);
|
||||||
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0 || !entryForm.tags.length) return;
|
||||||
|
const created = await createEntry({
|
||||||
|
entryType: entryForm.entryType,
|
||||||
|
amountDollars,
|
||||||
|
occurredAt: entryForm.occurredAt,
|
||||||
|
necessity: entryForm.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
|
purchaseType: entryForm.tags.join(", ") || "General",
|
||||||
|
notes: entryForm.notes.trim() || undefined,
|
||||||
|
tags: entryForm.tags
|
||||||
|
});
|
||||||
|
if (!created) return;
|
||||||
|
setNewEntryOpen(false);
|
||||||
|
setEntryForm({ amountDollars: "", occurredAt: today, necessity: "NECESSARY", notes: "", tags: [], entryType: "SPENDING" });
|
||||||
|
notify({ title: "Entry added", message: `${created.tags.join(", ")} - $${created.amountDollars.toFixed(2)}`, tone: "success" });
|
||||||
|
notifyEntryMutation();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitNewSchedule(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const amountDollars = Number(scheduleForm.amountDollars || 0);
|
||||||
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0 || !scheduleForm.tags.length) return;
|
||||||
|
const created = await createSchedule({
|
||||||
|
entryType: scheduleForm.entryType,
|
||||||
|
amountDollars,
|
||||||
|
startsOn: scheduleForm.startsOn,
|
||||||
|
necessity: scheduleForm.necessity,
|
||||||
|
purchaseType: scheduleForm.tags.join(", ") || "General",
|
||||||
|
notes: scheduleForm.notes.trim() || undefined,
|
||||||
|
tags: scheduleForm.tags,
|
||||||
|
frequency: scheduleForm.frequency,
|
||||||
|
intervalCount: scheduleForm.intervalCount,
|
||||||
|
endCondition: scheduleForm.endCondition,
|
||||||
|
endCount: scheduleForm.endCondition === "AFTER_COUNT" ? Number(scheduleForm.endCount || 0) || null : null,
|
||||||
|
endDate: scheduleForm.endCondition === "BY_DATE" ? scheduleForm.endDate || null : null,
|
||||||
|
createEntryNow: scheduleForm.createEntryNow
|
||||||
|
});
|
||||||
|
if (!created) return;
|
||||||
|
setNewScheduleOpen(false);
|
||||||
|
setScheduleForm({ amountDollars: "", startsOn: today, necessity: "NECESSARY", notes: "", tags: [], entryType: "SPENDING", frequency: "MONTHLY", intervalCount: 1, endCondition: "NEVER", endCount: "", endDate: "", createEntryNow: false });
|
||||||
|
notify({ title: "Schedule added", message: `${created.tags.join(", ") || "Schedule"} - $${created.amountDollars.toFixed(2)}`, tone: "success" });
|
||||||
|
if (scheduleForm.createEntryNow) notifyEntryMutation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEntryDetails(id: number) {
|
||||||
|
const index = filteredEntries.findIndex(entry => Number(entry.id) === Number(id));
|
||||||
|
if (index < 0) return;
|
||||||
|
const entry = filteredEntries[index];
|
||||||
|
const form = { amountDollars: String(entry.amountDollars), occurredAt: entry.occurredAt, necessity: entry.necessity, notes: entry.notes || "", tags: entry.tags || [], entryType: entry.entryType };
|
||||||
|
setSelectedEntryId(Number(id));
|
||||||
|
setSelectedEntryIndex(index);
|
||||||
|
setEntryDetailsForm(form);
|
||||||
|
setEntryDetailsOriginal(form);
|
||||||
|
setEntryRemovedTags([]);
|
||||||
|
setEntryDetailsOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openScheduleDetails(id: number) {
|
||||||
|
const schedule = schedules.find(item => Number(item.id) === Number(id));
|
||||||
|
if (!schedule) return;
|
||||||
|
const form: ScheduleDetailsForm = {
|
||||||
|
amountDollars: String(schedule.amountDollars),
|
||||||
|
startsOn: schedule.startsOn,
|
||||||
|
necessity: schedule.necessity,
|
||||||
|
notes: schedule.notes || "",
|
||||||
|
tags: schedule.tags || [],
|
||||||
|
entryType: schedule.entryType,
|
||||||
|
frequency: schedule.frequency,
|
||||||
|
intervalCount: schedule.intervalCount,
|
||||||
|
endCondition: schedule.endCondition,
|
||||||
|
endCount: schedule.endCount == null ? "" : String(schedule.endCount),
|
||||||
|
endDate: schedule.endDate || "",
|
||||||
|
nextRunOn: schedule.nextRunOn,
|
||||||
|
isActive: schedule.isActive
|
||||||
|
};
|
||||||
|
setSelectedScheduleId(Number(id));
|
||||||
|
setScheduleDetailsForm(form);
|
||||||
|
setScheduleDetailsOriginal(form);
|
||||||
|
setScheduleRemovedTags([]);
|
||||||
|
setScheduleDetailsOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEntryUpdate(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedEntryId || !hasEntryChanges()) return;
|
||||||
|
const amount = Number(entryDetailsForm.amountDollars || 0);
|
||||||
|
const tags = entryDetailsForm.tags.filter(tag => !entryRemovedTags.includes(tag));
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0 || !tags.length) return;
|
||||||
|
const updated = await updateEntry({
|
||||||
|
id: selectedEntryId,
|
||||||
|
entryType: entryDetailsForm.entryType,
|
||||||
|
amountDollars: amount,
|
||||||
|
occurredAt: entryDetailsForm.occurredAt,
|
||||||
|
necessity: entryDetailsForm.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
|
purchaseType: tags.join(", ") || "General",
|
||||||
|
notes: entryDetailsForm.notes.trim() || undefined,
|
||||||
|
tags
|
||||||
|
});
|
||||||
|
if (!updated) return;
|
||||||
|
setEntryDetailsOpen(false);
|
||||||
|
notify({ title: "Entry updated", message: `${updated.tags.join(", ")} - $${updated.amountDollars.toFixed(2)}` });
|
||||||
|
notifyEntryMutation();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitScheduleUpdate(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedScheduleId || !hasScheduleChanges()) return;
|
||||||
|
const amount = Number(scheduleDetailsForm.amountDollars || 0);
|
||||||
|
const tags = scheduleDetailsForm.tags.filter(tag => !scheduleRemovedTags.includes(tag));
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0 || !tags.length) return;
|
||||||
|
const updated = await updateSchedule({
|
||||||
|
id: selectedScheduleId,
|
||||||
|
entryType: scheduleDetailsForm.entryType,
|
||||||
|
amountDollars: amount,
|
||||||
|
startsOn: scheduleDetailsForm.startsOn,
|
||||||
|
necessity: scheduleDetailsForm.necessity,
|
||||||
|
purchaseType: tags.join(", ") || "General",
|
||||||
|
notes: scheduleDetailsForm.notes.trim() || undefined,
|
||||||
|
tags,
|
||||||
|
frequency: scheduleDetailsForm.frequency,
|
||||||
|
intervalCount: scheduleDetailsForm.intervalCount,
|
||||||
|
endCondition: scheduleDetailsForm.endCondition,
|
||||||
|
endCount: scheduleDetailsForm.endCondition === "AFTER_COUNT" ? Number(scheduleDetailsForm.endCount || 0) || null : null,
|
||||||
|
endDate: scheduleDetailsForm.endCondition === "BY_DATE" ? scheduleDetailsForm.endDate || null : null,
|
||||||
|
nextRunOn: scheduleDetailsForm.nextRunOn,
|
||||||
|
isActive: scheduleDetailsForm.isActive
|
||||||
|
});
|
||||||
|
if (!updated) return;
|
||||||
|
setScheduleDetailsOpen(false);
|
||||||
|
notify({ title: "Schedule updated", message: `${updated.tags.join(", ")} - $${updated.amountDollars.toFixed(2)}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (deleteTarget === "ENTRY" && selectedEntryId) {
|
||||||
|
const removed = await deleteEntry(selectedEntryId);
|
||||||
|
if (!removed) return;
|
||||||
|
setEntryDetailsOpen(false);
|
||||||
|
notify({ title: "Entry deleted", message: removed.tags.join(", ") || "Entry removed", tone: "danger" });
|
||||||
|
notifyEntryMutation();
|
||||||
|
}
|
||||||
|
if (deleteTarget === "SCHEDULE" && selectedScheduleId) {
|
||||||
|
const removed = await deleteSchedule(selectedScheduleId);
|
||||||
|
if (!removed) return;
|
||||||
|
setScheduleDetailsOpen(false);
|
||||||
|
notify({ title: "Schedule deleted", message: removed.tags.join(", ") || "Schedule removed", tone: "danger" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevEntry() {
|
||||||
|
if (!filteredEntries.length) return;
|
||||||
|
const current = selectedEntryIndex ?? 0;
|
||||||
|
const index = current === 0 ? filteredEntries.length - 1 : current - 1;
|
||||||
|
openEntryDetails(filteredEntries[index].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextEntry() {
|
||||||
|
if (!filteredEntries.length) return;
|
||||||
|
const current = selectedEntryIndex ?? 0;
|
||||||
|
const index = current === filteredEntries.length - 1 ? 0 : current + 1;
|
||||||
|
openEntryDetails(filteredEntries[index].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="panel panel-accent p-4">
|
||||||
|
<div className="card-header">
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={entryTab}
|
||||||
|
onChange={setEntryTab}
|
||||||
|
ariaLabel="Entries and schedules tab"
|
||||||
|
sizeClassName="px-3 py-2 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
|
{ value: "ENTRIES", label: "Entries", className: "mr-[-10px] w-24" },
|
||||||
|
{ value: "SCHEDULES", label: "Schedules", className: "w-24" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<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={() => entryTab === "ENTRIES" ? setNewEntryOpen(true) : setNewScheduleOpen(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={entryTab === "ENTRIES" ? "Add entry" : "Add schedule"}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{entryTab === "ENTRIES" ? (
|
||||||
|
<>
|
||||||
|
<EntriesList
|
||||||
|
activeGroupId={activeGroupId}
|
||||||
|
loading={entriesLoading}
|
||||||
|
entries={entries}
|
||||||
|
visibleEntries={visibleEntries}
|
||||||
|
activeFilterCount={activeFilterCount}
|
||||||
|
onOpenDetails={entry => openEntryDetails(entry.id)}
|
||||||
|
onClearFilters={clearFilters}
|
||||||
|
/>
|
||||||
|
<div ref={entriesLoadSentinelRef} className="h-1" aria-hidden="true" />
|
||||||
|
<ListProgressSignal
|
||||||
|
hasMore={hasMoreEntries}
|
||||||
|
shownCount={visibleEntries.length}
|
||||||
|
totalCount={filteredEntries.length}
|
||||||
|
noun="entries"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SchedulesList
|
||||||
|
activeGroupId={activeGroupId}
|
||||||
|
loading={schedulesLoading}
|
||||||
|
schedules={schedules}
|
||||||
|
visibleSchedules={visibleSchedules}
|
||||||
|
activeFilterCount={activeFilterCount}
|
||||||
|
onOpenDetails={schedule => openScheduleDetails(schedule.id)}
|
||||||
|
onClearFilters={clearFilters}
|
||||||
|
/>
|
||||||
|
<div ref={schedulesLoadSentinelRef} className="h-1" aria-hidden="true" />
|
||||||
|
<ListProgressSignal
|
||||||
|
hasMore={hasMoreSchedules}
|
||||||
|
shownCount={visibleSchedules.length}
|
||||||
|
totalCount={filteredSchedules.length}
|
||||||
|
noun="schedules"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NewEntryModal
|
||||||
|
isOpen={newEntryOpen && Boolean(activeGroupId)}
|
||||||
|
form={entryForm}
|
||||||
|
error={entriesError}
|
||||||
|
onClose={() => setNewEntryOpen(false)}
|
||||||
|
onSubmit={submitNewEntry}
|
||||||
|
onChange={next => setEntryForm(prev => ({ ...prev, ...next }))}
|
||||||
|
tagSuggestions={tagSuggestions}
|
||||||
|
emptyTagActionLabel={emptyTagActionLabel}
|
||||||
|
emptyTagActionDisabled={!canManageTags}
|
||||||
|
onEmptyTagAction={handleEmptyTagAction}
|
||||||
|
amountInputRef={amountInputRef}
|
||||||
|
tagsInputRef={tagsInputRef}
|
||||||
|
/>
|
||||||
|
<NewScheduleModal
|
||||||
|
isOpen={newScheduleOpen && Boolean(activeGroupId)}
|
||||||
|
form={scheduleForm}
|
||||||
|
error={schedulesError}
|
||||||
|
onClose={() => setNewScheduleOpen(false)}
|
||||||
|
onSubmit={submitNewSchedule}
|
||||||
|
onChange={next => setScheduleForm(prev => ({ ...prev, ...next }))}
|
||||||
|
tagSuggestions={tagSuggestions}
|
||||||
|
emptyTagActionLabel={emptyTagActionLabel}
|
||||||
|
emptyTagActionDisabled={!canManageTags}
|
||||||
|
onEmptyTagAction={handleEmptyTagAction}
|
||||||
|
/>
|
||||||
|
<EntryDetailsModal
|
||||||
|
isOpen={entryDetailsOpen}
|
||||||
|
form={entryDetailsForm}
|
||||||
|
originalForm={entryDetailsOriginal}
|
||||||
|
isDirty={hasEntryChanges()}
|
||||||
|
error={entriesError}
|
||||||
|
onClose={() => setEntryDetailsOpen(false)}
|
||||||
|
onSubmit={submitEntryUpdate}
|
||||||
|
onRequestDelete={() => {
|
||||||
|
setDeleteTarget("ENTRY");
|
||||||
|
setConfirmDeleteOpen(true);
|
||||||
|
}}
|
||||||
|
onRevert={() => {
|
||||||
|
if (!entryDetailsOriginal) return;
|
||||||
|
setEntryDetailsForm(entryDetailsOriginal);
|
||||||
|
setEntryRemovedTags([]);
|
||||||
|
}}
|
||||||
|
onChange={next => setEntryDetailsForm(prev => ({ ...prev, ...next }))}
|
||||||
|
onAddTag={tag => {
|
||||||
|
setEntryDetailsForm(prev => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags : [...prev.tags, tag] }));
|
||||||
|
setEntryRemovedTags(prev => prev.filter(item => item !== tag));
|
||||||
|
}}
|
||||||
|
onToggleTag={tag => setEntryRemovedTags(prev => prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag])}
|
||||||
|
removedTags={entryRemovedTags}
|
||||||
|
tagSuggestions={tagSuggestions}
|
||||||
|
emptyTagActionLabel={emptyTagActionLabel}
|
||||||
|
emptyTagActionDisabled={!canManageTags}
|
||||||
|
onEmptyTagAction={handleEmptyTagAction}
|
||||||
|
onPrev={prevEntry}
|
||||||
|
onNext={nextEntry}
|
||||||
|
loopHintPrev={selectedEntryIndex === 0 && filteredEntries.length > 1 ? "Loop" : ""}
|
||||||
|
loopHintNext={selectedEntryIndex === filteredEntries.length - 1 && filteredEntries.length > 1 ? "Loop" : ""}
|
||||||
|
canNavigate={filteredEntries.length > 1}
|
||||||
|
/>
|
||||||
|
<ScheduleDetailsModal
|
||||||
|
isOpen={scheduleDetailsOpen}
|
||||||
|
form={scheduleDetailsForm}
|
||||||
|
originalForm={scheduleDetailsOriginal}
|
||||||
|
isDirty={hasScheduleChanges()}
|
||||||
|
error={schedulesError}
|
||||||
|
onClose={() => setScheduleDetailsOpen(false)}
|
||||||
|
onSubmit={submitScheduleUpdate}
|
||||||
|
onRequestDelete={() => {
|
||||||
|
setDeleteTarget("SCHEDULE");
|
||||||
|
setConfirmDeleteOpen(true);
|
||||||
|
}}
|
||||||
|
onRevert={() => {
|
||||||
|
if (!scheduleDetailsOriginal) return;
|
||||||
|
setScheduleDetailsForm(scheduleDetailsOriginal);
|
||||||
|
setScheduleRemovedTags([]);
|
||||||
|
}}
|
||||||
|
onChange={next => setScheduleDetailsForm(prev => ({ ...prev, ...next }))}
|
||||||
|
onAddTag={tag => {
|
||||||
|
setScheduleDetailsForm(prev => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags : [...prev.tags, tag] }));
|
||||||
|
setScheduleRemovedTags(prev => prev.filter(item => item !== tag));
|
||||||
|
}}
|
||||||
|
onToggleTag={tag => setScheduleRemovedTags(prev => prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag])}
|
||||||
|
removedTags={scheduleRemovedTags}
|
||||||
|
tagSuggestions={tagSuggestions}
|
||||||
|
emptyTagActionLabel={emptyTagActionLabel}
|
||||||
|
emptyTagActionDisabled={!canManageTags}
|
||||||
|
onEmptyTagAction={handleEmptyTagAction}
|
||||||
|
/>
|
||||||
|
<EntriesFilterModal
|
||||||
|
isOpen={filterOpen}
|
||||||
|
filters={filters}
|
||||||
|
setFilters={setFilters}
|
||||||
|
activeFilterCount={activeFilterCount}
|
||||||
|
tagSuggestions={tagSuggestions}
|
||||||
|
canManageTags={canManageTags}
|
||||||
|
emptyTagActionLabel={emptyTagActionLabel}
|
||||||
|
onEmptyTagAction={handleEmptyTagAction}
|
||||||
|
onClearFilters={clearFilters}
|
||||||
|
onFilterAddTag={tag => setFilters(prev => (prev.tags.includes(tag) ? prev : { ...prev, tags: [...prev.tags, tag] }))}
|
||||||
|
onFilterToggleTag={tag => setFilters(prev => ({ ...prev, tags: prev.tags.filter(item => item !== tag) }))}
|
||||||
|
onClose={() => setFilterOpen(false)}
|
||||||
|
/>
|
||||||
|
<ConfirmSlideModal
|
||||||
|
isOpen={confirmDeleteOpen}
|
||||||
|
title={deleteTarget === "ENTRY" ? "Delete entry" : "Delete schedule"}
|
||||||
|
description={deleteTarget === "ENTRY" ? "This will permanently remove the entry and its tags." : "This will permanently remove the schedule and its tags."}
|
||||||
|
confirmLabel={deleteTarget === "ENTRY" ? "Delete entry" : "Delete schedule"}
|
||||||
|
onClose={() => setConfirmDeleteOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmDeleteOpen(false);
|
||||||
|
confirmDelete();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
apps/web/features/entries/components/schedules-list.tsx
Normal file
85
apps/web/features/entries/components/schedules-list.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Schedule } from "@/lib/shared/types";
|
||||||
|
|
||||||
|
type SchedulesListProps = {
|
||||||
|
activeGroupId: number | null;
|
||||||
|
loading: boolean;
|
||||||
|
schedules: Schedule[];
|
||||||
|
visibleSchedules: Schedule[];
|
||||||
|
activeFilterCount: number;
|
||||||
|
onOpenDetails: (schedule: Schedule, index: number) => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SchedulesList({
|
||||||
|
activeGroupId,
|
||||||
|
loading,
|
||||||
|
schedules,
|
||||||
|
visibleSchedules,
|
||||||
|
activeFilterCount,
|
||||||
|
onOpenDetails,
|
||||||
|
onClearFilters
|
||||||
|
}: SchedulesListProps) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{!activeGroupId ? (
|
||||||
|
<div className="text-sm text-muted">Select a group to view schedules.</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="h-3 w-36 rounded bg-surface" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : schedules.length ? (
|
||||||
|
visibleSchedules.length ? (
|
||||||
|
visibleSchedules.map((schedule, index) => (
|
||||||
|
<div
|
||||||
|
key={schedule.id}
|
||||||
|
className="flex min-h-[84px] cursor-pointer flex-col justify-between gap-2 rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm transition hover:border-accent hover:bg-accent-soft"
|
||||||
|
onClick={() => onOpenDetails(schedule, index)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-base font-semibold">${schedule.amountDollars.toFixed(2)}</div>
|
||||||
|
<div className={`rounded-full border px-2 py-0.5 text-[10px] ${schedule.isActive ? "border-green-400/60 bg-green-500/10 text-green-200" : "border-amber-400/60 bg-amber-500/10 text-amber-200"}`}>
|
||||||
|
{schedule.isActive ? "Active" : "Paused"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted">Next: {schedule.nextRunOn}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{(schedule.tags || []).length ? (
|
||||||
|
schedule.tags.map(tag => (
|
||||||
|
<span key={`${schedule.id}-${tag}`} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs">
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full border border-accent-weak px-2 py-0.5 text-xs text-soft">No tags</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 text-sm text-muted">
|
||||||
|
<div>No matching schedules.</div>
|
||||||
|
{activeFilterCount ? (
|
||||||
|
<button type="button" className="rounded-lg btn-outline-accent px-3 py-1 text-xs" onClick={onClearFilters}>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted">No schedules yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,13 +12,6 @@ type CreateEntryInput = {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
bucketId?: number | null;
|
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 };
|
type UpdateEntryInput = CreateEntryInput & { id: number };
|
||||||
@ -63,7 +56,7 @@ export default function useEntries(activeGroupId?: number | null) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [activeGroupId]);
|
}, [activeGroupId]);
|
||||||
|
|
||||||
const createEntry = useCallback(async (input: CreateEntryInput) => {
|
const createEntry = useCallback(async (input: CreateEntryInput): Promise<Entry | null> => {
|
||||||
setError("");
|
setError("");
|
||||||
const result = await entriesCreate(input);
|
const result = await entriesCreate(input);
|
||||||
if (isError(result)) {
|
if (isError(result)) {
|
||||||
@ -75,7 +68,7 @@ export default function useEntries(activeGroupId?: number | null) {
|
|||||||
return created;
|
return created;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateEntry = useCallback(async (input: UpdateEntryInput) => {
|
const updateEntry = useCallback(async (input: UpdateEntryInput): Promise<Entry | null> => {
|
||||||
setError("");
|
setError("");
|
||||||
const result = await entriesUpdate(input);
|
const result = await entriesUpdate(input);
|
||||||
if (isError(result)) {
|
if (isError(result)) {
|
||||||
@ -89,7 +82,7 @@ export default function useEntries(activeGroupId?: number | null) {
|
|||||||
return updated;
|
return updated;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const deleteEntry = useCallback(async (id: number | string) => {
|
const deleteEntry = useCallback(async (id: number | string): Promise<Entry | null> => {
|
||||||
setError("");
|
setError("");
|
||||||
const numericId = Number(id);
|
const numericId = Number(id);
|
||||||
if (!Number.isFinite(numericId) || numericId <= 0) return null;
|
if (!Number.isFinite(numericId) || numericId <= 0) return null;
|
||||||
119
apps/web/features/entries/hooks/use-schedules.ts
Normal file
119
apps/web/features/entries/hooks/use-schedules.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { Schedule, ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
|
||||||
|
import { schedulesCreate, schedulesDelete, schedulesList, schedulesUpdate } from "@/lib/client/schedules";
|
||||||
|
import type { ApiResult } from "@/lib/client/fetch-json";
|
||||||
|
|
||||||
|
type ScheduleInput = {
|
||||||
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
amountDollars: number;
|
||||||
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
|
purchaseType: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
startsOn: string;
|
||||||
|
frequency: ScheduleFrequency;
|
||||||
|
intervalCount?: number;
|
||||||
|
endCondition?: ScheduleEndCondition;
|
||||||
|
endCount?: number | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
|
||||||
|
return "error" in result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSchedulesAsc(a: Schedule, b: Schedule) {
|
||||||
|
if (a.nextRunOn === b.nextRunOn) return Number(a.id) - Number(b.id);
|
||||||
|
return a.nextRunOn > b.nextRunOn ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertScheduleSorted(schedules: Schedule[], next: Schedule) {
|
||||||
|
const without = schedules.filter(item => Number(item.id) !== Number(next.id));
|
||||||
|
const merged = [next, ...without];
|
||||||
|
merged.sort(compareSchedulesAsc);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useSchedules(activeGroupId?: number | null) {
|
||||||
|
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!activeGroupId) {
|
||||||
|
setError("");
|
||||||
|
setSchedules([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const result = await schedulesList();
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
setSchedules([]);
|
||||||
|
} else {
|
||||||
|
const next = [...(result.data.schedules || [])];
|
||||||
|
next.sort(compareSchedulesAsc);
|
||||||
|
setSchedules(next);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [activeGroupId]);
|
||||||
|
|
||||||
|
const createSchedule = useCallback(async (input: ScheduleInput & { createEntryNow?: boolean }): Promise<Schedule | null> => {
|
||||||
|
setError("");
|
||||||
|
const result = await schedulesCreate(input);
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const created = result.data.schedule;
|
||||||
|
setSchedules(prev => upsertScheduleSorted(prev, created));
|
||||||
|
return created;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateSchedule = useCallback(async (input: ScheduleInput & { id: number; nextRunOn?: string; isActive?: boolean }): Promise<Schedule | null> => {
|
||||||
|
setError("");
|
||||||
|
const result = await schedulesUpdate(input);
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const updated = result.data.schedule;
|
||||||
|
setSchedules(prev => upsertScheduleSorted(prev, updated));
|
||||||
|
return updated;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteSchedule = useCallback(async (id: number | string): Promise<Schedule | null> => {
|
||||||
|
setError("");
|
||||||
|
const numericId = Number(id);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) return null;
|
||||||
|
let removed: Schedule | null = null;
|
||||||
|
const result = await schedulesDelete({ id });
|
||||||
|
if (isError(result)) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setSchedules(prev => {
|
||||||
|
const index = prev.findIndex(item => Number(item.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 {
|
||||||
|
schedules,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
createSchedule,
|
||||||
|
updateSchedule,
|
||||||
|
deleteSchedule,
|
||||||
|
reload: load
|
||||||
|
};
|
||||||
|
}
|
||||||
3
apps/web/features/groups/README.md
Normal file
3
apps/web/features/groups/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Groups Feature
|
||||||
|
|
||||||
|
Reserved for groups domain modules (components/hooks/lib) during incremental migration.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user