Compare commits
No commits in common. "f8e426542d1f7a912da4ed93ac29f1ea595e2899" and "4873449e16f96859caefc2f76223d0457116b755" have entirely different histories.
f8e426542d
...
4873449e16
@ -1,28 +0,0 @@
|
||||
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
|
||||
@ -1,93 +0,0 @@
|
||||
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
|
||||
69
.gitea/workflows/deploy.yml
Normal file
69
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,69 @@
|
||||
name: Build & Deploy Fiddy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
env:
|
||||
REGISTRY: git.nicosaya.com/nalalangan/fiddy
|
||||
IMAGE_TAG: main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test --if-present
|
||||
|
||||
- name: Docker login
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login $REGISTRY -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Build Web Image
|
||||
run: |
|
||||
docker build -t $REGISTRY/web:${{ github.sha }} -t $REGISTRY/web:${{ env.IMAGE_TAG }} -f docker/Dockerfile .
|
||||
|
||||
- name: Push Web Image
|
||||
run: |
|
||||
docker push $REGISTRY/web:${{ github.sha }}
|
||||
docker push $REGISTRY/web:${{ env.IMAGE_TAG }}
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Upload docker-compose.yml
|
||||
run: |
|
||||
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} "mkdir -p /opt/fiddy"
|
||||
scp docker-compose.yml ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/fiddy/docker-compose.yml
|
||||
|
||||
- name: Deploy via SSH
|
||||
run: |
|
||||
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'EOF'
|
||||
cd /opt/fiddy
|
||||
export IMAGE_TAG=main
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
docker image prune -f
|
||||
EOF
|
||||
148
.github/copilot-instructions.md
vendored
148
.github/copilot-instructions.md
vendored
@ -1,41 +1,123 @@
|
||||
# Copilot Instructions — Fiddy (External DB)
|
||||
|
||||
## Authority
|
||||
- **Source of truth:** `PROJECT_INSTRUCTIONS.md` (repo root). If conflict, follow it.
|
||||
- **Bugfix work:** follow `DEBUGGING_INSTRUCTIONS.md` (repo root).
|
||||
- Keep this file short: it’s a guide for Copilot behavior, not the full spec.
|
||||
## Source of truth
|
||||
- Always consult PROJECT_INSTRUCTIONS.md at the repo root.
|
||||
- If any guidance conflicts, PROJECT_INSTRUCTIONS.md takes precedence.
|
||||
- Keep this file focused on architecture/stack; keep checklists and project workflow rules in PROJECT_INSTRUCTIONS.md.
|
||||
- When asked to fix bugs, follow DEBUGGING_INSTRUCTIONS.md at the repo root.
|
||||
|
||||
## High-level behavior
|
||||
- Make the **smallest change** that resolves the bug or request.
|
||||
- **Scan the repo first** for existing patterns (don’t invent files/endpoints unless necessary).
|
||||
- Respect layering: **route → server service → client wrapper → hook → UI**.
|
||||
- Keep diffs tight; avoid large refactors unless required.
|
||||
## Stack
|
||||
- Monorepo (npm workspaces)
|
||||
- Next.js (App Router) + TypeScript + Tailwind
|
||||
- External Postgres (on-prem server) via node-postgres (pg). No ORM.
|
||||
- Docker Compose dev/prod
|
||||
- Gitea + act-runner CI/CD
|
||||
|
||||
## Hard rules (do not violate)
|
||||
- External DB: `DATABASE_URL` points to on-prem Postgres (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.
|
||||
## Environment
|
||||
- Dev and Prod must use the same schema/migrations (`packages/db/migrations`).
|
||||
- `DATABASE_URL` points to the external DB server (NOT a container).
|
||||
|
||||
## Architecture quick map (follow existing patterns)
|
||||
- API routes: `app/api/**/route.ts` (thin parse/validate + call service)
|
||||
- Server services: `lib/server/*` (DB + authz, must include `import "server-only";`)
|
||||
- Client wrappers: `lib/client/*` (typed fetch + error normalization, credentials included)
|
||||
- Hooks: `hooks/use-*.ts` (UI-facing API layer; components avoid raw `fetch()`)
|
||||
## Auth
|
||||
- Custom email/password auth.
|
||||
- Use HttpOnly session cookies backed by DB table `sessions`.
|
||||
- NEVER trust client-side RBAC checks.
|
||||
|
||||
## API conventions
|
||||
- Prefer error shape: `{ error: { code, message }, request_id? }`
|
||||
- Validate input at the route boundary; authorize in services.
|
||||
## Receipts
|
||||
- Store receipt images in Postgres `bytea` table `receipts`.
|
||||
- Entries list endpoints must not return image bytes.
|
||||
- Image bytes only fetched by separate endpoint when inspecting a single item.
|
||||
|
||||
## 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
|
||||
## UI
|
||||
- Dark mode, minimal, mobile-first.
|
||||
- Navbar layout: left nav dropdown, middle group selector, right user menu.
|
||||
- Dodger Blue accent (#1E90FF).
|
||||
- Top navbar: left nav dropdown, middle group selector, right user menu.
|
||||
|
||||
## Code Rules
|
||||
- Small files, minimal comments.
|
||||
- Prefer single-line `if` without braces when only one line follows.
|
||||
- Heavy logic lives in components/hooks/services, not page files.
|
||||
- Prefer API calls via exported hooks/services for reusability and cleanliness (avoid raw fetch in components when possible).
|
||||
- Add/update unit tests with changes (TDD).
|
||||
- Heavy focus on code readability and maintainability; prioritize clean code over clever code.
|
||||
- ie. Separating different concerns into different files, and adding helper functions to avoid nested logic and improve readability, is preferred over clever one-liners or consolidating logic into fewer files.
|
||||
- ie. Separate groups of related codes by adding 3 line breaks between them
|
||||
|
||||
## Data Model
|
||||
- Users (system_role USER|SYS_ADMIN)
|
||||
- Groups + membership (group_role MEMBER|GROUP_ADMIN)
|
||||
- Entries (group-scoped) + optional receipt_id
|
||||
- User settings (jsonb)
|
||||
- Reports for system admins
|
||||
|
||||
## Architecture Contract (Backend ↔ Client ↔ Hooks ↔ UI)
|
||||
|
||||
### No-Assumptions Rule (Required)
|
||||
- Before making structural changes, first scan the repo and identify:
|
||||
- the web app root (where `app/`, `components/`, `hooks/`, `lib/` live)
|
||||
- existing API routes and helpers
|
||||
- existing patterns already in use
|
||||
- Do not invent files, endpoints, or conventions. If something is missing, add it minimally and consistently.
|
||||
|
||||
### Layering (Hard Boundaries)
|
||||
For every domain (auth, groups, entries, receipts, etc.), keep a consistent 4-layer flow:
|
||||
|
||||
1) **API Route Handlers** (`app/api/.../route.ts`)
|
||||
- Thin: parse input, call a server service, return JSON.
|
||||
- No direct DB queries inside route files unless there is no existing server service.
|
||||
- Must enforce auth & membership checks on server.
|
||||
|
||||
2) **Server Services (DB + authorization)** (`lib/server/*`)
|
||||
- Own all DB access and authorization helpers (sessions, requireGroupMember, etc.).
|
||||
- Server-only modules must include `import "server-only";`
|
||||
- Prefer small domain modules: `lib/server/auth.ts`, `lib/server/groups.ts`, `lib/server/entries.ts`, `lib/server/session.ts`.
|
||||
|
||||
3) **Client API Wrappers** (`lib/client/*`)
|
||||
- Typed fetch helpers only (no React state).
|
||||
- Centralize `fetchJson()` / error normalization.
|
||||
- Always send credentials (cookies) and never trust client-side RBAC.
|
||||
|
||||
4) **Hooks (UI-facing API layer)** (`hooks/use-*.ts`)
|
||||
- Hooks are the primary interface for components/pages to call APIs.
|
||||
- Components should not call `fetch()` directly unless there’s a strong reason.
|
||||
|
||||
### Domain Blueprint (Consistency Rule)
|
||||
For any new feature/domain, prefer:
|
||||
- `app/api/<domain>/...`
|
||||
- `lib/server/<domain>.ts`
|
||||
- `lib/client/<domain>.ts`
|
||||
- `hooks/use-<domain>.ts`
|
||||
- `components/<domain>/*`
|
||||
- `__tests__/<domain>.test.ts`
|
||||
|
||||
### API Conventions
|
||||
- Prefer consistent JSON response shape for errors:
|
||||
- `{ error: { code: string, message: string } }`
|
||||
- Validate inputs at the route boundary (basic shape/type), and validate authorization in server services.
|
||||
- When adding endpoints, mirror existing REST style used in the project.
|
||||
|
||||
### Non-Regression Contracts (Do Not Break)
|
||||
- Entries list endpoints must **never** include receipt image bytes; image bytes are fetched via a separate endpoint only.
|
||||
- Auth is DB-backed HttpOnly sessions; all auth checks are server-side.
|
||||
- Groups require server-side membership checks; active group persists per user.
|
||||
- Group invite codes:
|
||||
- shown once immediately after group creation
|
||||
- modal renders outside navbar/header so it overlays the viewport correctly
|
||||
- avoid re-exposing invite code elsewhere without explicit “group settings” work
|
||||
|
||||
### UI Structure
|
||||
- Page files stay thin; heavy logic stays in hooks/services/components.
|
||||
- Dark mode, minimal, mobile-first.
|
||||
- Dodger Blue accent (#1E90FF).
|
||||
- Navbar: left nav dropdown, middle group selector, right user menu.
|
||||
|
||||
### Environment
|
||||
- Dev and Prod must use the same schema/migrations (`packages/db/migrations`).
|
||||
- `DATABASE_URL` points to external Postgres (NOT a container).
|
||||
- `DATABASE_URL` format must be a full connection string; URL-encode special chars in passwords.
|
||||
|
||||
### Tests (Required)
|
||||
- Add/update tests for API behavior changes:
|
||||
- auth
|
||||
- groups
|
||||
- entries (group scoping)
|
||||
- Tests must include negative cases: unauthorized, not-a-member, invalid inputs.
|
||||
|
||||
53
AGENTS.md
53
AGENTS.md
@ -1,53 +0,0 @@
|
||||
# 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,130 +1,24 @@
|
||||
# Project Instructions — Fiddy (External DB)
|
||||
|
||||
## 1) Core expectation
|
||||
This project connects to an **external Postgres instance (on-prem server)**. Dev and Prod must share the **same schema** through **migrations**.
|
||||
## Core expectation
|
||||
This project connects to an external Postgres instance (on-prem server). Dev and Prod must share the same schema through migrations.
|
||||
|
||||
## 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)
|
||||
## Decisions / constraints (Group Settings)
|
||||
- Add `GROUP_OWNER` role to group roles; migrate existing groups so the first admin becomes owner.
|
||||
- Join policy default is `NOT_ACCEPTING`. Policies: `NOT_ACCEPTING`, `AUTO_ACCEPT`, `APPROVAL_REQUIRED`.
|
||||
- Both owner and admins can approve join requests and manage invite links.
|
||||
- Invite links:
|
||||
- TTL limited to 1–7 days.
|
||||
- Settings are immutable after creation (policy, single-use, etc.).
|
||||
- Single-use does not override approval-required.
|
||||
- Expired links are retained and can be revived.
|
||||
- Single-use links are deleted after successful use.
|
||||
- Revive resets `used_at` and `revoked_at`, refreshes `expires_at`, and creates a new audit event.
|
||||
- TTL limited to 1–7 days.
|
||||
- Settings are immutable after creation (policy, single-use, etc.).
|
||||
- Single-use does not override approval-required.
|
||||
- Expired links are retained and can be revived.
|
||||
- Single-use links are deleted after successful use.
|
||||
- Revive resets `used_at` and `revoked_at`, refreshes `expires_at`, and creates a new audit event.
|
||||
- No cron/worker jobs for now (auto ownership transfer and invite rotation are paused).
|
||||
- Group role icons must be consistent: 👑 owner, 🛡️ admin, 👤 member.
|
||||
- API must generate `request_id` and return it in responses; audit logs must include it.
|
||||
- Audit logs must never store full invite codes (store last4 only).
|
||||
|
||||
---
|
||||
|
||||
## 7) Do first (vertical slice)
|
||||
## Do first (vertical slice)
|
||||
1) DB migrate command + schema
|
||||
2) Register/Login/Logout (custom sessions)
|
||||
3) Protected dashboard page
|
||||
@ -133,57 +27,24 @@ For `app/api/**/[param]/route.ts`:
|
||||
6) Receipt upload/download endpoints
|
||||
7) Settings + Reports
|
||||
|
||||
---
|
||||
|
||||
## 8) Definition of done
|
||||
- Works via `docker-compose.dev.yml` with external DB
|
||||
## Definition of done
|
||||
- Works via docker-compose.dev.yml with external DB
|
||||
- Migrations applied via `npm run db:migrate`
|
||||
- Tests + lint pass
|
||||
- RBAC enforced server-side
|
||||
- No large files
|
||||
- No TypeScript warnings or lint errors in touched files
|
||||
- No new cron/worker dependencies unless explicitly approved
|
||||
- No orphaned utilities/hooks/contexts after refactors
|
||||
- No duplicate mechanisms for the same state flow
|
||||
- Text encoding remains clean in user-facing strings/docs
|
||||
|
||||
---
|
||||
|
||||
## 9) Desktop + mobile UX checklist (required)
|
||||
## Desktop + mobile UX checklist (required)
|
||||
- Touch: long-press affordance for item-level actions when no visible button.
|
||||
- Mouse: hover affordance on interactive rows/cards.
|
||||
- Tap targets remain >= 40px on mobile.
|
||||
- Modal overlays must close on outside click/tap.
|
||||
- Use bubble notifications for main actions (create/update/delete/join).
|
||||
- Add Playwright UI tests for new UI features and critical flows.
|
||||
- Group role icons must be consistent: 👑 owner, 🛡️ admin, 👤 member.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
## PR review checklist
|
||||
- Desktop + mobile UX checklist satisfied (hover + long-press where applicable).
|
||||
- No TypeScript warnings or lint errors introduced.
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@ -1,168 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"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";
|
||||
|
||||
test("calculateBucketUsage matches tag subset", () => {
|
||||
const bucket = { tags: ["groceries", "weekly"], necessity: "BOTH" as const, windowDays: 30 };
|
||||
const bucket = { tags: ["groceries", "weekly"], necessity: "BOTH", windowDays: 30 };
|
||||
const entries = [
|
||||
{ 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" as const, tags: ["groceries"], entryType: "SPENDING" as const }
|
||||
{ amountDollars: 20, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["groceries", "weekly", "extra"], isRecurring: false, entryType: "SPENDING" as const },
|
||||
{ amountDollars: 15, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["groceries"], isRecurring: false, entryType: "SPENDING" as const }
|
||||
];
|
||||
|
||||
const result = calculateBucketUsage(bucket, entries, today);
|
||||
@ -16,10 +16,10 @@ test("calculateBucketUsage matches tag subset", () => {
|
||||
assert.equal(result.matchedCount, 1);
|
||||
});
|
||||
|
||||
test("calculateBucketUsage ignores non-spending entries", () => {
|
||||
const bucket = { tags: ["rent"], necessity: "BOTH" as const, windowDays: 30 };
|
||||
test("calculateBucketUsage excludes recurring entries", () => {
|
||||
const bucket = { tags: ["rent"], necessity: "BOTH", windowDays: 30 };
|
||||
const entries = [
|
||||
{ amountDollars: 500, occurredAt: "2026-02-10", necessity: "NECESSARY" as const, tags: ["rent"], entryType: "INCOME" as const }
|
||||
{ amountDollars: 500, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["rent"], isRecurring: true, entryType: "SPENDING" as const }
|
||||
];
|
||||
|
||||
const result = calculateBucketUsage(bucket, entries, today);
|
||||
@ -28,11 +28,11 @@ test("calculateBucketUsage ignores non-spending entries", () => {
|
||||
});
|
||||
|
||||
test("calculateBucketUsage applies windowDays filtering", () => {
|
||||
const bucket = { tags: [], necessity: "BOTH" as const, windowDays: 3 };
|
||||
const bucket = { tags: [], necessity: "BOTH", windowDays: 3 };
|
||||
const entries = [
|
||||
{ amountDollars: 10, occurredAt: "2026-02-11", necessity: "NECESSARY" as const, tags: [], 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" as const, tags: [], entryType: "SPENDING" as const }
|
||||
{ amountDollars: 10, occurredAt: "2026-02-11", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const },
|
||||
{ amountDollars: 10, occurredAt: "2026-02-09", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const },
|
||||
{ amountDollars: 10, occurredAt: "2026-02-08", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const }
|
||||
];
|
||||
|
||||
const result = calculateBucketUsage(bucket, entries, today);
|
||||
@ -41,10 +41,10 @@ test("calculateBucketUsage applies windowDays filtering", () => {
|
||||
});
|
||||
|
||||
test("calculateBucketUsage accepts all when bucket necessity is BOTH", () => {
|
||||
const bucket = { tags: [], necessity: "BOTH" as const, windowDays: 30 };
|
||||
const bucket = { tags: [], necessity: "BOTH", windowDays: 30 };
|
||||
const entries = [
|
||||
{ amountDollars: 12, occurredAt: "2026-02-10", necessity: "NECESSARY" as const, tags: [], entryType: "SPENDING" as const },
|
||||
{ amountDollars: 8, occurredAt: "2026-02-10", necessity: "UNNECESSARY" as const, tags: [], entryType: "SPENDING" as const }
|
||||
{ amountDollars: 12, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const },
|
||||
{ amountDollars: 8, occurredAt: "2026-02-10", necessity: "UNNECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const }
|
||||
];
|
||||
|
||||
const result = calculateBucketUsage(bucket, entries, today);
|
||||
@ -53,10 +53,10 @@ test("calculateBucketUsage accepts all when bucket necessity is BOTH", () => {
|
||||
});
|
||||
|
||||
test("calculateBucketUsage halves BOTH entries when bucket not BOTH", () => {
|
||||
const bucket = { tags: [], necessity: "NECESSARY" as const, windowDays: 30 };
|
||||
const bucket = { tags: [], necessity: "NECESSARY", windowDays: 30 };
|
||||
const entries = [
|
||||
{ amountDollars: 10, occurredAt: "2026-02-10", necessity: "BOTH" as const, tags: [], entryType: "SPENDING" as const },
|
||||
{ amountDollars: 10, occurredAt: "2026-02-10", necessity: "UNNECESSARY" as const, tags: [], entryType: "SPENDING" as const }
|
||||
{ amountDollars: 10, occurredAt: "2026-02-10", necessity: "BOTH", tags: [], isRecurring: false, entryType: "SPENDING" as const },
|
||||
{ amountDollars: 10, occurredAt: "2026-02-10", necessity: "UNNECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const }
|
||||
];
|
||||
|
||||
const result = calculateBucketUsage(bucket, entries, today);
|
||||
|
||||
@ -75,7 +75,7 @@ test("buckets CRUD", async t => {
|
||||
assert.equal(updated?.name, "Groceries+");
|
||||
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
|
||||
|
||||
await deleteBucket({ id: bucket.id, groupId, userId });
|
||||
await deleteBucket({ id: bucket.id, groupId });
|
||||
const listAfter = await listBuckets(groupId);
|
||||
assert.equal(listAfter.length, 0);
|
||||
} finally {
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
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(
|
||||
() => setGroupSettings({ userId: memberId!, groupId: groupId!, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" }),
|
||||
() => setGroupSettings({ userId: memberId!, groupId, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" }),
|
||||
{ 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" });
|
||||
assert.equal(result.status, "JOINED");
|
||||
|
||||
const queryResult = await client.query(
|
||||
const { rowCount } = await client.query(
|
||||
"select 1 from group_members where group_id=$1 and user_id=$2",
|
||||
[groupId, memberId]
|
||||
);
|
||||
assert.equal(queryResult.rows.length, 1);
|
||||
assert.equal(rowCount, 1);
|
||||
} finally {
|
||||
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
|
||||
client.release();
|
||||
|
||||
@ -1,139 +0,0 @@
|
||||
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,9 +56,11 @@ test("recurring entries list", async t => {
|
||||
purchaseType: "Rent",
|
||||
notes: "Monthly rent",
|
||||
tags: ["rent"],
|
||||
isRecurring: true,
|
||||
frequency: "MONTHLY",
|
||||
intervalCount: 1,
|
||||
endCondition: "NEVER"
|
||||
endCondition: "NEVER",
|
||||
nextRunAt: "2026-02-01"
|
||||
});
|
||||
|
||||
const list = await listRecurringEntries(groupId);
|
||||
@ -67,7 +69,7 @@ test("recurring entries list", async t => {
|
||||
} finally {
|
||||
if (groupId) {
|
||||
const list = await listRecurringEntries(groupId);
|
||||
for (const entry of list) await deleteRecurringEntry({ id: entry.id, groupId, userId: userId! });
|
||||
for (const entry of list) await deleteRecurringEntry({ id: entry.id, groupId });
|
||||
}
|
||||
await cleanupTestData(client, { userIds: [userId], groupId });
|
||||
client.release();
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
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,7 +55,9 @@ test("entries CRUD", async t => {
|
||||
necessity: "NECESSARY",
|
||||
purchaseType: "Groceries",
|
||||
notes: "Test",
|
||||
tags: ["groceries", "weekly"]
|
||||
tags: ["groceries", "weekly"],
|
||||
isRecurring: false,
|
||||
intervalCount: 1
|
||||
});
|
||||
|
||||
const list = await listEntries(groupId);
|
||||
@ -74,14 +76,19 @@ test("entries CRUD", async t => {
|
||||
necessity: "BOTH",
|
||||
purchaseType: "Groceries",
|
||||
notes: "Updated",
|
||||
tags: ["groceries"]
|
||||
tags: ["groceries"],
|
||||
isRecurring: true,
|
||||
frequency: "MONTHLY",
|
||||
intervalCount: 1,
|
||||
endCondition: "NEVER",
|
||||
nextRunAt: "2026-02-02"
|
||||
});
|
||||
assert.ok(updated);
|
||||
assert.equal(updated?.amountDollars, 15);
|
||||
assert.equal(updated?.entryType, "INCOME");
|
||||
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
|
||||
|
||||
await deleteEntry({ id: entry.id, groupId, userId });
|
||||
await deleteEntry({ id: entry.id, groupId });
|
||||
const listAfter = await listEntries(groupId);
|
||||
assert.equal(listAfter.length, 0);
|
||||
} finally {
|
||||
|
||||
@ -32,15 +32,6 @@ export async function cleanupTestData(client: PoolClient, args: CleanupArgs) {
|
||||
"delete from entry_tags where entry_id in (select id from entries where group_id=$1)",
|
||||
[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 buckets where group_id=$1", [groupId]);
|
||||
await safeQuery("delete from entries where group_id=$1", [groupId]);
|
||||
@ -73,3 +64,4 @@ export async function cleanupTestDataFromPool(pool: Pool, args: CleanupArgs) {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
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,28 +2,25 @@ import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { getSessionCookieName } from "@/lib/server/auth";
|
||||
import { loginUser } from "@/lib/server/auth-service";
|
||||
import { enforceAuthRateLimit } from "@/lib/server/rate-limit";
|
||||
import { toErrorResponse } from "@/lib/server/errors";
|
||||
import { getRequestMeta } from "@/lib/server/request";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { requestId, ip } = await getRequestMeta();
|
||||
const body = await req.json().catch(() => null);
|
||||
const email = String(body?.email || "").trim().toLowerCase();
|
||||
const password = String(body?.password || "");
|
||||
const remember = Boolean(body?.remember ?? true);
|
||||
|
||||
if (!email || !password)
|
||||
return NextResponse.json({ error: { code: "MISSING_CREDENTIALS", message: "Missing credentials" } }, { status: 400 });
|
||||
|
||||
let user;
|
||||
let session;
|
||||
try {
|
||||
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 });
|
||||
user = result.user;
|
||||
session = result.session;
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/auth/login", requestId);
|
||||
const { status, body } = toErrorResponse(e, "POST /api/auth/login");
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
const cookieStore = await cookies();
|
||||
@ -35,5 +32,5 @@ export async function POST(req: Request) {
|
||||
path: "/"
|
||||
});
|
||||
|
||||
return NextResponse.json({ requestId, request_id: requestId, user });
|
||||
return NextResponse.json({ user });
|
||||
}
|
||||
|
||||
@ -2,27 +2,19 @@ import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { getSessionCookieName } from "@/lib/server/auth";
|
||||
import { logoutUser } from "@/lib/server/auth-service";
|
||||
import { getRequestMeta } from "@/lib/server/request";
|
||||
import { toErrorResponse } from "@/lib/server/errors";
|
||||
|
||||
export async function POST() {
|
||||
const { requestId } = await getRequestMeta();
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(getSessionCookieName())?.value;
|
||||
if (token)
|
||||
await logoutUser(token);
|
||||
cookieStore.set(getSessionCookieName(), "", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 0,
|
||||
path: "/"
|
||||
});
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(getSessionCookieName())?.value;
|
||||
if (token)
|
||||
await logoutUser(token);
|
||||
cookieStore.set(getSessionCookieName(), "", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 0,
|
||||
path: "/"
|
||||
});
|
||||
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/auth/logout", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@ -1,17 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/server/session";
|
||||
import { getRequestMeta } from "@/lib/server/request";
|
||||
import { toErrorResponse } from "@/lib/server/errors";
|
||||
|
||||
export async function GET() {
|
||||
const { requestId } = await getRequestMeta();
|
||||
try {
|
||||
const user = await getSessionUser();
|
||||
if (!user)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "UNAUTHORIZED", message: "Unauthorized" } }, { status: 401 });
|
||||
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 });
|
||||
}
|
||||
const user = await getSessionUser();
|
||||
if (!user)
|
||||
return NextResponse.json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } }, { status: 401 });
|
||||
return NextResponse.json({ user });
|
||||
}
|
||||
|
||||
@ -2,30 +2,27 @@ import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { getSessionCookieName, getSessionTtlMs } from "@/lib/server/auth";
|
||||
import { registerUser } from "@/lib/server/auth-service";
|
||||
import { enforceAuthRateLimit } from "@/lib/server/rate-limit";
|
||||
import { toErrorResponse } from "@/lib/server/errors";
|
||||
import { getRequestMeta } from "@/lib/server/request";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { requestId, ip } = await getRequestMeta();
|
||||
const body = await req.json().catch(() => null);
|
||||
const email = String(body?.email || "").trim().toLowerCase();
|
||||
const password = String(body?.password || "");
|
||||
const displayName = String(body?.displayName || "").trim();
|
||||
|
||||
if (!email || !email.includes("@"))
|
||||
return NextResponse.json({ error: { code: "INVALID_EMAIL", message: "Invalid email" } }, { status: 400 });
|
||||
if (password.length < 8)
|
||||
return NextResponse.json({ error: { code: "PASSWORD_TOO_SHORT", message: "Password too short" } }, { status: 400 });
|
||||
|
||||
let user;
|
||||
let session;
|
||||
try {
|
||||
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 });
|
||||
user = result.user;
|
||||
session = result.session;
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/auth/register", requestId);
|
||||
const { status, body } = toErrorResponse(e, "POST /api/auth/register");
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
const cookieStore = await cookies();
|
||||
@ -37,5 +34,5 @@ export async function POST(req: Request) {
|
||||
path: "/"
|
||||
});
|
||||
|
||||
return NextResponse.json({ requestId, request_id: requestId, user });
|
||||
return NextResponse.json({ user });
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
||||
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 });
|
||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const name = String(body?.name || "").trim();
|
||||
@ -30,13 +30,13 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
||||
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
|
||||
|
||||
if (!name)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
|
||||
if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0))
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
|
||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||
if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
|
||||
|
||||
const bucket = await updateBucket({
|
||||
id,
|
||||
@ -51,9 +51,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
||||
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||
windowDays
|
||||
});
|
||||
if (!bucket) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
||||
if (!bucket) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
||||
|
||||
return NextResponse.json({ requestId, request_id: requestId, bucket });
|
||||
return NextResponse.json({ requestId, bucket });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "PATCH /api/buckets/[id]", requestId);
|
||||
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 { 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 });
|
||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||
|
||||
await deleteBucket({ id, groupId, userId: user.id });
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
await deleteBucket({ id, groupId });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "DELETE /api/buckets/[id]", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireSessionUser } from "@/lib/server/session";
|
||||
import { createBucket, listBuckets, requireActiveGroup } from "@/lib/server/buckets";
|
||||
import { toErrorResponse } from "@/lib/server/errors";
|
||||
@ -16,7 +16,7 @@ export async function GET() {
|
||||
const user = await requireSessionUser();
|
||||
const groupId = await requireActiveGroup(user.id);
|
||||
const buckets = await listBuckets(groupId);
|
||||
return NextResponse.json({ requestId, request_id: requestId, buckets });
|
||||
return NextResponse.json({ requestId, buckets });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "GET /api/buckets", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
@ -38,13 +38,13 @@ export async function POST(req: Request) {
|
||||
const necessity = String(body?.necessity || "BOTH").toUpperCase();
|
||||
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
|
||||
|
||||
if (!name) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
|
||||
if (!name) return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
|
||||
if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0))
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
|
||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||
if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
|
||||
|
||||
const bucket = await createBucket({
|
||||
groupId,
|
||||
@ -59,10 +59,9 @@ export async function POST(req: Request) {
|
||||
windowDays
|
||||
});
|
||||
|
||||
return NextResponse.json({ requestId, request_id: requestId, bucket });
|
||||
return NextResponse.json({ requestId, bucket });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/buckets", requestId);
|
||||
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 { 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 });
|
||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const amountDollars = Number(body?.amountDollars || 0);
|
||||
@ -27,16 +27,30 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
||||
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
|
||||
const notes = String(body?.notes || "").trim();
|
||||
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
|
||||
const isRecurring = Boolean(body?.isRecurring);
|
||||
const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null;
|
||||
const intervalCount = Number(body?.intervalCount || 1);
|
||||
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null;
|
||||
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : (isRecurring ? occurredAt : null);
|
||||
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
||||
|
||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { 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, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
||||
if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
|
||||
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||
return NextResponse.json({ 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 });
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
||||
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
||||
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
|
||||
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
|
||||
|
||||
const entry = await updateEntry({
|
||||
id,
|
||||
groupId,
|
||||
@ -48,12 +62,19 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
||||
purchaseType,
|
||||
notes: notes || undefined,
|
||||
tags,
|
||||
isRecurring,
|
||||
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
|
||||
intervalCount,
|
||||
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
||||
endCount,
|
||||
endDate,
|
||||
nextRunAt,
|
||||
bucketId
|
||||
});
|
||||
|
||||
if (!entry) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
||||
if (!entry) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
||||
|
||||
return NextResponse.json({ requestId, request_id: requestId, entry });
|
||||
return NextResponse.json({ requestId, entry });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "PATCH /api/entries/[id]", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
@ -67,10 +88,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
|
||||
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 });
|
||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||
|
||||
await deleteEntry({ id, groupId, userId: user.id });
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
await deleteEntry({ id, groupId });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "DELETE /api/entries/[id]", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireSessionUser } from "@/lib/server/session";
|
||||
import { createEntry, listEntries, requireActiveGroup } from "@/lib/server/entries";
|
||||
import { toErrorResponse } from "@/lib/server/errors";
|
||||
@ -16,7 +16,7 @@ export async function GET() {
|
||||
const user = await requireSessionUser();
|
||||
const groupId = await requireActiveGroup(user.id);
|
||||
const entries = await listEntries(groupId);
|
||||
return NextResponse.json({ requestId, request_id: requestId, entries });
|
||||
return NextResponse.json({ requestId, entries });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "GET /api/entries", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
@ -36,16 +36,30 @@ export async function POST(req: Request) {
|
||||
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
|
||||
const notes = String(body?.notes || "").trim();
|
||||
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
|
||||
const isRecurring = Boolean(body?.isRecurring);
|
||||
const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null;
|
||||
const intervalCount = Number(body?.intervalCount || 1);
|
||||
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null;
|
||||
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : (isRecurring ? occurredAt : null);
|
||||
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
||||
|
||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { 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, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
||||
if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
|
||||
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||
return NextResponse.json({ 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 });
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
|
||||
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
||||
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
|
||||
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
|
||||
|
||||
const entry = await createEntry({
|
||||
groupId,
|
||||
userId: user.id,
|
||||
@ -56,13 +70,19 @@ export async function POST(req: Request) {
|
||||
purchaseType,
|
||||
notes: notes || undefined,
|
||||
tags,
|
||||
isRecurring,
|
||||
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
|
||||
intervalCount,
|
||||
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
||||
endCount,
|
||||
endDate,
|
||||
nextRunAt,
|
||||
bucketId
|
||||
});
|
||||
|
||||
return NextResponse.json({ requestId, request_id: requestId, entry });
|
||||
return NextResponse.json({ requestId, entry });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/entries", requestId);
|
||||
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 { getActiveGroupId, listGroups, setActiveGroupForUser } from "@/lib/server/groups";
|
||||
import { toErrorResponse } from "@/lib/server/errors";
|
||||
@ -11,7 +11,7 @@ export async function GET() {
|
||||
const groups = await listGroups(user.id);
|
||||
const activeGroupId = await getActiveGroupId(user.id);
|
||||
const active = groups.find(group => Number(group.id) === activeGroupId) || null;
|
||||
return NextResponse.json({ requestId, request_id: requestId, active });
|
||||
return NextResponse.json({ requestId, active });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "GET /api/groups/active", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
@ -24,13 +24,12 @@ export async function POST(req: Request) {
|
||||
const user = await requireSessionUser();
|
||||
const body = await req.json().catch(() => null);
|
||||
const groupId = Number(body?.groupId || 0);
|
||||
if (!groupId) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_GROUP_ID", message: "groupId is required" } }, { status: 400 });
|
||||
if (!groupId) return NextResponse.json({ requestId, error: { code: "MISSING_GROUP_ID", message: "groupId is required" } }, { status: 400 });
|
||||
|
||||
await setActiveGroupForUser(user.id, groupId);
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups/active", requestId);
|
||||
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 { requireActiveGroup } from "@/lib/server/groups";
|
||||
import { listGroupAudit } from "@/lib/server/group-audit";
|
||||
@ -11,10 +11,9 @@ export async function GET() {
|
||||
const user = await requireSessionUser();
|
||||
const groupId = await requireActiveGroup(user.id);
|
||||
const events = await listGroupAudit({ userId: user.id, groupId });
|
||||
return NextResponse.json({ requestId, request_id: requestId, events });
|
||||
return NextResponse.json({ requestId, events });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "GET /api/groups/audit", requestId);
|
||||
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 { requireActiveGroup, deleteGroup } from "@/lib/server/groups";
|
||||
import { toErrorResponse } from "@/lib/server/errors";
|
||||
@ -10,10 +10,9 @@ export async function POST() {
|
||||
const user = await requireSessionUser();
|
||||
const groupId = await requireActiveGroup(user.id);
|
||||
await deleteGroup({ userId: user.id, groupId, requestId, ip, userAgent });
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups/delete", requestId);
|
||||
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 { requireActiveGroup } from "@/lib/server/groups";
|
||||
import { deleteInviteLink } from "@/lib/server/group-invites";
|
||||
@ -13,12 +13,11 @@ export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => null);
|
||||
const linkId = Number(body?.linkId || 0);
|
||||
if (!linkId)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
||||
await deleteInviteLink({ linkId, userId: user.id, groupId, requestId, ip, userAgent });
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/delete", requestId);
|
||||
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 { requireActiveGroup } from "@/lib/server/groups";
|
||||
import { reviveInviteLink } from "@/lib/server/group-invites";
|
||||
@ -14,15 +14,14 @@ export async function POST(req: Request) {
|
||||
const linkId = Number(body?.linkId || 0);
|
||||
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
|
||||
if (!linkId)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
||||
if (!ttlDays)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
|
||||
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
|
||||
await reviveInviteLink({ userId: user.id, groupId, linkId, expiresAt, requestId, ip, userAgent });
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revive", requestId);
|
||||
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 { requireActiveGroup } from "@/lib/server/groups";
|
||||
import { revokeInviteLink } from "@/lib/server/group-invites";
|
||||
@ -13,12 +13,11 @@ export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => null);
|
||||
const linkId = Number(body?.linkId || 0);
|
||||
if (!linkId)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
|
||||
await revokeInviteLink({ userId: user.id, groupId, linkId, requestId, ip, userAgent });
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revoke", requestId);
|
||||
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 { requireActiveGroup } from "@/lib/server/groups";
|
||||
import { createInviteLink, listInviteLinks } from "@/lib/server/group-invites";
|
||||
@ -11,7 +11,7 @@ export async function GET() {
|
||||
const user = await requireSessionUser();
|
||||
const groupId = await requireActiveGroup(user.id);
|
||||
const links = await listInviteLinks({ userId: user.id, groupId });
|
||||
return NextResponse.json({ requestId, request_id: requestId, links });
|
||||
return NextResponse.json({ requestId, links });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "GET /api/groups/invites", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
@ -30,13 +30,12 @@ export async function POST(req: Request) {
|
||||
const singleUse = Boolean(body?.singleUse);
|
||||
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
|
||||
if (!ttlDays)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
|
||||
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
|
||||
const link = await createInviteLink({ userId: user.id, groupId, policy, singleUse, expiresAt, requestId, ip, userAgent });
|
||||
return NextResponse.json({ requestId, request_id: requestId, link });
|
||||
return NextResponse.json({ requestId, link });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups/invites", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,25 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireSessionUser } from "@/lib/server/session";
|
||||
import { joinGroup } from "@/lib/server/groups";
|
||||
import { toErrorResponse } from "@/lib/server/errors";
|
||||
import { getRequestMeta } from "@/lib/server/request";
|
||||
import { enforceIpRateLimit } from "@/lib/server/rate-limit";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { requestId, ip } = await getRequestMeta();
|
||||
const { requestId } = await getRequestMeta();
|
||||
try {
|
||||
await enforceIpRateLimit({ scope: "groups:join:ip", ip, limit: 60 });
|
||||
const user = await requireSessionUser();
|
||||
const body = await req.json().catch(() => null);
|
||||
const inviteCode = String(body?.inviteCode || "").trim().toUpperCase();
|
||||
if (!inviteCode)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_INVITE_CODE", message: "Invite code is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "MISSING_INVITE_CODE", message: "Invite code is required" } }, { status: 400 });
|
||||
|
||||
const group = await joinGroup(user.id, inviteCode);
|
||||
return NextResponse.json({ requestId, request_id: requestId, group });
|
||||
return NextResponse.json({ requestId, group });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups/join", requestId);
|
||||
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 { requireActiveGroup } from "@/lib/server/groups";
|
||||
import { approveJoinRequest } from "@/lib/server/group-members";
|
||||
@ -14,12 +14,11 @@ export async function POST(req: Request) {
|
||||
const userId = Number(body?.userId || 0);
|
||||
const joinRequestId = Number(body?.requestId || 0);
|
||||
if (!userId)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||
await approveJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent, requestRowId: joinRequestId || undefined });
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/approve", requestId);
|
||||
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 { requireActiveGroup } from "@/lib/server/groups";
|
||||
import { demoteAdmin } from "@/lib/server/group-members";
|
||||
@ -13,12 +13,11 @@ export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => null);
|
||||
const userId = Number(body?.userId || 0);
|
||||
if (!userId)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||
await demoteAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/demote", requestId);
|
||||
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 { requireActiveGroup } from "@/lib/server/groups";
|
||||
import { denyJoinRequest } from "@/lib/server/group-members";
|
||||
@ -13,12 +13,11 @@ export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => null);
|
||||
const userId = Number(body?.userId || 0);
|
||||
if (!userId)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||
await denyJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent });
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/deny", requestId);
|
||||
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 { requireActiveGroup } from "@/lib/server/groups";
|
||||
import { kickMember } from "@/lib/server/group-members";
|
||||
@ -13,12 +13,11 @@ export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => null);
|
||||
const userId = Number(body?.userId || 0);
|
||||
if (!userId)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||
await kickMember({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/kick", requestId);
|
||||
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 { requireActiveGroup } from "@/lib/server/groups";
|
||||
import { leaveGroup } from "@/lib/server/group-members";
|
||||
@ -11,10 +11,9 @@ export async function POST() {
|
||||
const user = await requireSessionUser();
|
||||
const groupId = await requireActiveGroup(user.id);
|
||||
await leaveGroup({ userId: user.id, groupId, requestId, ip, userAgent });
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/leave", requestId);
|
||||
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 { requireActiveGroup } from "@/lib/server/groups";
|
||||
import { promoteToAdmin } from "@/lib/server/group-members";
|
||||
@ -13,12 +13,11 @@ export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => null);
|
||||
const userId = Number(body?.userId || 0);
|
||||
if (!userId)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||
await promoteToAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/promote", requestId);
|
||||
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 { requireActiveGroup } from "@/lib/server/groups";
|
||||
import { listGroupMembers, listJoinRequests } from "@/lib/server/group-members";
|
||||
@ -12,10 +12,9 @@ export async function GET() {
|
||||
const groupId = await requireActiveGroup(user.id);
|
||||
const members = await listGroupMembers(groupId);
|
||||
const requests = await listJoinRequests({ userId: user.id, groupId });
|
||||
return NextResponse.json({ requestId, request_id: requestId, members, requests, currentUserId: user.id });
|
||||
return NextResponse.json({ requestId, members, requests, currentUserId: user.id });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "GET /api/groups/members", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireSessionUser } from "@/lib/server/session";
|
||||
import { requireActiveGroup } from "@/lib/server/groups";
|
||||
import { transferOwnership } from "@/lib/server/group-members";
|
||||
@ -13,12 +13,11 @@ export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => null);
|
||||
const userId = Number(body?.userId || 0);
|
||||
if (!userId)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
|
||||
await transferOwnership({ actorUserId: user.id, groupId, newOwnerUserId: userId, requestId, ip, userAgent });
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/transfer-owner", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireSessionUser } from "@/lib/server/session";
|
||||
import { requireActiveGroup, renameGroup } from "@/lib/server/groups";
|
||||
import { toErrorResponse } from "@/lib/server/errors";
|
||||
@ -12,12 +12,11 @@ export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => null);
|
||||
const name = String(body?.name || "").trim();
|
||||
if (!name)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
|
||||
await renameGroup({ userId: user.id, groupId, name, requestId, ip, userAgent });
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups/rename", requestId);
|
||||
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 { createGroup, listGroups } from "@/lib/server/groups";
|
||||
import { toErrorResponse } from "@/lib/server/errors";
|
||||
@ -9,7 +9,7 @@ export async function GET() {
|
||||
try {
|
||||
const user = await requireSessionUser();
|
||||
const groups = await listGroups(user.id);
|
||||
return NextResponse.json({ requestId, request_id: requestId, groups });
|
||||
return NextResponse.json({ requestId, groups });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "GET /api/groups", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
@ -22,13 +22,12 @@ export async function POST(req: Request) {
|
||||
const user = await requireSessionUser();
|
||||
const body = await req.json().catch(() => null);
|
||||
const name = String(body?.name || "").trim();
|
||||
if (!name) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
|
||||
if (!name) return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
|
||||
|
||||
const group = await createGroup(user.id, name);
|
||||
return NextResponse.json({ requestId, request_id: requestId, group });
|
||||
return NextResponse.json({ requestId, group });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups", requestId);
|
||||
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 { requireActiveGroup } from "@/lib/server/groups";
|
||||
import { getGroupSettings, setGroupSettings } from "@/lib/server/group-settings";
|
||||
@ -11,7 +11,7 @@ export async function GET() {
|
||||
const user = await requireSessionUser();
|
||||
const groupId = await requireActiveGroup(user.id);
|
||||
const settings = await getGroupSettings(groupId);
|
||||
return NextResponse.json({ requestId, request_id: requestId, settings });
|
||||
return NextResponse.json({ requestId, settings });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "GET /api/groups/settings", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
@ -30,10 +30,9 @@ export async function POST(req: Request) {
|
||||
: "NOT_ACCEPTING";
|
||||
await setGroupSettings({ userId: user.id, groupId, allowMemberTagManage, joinPolicy });
|
||||
const settings = await getGroupSettings(groupId);
|
||||
return NextResponse.json({ requestId, request_id: requestId, settings });
|
||||
return NextResponse.json({ requestId, settings });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/groups/settings", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
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"
|
||||
});
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
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,12 +3,10 @@ import { getSessionUser, requireSessionUser } from "@/lib/server/session";
|
||||
import { apiError, toErrorResponse } from "@/lib/server/errors";
|
||||
import { getRequestMeta } from "@/lib/server/request";
|
||||
import { acceptInviteLink, getInviteLinkSummaryByToken, getInviteViewerStatus } from "@/lib/server/group-invites";
|
||||
import { enforceIpRateLimit } from "@/lib/server/rate-limit";
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ token: string }> }) {
|
||||
const { requestId, ip } = await getRequestMeta();
|
||||
const { requestId } = await getRequestMeta();
|
||||
try {
|
||||
await enforceIpRateLimit({ scope: "invite-links:get:ip", ip, limit: 120 });
|
||||
const { token } = await context.params;
|
||||
const normalized = String(token || "").trim();
|
||||
if (!normalized) apiError("INVITE_NOT_FOUND");
|
||||
@ -18,9 +16,9 @@ export async function GET(_: Request, context: { params: Promise<{ token: string
|
||||
if (user) {
|
||||
const viewerStatus = await getInviteViewerStatus({ userId: user.id, groupId: link.groupId });
|
||||
if (viewerStatus)
|
||||
return NextResponse.json({ requestId, request_id: requestId, link: { ...link, viewerStatus } });
|
||||
return NextResponse.json({ requestId, link: { ...link, viewerStatus } });
|
||||
}
|
||||
return NextResponse.json({ requestId, request_id: requestId, link });
|
||||
return NextResponse.json({ requestId, link });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "GET /api/invite-links/[token]", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
@ -30,13 +28,12 @@ export async function GET(_: Request, context: { params: Promise<{ token: string
|
||||
export async function POST(_: Request, context: { params: Promise<{ token: string }> }) {
|
||||
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||
try {
|
||||
await enforceIpRateLimit({ scope: "invite-links:accept:ip", ip, limit: 60 });
|
||||
const user = await requireSessionUser();
|
||||
const { token } = await context.params;
|
||||
const normalized = String(token || "").trim();
|
||||
if (!normalized) apiError("INVITE_NOT_FOUND");
|
||||
const result = await acceptInviteLink({ userId: user.id, token: normalized, requestId, ip, userAgent });
|
||||
return NextResponse.json({ requestId, request_id: requestId, result });
|
||||
return NextResponse.json({ requestId, result });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/invite-links/[token]", requestId);
|
||||
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 { 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 });
|
||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const amountDollars = Number(body?.amountDollars || 0);
|
||||
@ -33,21 +33,22 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
||||
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
|
||||
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
||||
|
||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { 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, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
||||
if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
|
||||
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||
return NextResponse.json({ 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 });
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { 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 (frequency && !['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency))
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
|
||||
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, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
|
||||
|
||||
const entry = await updateRecurringEntry({
|
||||
id,
|
||||
@ -60,17 +61,19 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
||||
purchaseType,
|
||||
notes: notes || undefined,
|
||||
tags,
|
||||
frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null,
|
||||
isRecurring: true,
|
||||
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
|
||||
intervalCount,
|
||||
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
||||
endCount,
|
||||
endDate,
|
||||
nextRunAt
|
||||
nextRunAt,
|
||||
bucketId
|
||||
});
|
||||
|
||||
if (!entry) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
||||
if (!entry) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
|
||||
|
||||
return NextResponse.json({ requestId, request_id: requestId, entry });
|
||||
return NextResponse.json({ requestId, entry });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "PATCH /api/recurring-entries/[id]", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
@ -84,10 +87,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
|
||||
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 });
|
||||
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
|
||||
|
||||
await deleteRecurringEntry({ id, groupId, userId: user.id });
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
await deleteRecurringEntry({ id, groupId });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "DELETE /api/recurring-entries/[id]", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
|
||||
@ -16,7 +16,7 @@ export async function GET() {
|
||||
const user = await requireSessionUser();
|
||||
const groupId = await requireActiveGroup(user.id);
|
||||
const entries = await listRecurringEntries(groupId);
|
||||
return NextResponse.json({ requestId, request_id: requestId, entries });
|
||||
return NextResponse.json({ requestId, entries });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "GET /api/recurring-entries", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
@ -42,21 +42,22 @@ export async function POST(req: Request) {
|
||||
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
|
||||
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
||||
|
||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { 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, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
||||
if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
|
||||
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
|
||||
return NextResponse.json({ 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 });
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { 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 (frequency && !['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency))
|
||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
|
||||
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, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
|
||||
return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
|
||||
|
||||
const entry = await createRecurringEntry({
|
||||
groupId,
|
||||
@ -68,17 +69,19 @@ export async function POST(req: Request) {
|
||||
purchaseType,
|
||||
notes: notes || undefined,
|
||||
tags,
|
||||
frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null,
|
||||
isRecurring: true,
|
||||
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
|
||||
intervalCount,
|
||||
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
||||
endCount,
|
||||
endDate
|
||||
endDate,
|
||||
nextRunAt,
|
||||
bucketId
|
||||
});
|
||||
|
||||
return NextResponse.json({ requestId, request_id: requestId, entry });
|
||||
return NextResponse.json({ requestId, entry });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/recurring-entries", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,96 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@ -1,84 +0,0 @@
|
||||
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 { name } = await params;
|
||||
await deleteTagForGroup({ userId: user.id, groupId, name: decodeURIComponent(name) });
|
||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
||||
return NextResponse.json({ requestId, ok: true });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "DELETE /api/tags/[name]", requestId);
|
||||
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 { requireActiveGroup } from "@/lib/server/entries";
|
||||
import { ensureTagsForGroup, listGroupTags } from "@/lib/server/tags";
|
||||
@ -11,7 +11,7 @@ export async function GET() {
|
||||
const user = await requireSessionUser();
|
||||
const groupId = await requireActiveGroup(user.id);
|
||||
const tags = await listGroupTags(groupId);
|
||||
return NextResponse.json({ requestId, request_id: requestId, tags });
|
||||
return NextResponse.json({ requestId, tags });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "GET /api/tags", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
@ -27,10 +27,9 @@ export async function POST(req: Request) {
|
||||
const tags = Array.isArray(body?.tags) ? body.tags.map((tag: unknown) => String(tag)) : [];
|
||||
await ensureTagsForGroup({ userId: user.id, groupId, tags });
|
||||
const list = await listGroupTags(groupId);
|
||||
return NextResponse.json({ requestId, request_id: requestId, tags: list });
|
||||
return NextResponse.json({ requestId, tags: list });
|
||||
} catch (e) {
|
||||
const { status, body } = toErrorResponse(e, "POST /api/tags", requestId);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
14
apps/web/app/groups/[id]/settings/page.tsx
Normal file
14
apps/web/app/groups/[id]/settings/page.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSessionUser } from "@/lib/server/session";
|
||||
import GroupSettingsContent from "@/components/group-settings-content";
|
||||
|
||||
export default async function GroupSettingsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const { id } = await params;
|
||||
const groupId = Number(id || 0);
|
||||
if (!groupId) redirect("/");
|
||||
|
||||
return <GroupSettingsContent groupId={groupId} />;
|
||||
}
|
||||
@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useAuthContext } from "@/hooks/auth-context";
|
||||
import useInviteLink from "@/features/groups/hooks/use-invite-link";
|
||||
import useInviteLink from "@/hooks/use-invite-link";
|
||||
|
||||
export default function InvitePage() {
|
||||
const params = useParams();
|
||||
@ -154,7 +154,7 @@ export default function InvitePage() {
|
||||
<div className="card-title">Invite details</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="text-sm text-muted">Loading invite...</div>
|
||||
<div className="text-sm text-muted">Loading invite…</div>
|
||||
) : error ? (
|
||||
<div className="text-sm text-red-400">{error}</div>
|
||||
) : link ? (
|
||||
@ -203,7 +203,7 @@ export default function InvitePage() {
|
||||
<div className="card-title">Join this group</div>
|
||||
</div>
|
||||
{checkingSession ? (
|
||||
<div className="text-sm text-muted">Checking session...</div>
|
||||
<div className="text-sm text-muted">Checking session…</div>
|
||||
) : !hasSession ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-muted">Sign in to accept this invite.</div>
|
||||
@ -244,7 +244,7 @@ export default function InvitePage() {
|
||||
disabled={!link || Boolean(result) || accepting}
|
||||
onClick={accept}
|
||||
>
|
||||
{accepting ? "Joining..." : actionLabel}
|
||||
{accepting ? "Joining…" : actionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
@ -280,4 +280,3 @@ export default function InvitePage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
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 />;
|
||||
}
|
||||
151
apps/web/components/bucket-card.tsx
Normal file
151
apps/web/components/bucket-card.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import { Bucket } from "@/lib/server/buckets";
|
||||
import React from "react";
|
||||
|
||||
|
||||
type BucketCardProps = {
|
||||
bucket: Bucket;
|
||||
|
||||
icon?: string | null;
|
||||
|
||||
isExpanded: boolean;
|
||||
toggleExpanded: (bucketId: number) => void;
|
||||
|
||||
isMenuOpen: boolean;
|
||||
setMenuOpenId: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
|
||||
setConfirmDeleteId: (bucketId: number) => void;
|
||||
openEdit: (bucketId: number) => void;
|
||||
|
||||
limit: number;
|
||||
|
||||
usageLabel: string;
|
||||
renderUsageBar: (bucket: Bucket) => React.ReactNode;
|
||||
};
|
||||
|
||||
export function BucketCard({
|
||||
bucket,
|
||||
icon,
|
||||
isExpanded,
|
||||
toggleExpanded,
|
||||
isMenuOpen,
|
||||
setMenuOpenId,
|
||||
setConfirmDeleteId,
|
||||
openEdit,
|
||||
limit,
|
||||
usageLabel,
|
||||
renderUsageBar,
|
||||
}: BucketCardProps) {
|
||||
return (
|
||||
<div
|
||||
className="w-full max-w-[360px] rounded-lg border border-accent-weak bg-panel px-3 py-3 transition hover:border-accent"
|
||||
onClick={() => toggleExpanded(bucket.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full border border-accent-weak bg-surface text-lg">
|
||||
{icon || "🚫"}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{bucket.name}</div>
|
||||
{bucket.description ? (
|
||||
<div className={`text-xs text-soft ${isExpanded ? "" : "truncate"}`}>
|
||||
{bucket.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative" data-bucket-menu>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setMenuOpenId((prev) => (prev === bucket.id ? null : bucket.id));
|
||||
}}
|
||||
aria-label="Bucket actions"
|
||||
data-bucket-menu-button
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
|
||||
{isMenuOpen ? (
|
||||
<div className="absolute right-0 mt-2 w-40 rounded-lg border border-accent-weak bg-panel p-1 text-xs shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-md px-2 py-1 text-left hover:bg-accent-soft"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openEdit(bucket.id);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-md px-2 py-1 text-left text-red-200 hover:bg-red-500/10"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setConfirmDeleteId(bucket.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{limit > 0 ? (
|
||||
<>
|
||||
{renderUsageBar(bucket)}
|
||||
{isExpanded ? (
|
||||
<div className="mt-2 space-y-2 text-xs text-soft">
|
||||
<div>{usageLabel}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{bucket.tags?.length ? (
|
||||
bucket.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[11px] text-soft">No tags</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : isExpanded ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-soft">
|
||||
{bucket.tags?.length ? (
|
||||
bucket.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[11px] text-soft">No tags</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(BucketCard, (prev, next) => (
|
||||
prev.bucket === next.bucket
|
||||
&& prev.icon === next.icon
|
||||
&& prev.isExpanded === next.isExpanded
|
||||
&& prev.isMenuOpen === next.isMenuOpen
|
||||
&& prev.limit === next.limit
|
||||
&& prev.usageLabel === next.usageLabel
|
||||
));
|
||||
@ -2,24 +2,24 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useGroupsContext } from "@/hooks/groups-context";
|
||||
import useBuckets from "@/features/buckets/hooks/use-buckets";
|
||||
import useTags from "@/features/tags/hooks/use-tags";
|
||||
import useBuckets from "@/hooks/use-buckets";
|
||||
import useTags from "@/hooks/use-tags";
|
||||
import NewBucketModal from "@/components/new-bucket-modal";
|
||||
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
||||
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
||||
import BucketCard from "./bucket-card";
|
||||
import { useEntryMutation } from "@/hooks/entry-mutation-context";
|
||||
import { useNotificationsContext } from "@/hooks/notifications-context";
|
||||
|
||||
export default function BucketsPanel() {
|
||||
const { activeGroupId } = useGroupsContext();
|
||||
const { buckets, loading, error, createBucket, updateBucket, deleteBucket, reload } = useBuckets(activeGroupId);
|
||||
const { mutationVersion } = useEntryMutation();
|
||||
const { notify } = useNotificationsContext();
|
||||
const { tags: tagSuggestions } = useTags(activeGroupId);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
|
||||
const [menuOpenId, setMenuOpenId] = useState<number | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<number[]>([]);
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
@ -33,6 +33,18 @@ export default function BucketsPanel() {
|
||||
const iconMap = useMemo(() => new Map(bucketIcons.map(item => [item.key, item.icon])), []);
|
||||
const orderedBuckets = useMemo(() => [...buckets].sort((a, b) => a.position - b.position || a.name.localeCompare(b.name)), [buckets]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (!menuOpenId) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
if (target.closest("[data-bucket-menu]") || target.closest("[data-bucket-menu-button]")) return;
|
||||
setMenuOpenId(null);
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [menuOpenId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeGroupId) return;
|
||||
if (mutationVersion === 0) return;
|
||||
@ -63,6 +75,7 @@ export default function BucketsPanel() {
|
||||
windowDays: bucket.windowDays ? String(bucket.windowDays) : "30"
|
||||
});
|
||||
setModalOpen(true);
|
||||
setMenuOpenId(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
@ -84,10 +97,7 @@ export default function BucketsPanel() {
|
||||
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||
windowDays
|
||||
});
|
||||
if (ok) {
|
||||
notify({ title: "Bucket updated", message: form.name.trim(), tone: "success" });
|
||||
setModalOpen(false);
|
||||
}
|
||||
if (ok) setModalOpen(false);
|
||||
} else {
|
||||
const ok = await createBucket({
|
||||
name: form.name.trim(),
|
||||
@ -98,17 +108,40 @@ export default function BucketsPanel() {
|
||||
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||
windowDays
|
||||
});
|
||||
if (ok) {
|
||||
notify({ title: "Bucket created", message: form.name.trim(), tone: "success" });
|
||||
setModalOpen(false);
|
||||
}
|
||||
if (ok) setModalOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpanded(bucketId: number) {
|
||||
setExpandedIds(prev => prev.includes(bucketId)
|
||||
? prev.filter(id => id !== bucketId)
|
||||
: [...prev, bucketId]);
|
||||
}
|
||||
|
||||
function budgetUsage(bucket: typeof buckets[number]) {
|
||||
const limit = bucket.budgetLimitDollars || 0;
|
||||
const spent = bucket.totalUsage || 0;
|
||||
return { limit, spent };
|
||||
const pct = limit > 0 ? (spent / limit) * 100 : 0;
|
||||
return { limit, spent, pct };
|
||||
}
|
||||
|
||||
function renderUsageBar(bucket: typeof buckets[number]) {
|
||||
const { limit, spent, pct } = budgetUsage(bucket);
|
||||
if (!limit) return null;
|
||||
const clamped = Math.max(0, pct);
|
||||
const overage = Math.max(0, clamped - 100);
|
||||
const overColor = clamped > 200 ? "bg-red-500" : "bg-yellow-400";
|
||||
const tone = overage > 0 ? overColor : clamped >= 80 ? "bg-yellow-400" : "bg-green-400";
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="h-2 w-full rounded-full bg-surface">
|
||||
<div
|
||||
className={`h-2 rounded-full ${tone}`}
|
||||
style={{ width: `${Math.min(100, clamped)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -126,7 +159,7 @@ export default function BucketsPanel() {
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(260px,1fr))]">
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
|
||||
|
||||
{!activeGroupId ? (
|
||||
@ -148,14 +181,21 @@ export default function BucketsPanel() {
|
||||
orderedBuckets.map(bucket => {
|
||||
const icon = bucket.iconKey ? iconMap.get(bucket.iconKey) : null;
|
||||
const { limit, spent } = budgetUsage(bucket);
|
||||
const isExpanded = expandedIds.includes(bucket.id);
|
||||
const usageLabel = limit ? `$${spent.toFixed(2)} / $${limit.toFixed(2)}` : "";
|
||||
return <BucketCard
|
||||
key={bucket.id}
|
||||
bucket={bucket}
|
||||
icon={icon}
|
||||
isExpanded={isExpanded}
|
||||
toggleExpanded={toggleExpanded}
|
||||
isMenuOpen={menuOpenId === bucket.id}
|
||||
setMenuOpenId={setMenuOpenId}
|
||||
setConfirmDeleteId={setConfirmDeleteId}
|
||||
openEdit={openEdit}
|
||||
limit={limit}
|
||||
usageLabel={usageLabel}
|
||||
renderUsageBar={renderUsageBar}
|
||||
/>
|
||||
})
|
||||
) : (
|
||||
@ -172,11 +212,6 @@ export default function BucketsPanel() {
|
||||
onSubmit={handleSubmit}
|
||||
onChange={next => setForm(prev => ({ ...prev, ...next }))}
|
||||
tagSuggestions={tagSuggestions}
|
||||
canDelete={Boolean(editId)}
|
||||
onDelete={() => {
|
||||
if (!editId) return;
|
||||
setConfirmDeleteId(editId);
|
||||
}}
|
||||
/>
|
||||
<ConfirmSlideModal
|
||||
isOpen={Boolean(confirmDeleteId)}
|
||||
@ -186,21 +221,10 @@ export default function BucketsPanel() {
|
||||
onClose={() => setConfirmDeleteId(null)}
|
||||
onConfirm={async () => {
|
||||
if (!confirmDeleteId) return;
|
||||
const deletedBucket = buckets.find(bucket => bucket.id === confirmDeleteId) || null;
|
||||
const ok = await deleteBucket(confirmDeleteId);
|
||||
if (ok) {
|
||||
notify({
|
||||
title: "Bucket deleted",
|
||||
message: deletedBucket?.name || "Bucket removed",
|
||||
tone: "danger"
|
||||
});
|
||||
setConfirmDeleteId(null);
|
||||
setModalOpen(false);
|
||||
resetForm();
|
||||
}
|
||||
if (ok) setConfirmDeleteId(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,92 +0,0 @@
|
||||
"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,60 +20,23 @@ export default function ConfirmSlideModal({
|
||||
onConfirm
|
||||
}: ConfirmSlideModalProps) {
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const endFlashTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reachedEndRef = useRef(false);
|
||||
const [dragX, setDragX] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
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);
|
||||
}
|
||||
}
|
||||
const handleSize = 44;
|
||||
|
||||
function handlePointerDown(event: React.PointerEvent<HTMLButtonElement>) {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
reachedEndRef.current = false;
|
||||
setIsAtEnd(false);
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function handlePointerMove(event: React.PointerEvent<HTMLButtonElement>) {
|
||||
if (!dragging) return;
|
||||
const next = getDragPositionFromClientX(event.clientX);
|
||||
const nextAtEnd = isEndPosition(next);
|
||||
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
const rect = track.getBoundingClientRect();
|
||||
const next = Math.min(Math.max(0, event.clientX - rect.left - handleSize / 2), rect.width - handleSize);
|
||||
setDragX(next);
|
||||
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>) {
|
||||
@ -82,35 +45,14 @@ export default function ConfirmSlideModal({
|
||||
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
const releaseX = getDragPositionFromClientX(event.clientX);
|
||||
const releaseAtEnd = isEndPosition(releaseX);
|
||||
|
||||
setIsAtEnd(prev => (prev ? false : prev));
|
||||
|
||||
if (releaseAtEnd && !reachedEndRef.current) {
|
||||
triggerEndFeedback();
|
||||
}
|
||||
|
||||
if (releaseAtEnd) {
|
||||
const threshold = (track.clientWidth - handleSize) * 0.8;
|
||||
if (dragX >= threshold) {
|
||||
setDragX(0);
|
||||
onConfirm();
|
||||
} else {
|
||||
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(() => {
|
||||
if (!isOpen) return;
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
@ -131,26 +73,20 @@ export default function ConfirmSlideModal({
|
||||
<div className="text-xs text-soft">Slide to confirm</div>
|
||||
<div
|
||||
ref={trackRef}
|
||||
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"}`}
|
||||
className="mt-2 h-11 rounded-full border border-accent-weak bg-surface relative overflow-hidden touch-none select-none"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-accent-soft"
|
||||
className="absolute inset-y-0 left-0 bg-accent-soft rounded-full"
|
||||
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
|
||||
type="button"
|
||||
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"}`}
|
||||
className="absolute top-0 left-0 h-11 w-11 rounded-full border border-accent bg-panel text-xl font-semibold text-[color:var(--color-text)] touch-none select-none leading-none"
|
||||
style={{ transform: `translateX(${dragX}px)` }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onPointerCancel={handlePointerUp}
|
||||
aria-label="Slide to confirm"
|
||||
>
|
||||
▸
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useGroupsContext } from "@/hooks/groups-context";
|
||||
import EntriesPanel from "@/features/entries/components/entries-panel";
|
||||
import BucketsPanel from "@/features/buckets/components/buckets-panel";
|
||||
import EntriesPanel from "@/components/entries-panel";
|
||||
import BucketsPanel from "@/components/buckets-panel";
|
||||
import { EntryMutationProvider } from "@/hooks/entry-mutation-context";
|
||||
|
||||
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
785
apps/web/components/entries-panel.tsx
Normal file
785
apps/web/components/entries-panel.tsx
Normal file
@ -0,0 +1,785 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useEntries from "@/hooks/use-entries";
|
||||
import { useGroupsContext } from "@/hooks/groups-context";
|
||||
import NewEntryModal from "@/components/new-entry-modal";
|
||||
import EntryDetailsModal from "@/components/entry-details-modal";
|
||||
import { useNotificationsContext } from "@/hooks/notifications-context";
|
||||
import useTags from "@/hooks/use-tags";
|
||||
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
||||
import useGroupSettings from "@/hooks/use-group-settings";
|
||||
import TagInput from "@/components/tag-input";
|
||||
import { emitEntryMutated } from "@/lib/client/entry-mutation-events";
|
||||
|
||||
export default function EntriesPanel() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const { groups, activeGroupId } = useGroupsContext();
|
||||
const router = useRouter();
|
||||
const { entries, loading, error, createEntry, updateEntry, deleteEntry } = useEntries(activeGroupId);
|
||||
const { notify } = useNotificationsContext();
|
||||
const { tags: tagSuggestions } = useTags(activeGroupId);
|
||||
const { settings } = useGroupSettings(activeGroupId);
|
||||
const activeGroup = groups.find(group => group.id === activeGroupId) || null;
|
||||
const canManageTags = Boolean(activeGroup && (activeGroup.role !== "MEMBER" || settings.allowMemberTagManage));
|
||||
const emptyTagActionLabel = canManageTags
|
||||
? "No Tags Assigned Yet - Click To Assign Tags"
|
||||
: "No Tags Assigned Yet - Contact Your Group Admin";
|
||||
const [form, setForm] = useState({
|
||||
amountDollars: "",
|
||||
occurredAt: today,
|
||||
necessity: "NECESSARY",
|
||||
notes: "",
|
||||
tags: [] as string[],
|
||||
entryType: "SPENDING" as "SPENDING" | "INCOME",
|
||||
isRecurring: false,
|
||||
frequency: "MONTHLY" as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY",
|
||||
intervalCount: 1,
|
||||
endCondition: "NEVER" as "NEVER" | "AFTER_COUNT" | "BY_DATE",
|
||||
endCount: "",
|
||||
endDate: ""
|
||||
});
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [detailsForm, setDetailsForm] = useState({
|
||||
amountDollars: "",
|
||||
occurredAt: today,
|
||||
necessity: "NECESSARY",
|
||||
notes: "",
|
||||
tags: [] as string[],
|
||||
entryType: "SPENDING" as "SPENDING" | "INCOME",
|
||||
isRecurring: false,
|
||||
frequency: "MONTHLY" as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY",
|
||||
intervalCount: 1,
|
||||
endCondition: "NEVER" as "NEVER" | "AFTER_COUNT" | "BY_DATE",
|
||||
endCount: "",
|
||||
endDate: ""
|
||||
});
|
||||
const [detailsOriginal, setDetailsOriginal] = useState<typeof detailsForm | null>(null);
|
||||
const [removedTags, setRemovedTags] = useState<string[]>([]);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [discardOpen, setDiscardOpen] = useState(false);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [entryTab, setEntryTab] = useState<"SINGLE" | "RECURRING">("SINGLE");
|
||||
const emptyFilters = {
|
||||
amountMin: "",
|
||||
amountMax: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
necessity: "ANY",
|
||||
notesQuery: "",
|
||||
tags: [] as string[],
|
||||
tagsMode: "ANY" as "ANY" | "ALL"
|
||||
};
|
||||
const [filters, setFilters] = useState(emptyFilters);
|
||||
const amountInputRef = useRef<HTMLInputElement>(null);
|
||||
const tagsInputRef = useRef<HTMLInputElement>(null);
|
||||
const pendingDiscardRef = useRef<
|
||||
| { type: "close" }
|
||||
| { type: "prev" }
|
||||
| { type: "next" }
|
||||
| { type: "open"; entry: typeof entries[number]; index: number }
|
||||
| null
|
||||
>(null);
|
||||
const filteredEntries = useMemo(() => {
|
||||
if (!entries.length) return entries;
|
||||
const min = filters.amountMin ? Number(filters.amountMin) : null;
|
||||
const max = filters.amountMax ? Number(filters.amountMax) : null;
|
||||
const from = filters.dateFrom ? new Date(filters.dateFrom).getTime() : null;
|
||||
const to = filters.dateTo ? new Date(filters.dateTo).getTime() : null;
|
||||
const query = filters.notesQuery.trim().toLowerCase();
|
||||
const tagsFilter = filters.tags.map(tag => tag.toLowerCase());
|
||||
return entries.filter(entry => {
|
||||
if (min != null && entry.amountDollars < min) return false;
|
||||
if (max != null && entry.amountDollars > max) return false;
|
||||
if (from != null) {
|
||||
const time = new Date(entry.occurredAt).getTime();
|
||||
if (!Number.isNaN(from) && time < from) return false;
|
||||
}
|
||||
if (to != null) {
|
||||
const time = new Date(entry.occurredAt).getTime();
|
||||
if (!Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false;
|
||||
}
|
||||
if (filters.necessity !== "ANY" && entry.necessity !== filters.necessity) return false;
|
||||
if (query) {
|
||||
const notes = (entry.notes || "").toLowerCase();
|
||||
if (!notes.includes(query)) return false;
|
||||
}
|
||||
if (tagsFilter.length) {
|
||||
const entryTags = (entry.tags || []).map(tag => tag.toLowerCase());
|
||||
if (filters.tagsMode === "ALL") {
|
||||
if (!tagsFilter.every(tag => entryTags.includes(tag))) return false;
|
||||
} else if (!tagsFilter.some(tag => entryTags.includes(tag))) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [entries, filters]);
|
||||
const visibleEntries = useMemo(() => filteredEntries.filter(entry => entry.isRecurring === (entryTab === "RECURRING")), [filteredEntries, entryTab]);
|
||||
const totalEntries = visibleEntries.length;
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (filters.amountMin) count += 1;
|
||||
if (filters.amountMax) count += 1;
|
||||
if (filters.dateFrom) count += 1;
|
||||
if (filters.dateTo) count += 1;
|
||||
if (filters.necessity !== "ANY") count += 1;
|
||||
if (filters.notesQuery.trim()) count += 1;
|
||||
if (filters.tags.length) count += 1;
|
||||
return count;
|
||||
}, [filters]);
|
||||
|
||||
function handleEmptyTagAction() {
|
||||
if (!activeGroupId || !canManageTags) return;
|
||||
router.push("/groups/settings");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (tagsInputRef.current)
|
||||
tagsInputRef.current.setCustomValidity(form.tags.length ? "" : "Please fill out this field");
|
||||
}, [form.tags.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filterOpen && !discardOpen) return;
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
if (discardOpen) handleCancelDiscard();
|
||||
if (filterOpen) setFilterOpen(false);
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [discardOpen, filterOpen]);
|
||||
|
||||
async function handleCreate(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (tagsInputRef.current)
|
||||
tagsInputRef.current.setCustomValidity(form.tags.length ? "" : "Please fill out this field");
|
||||
if (!e.currentTarget.reportValidity()) {
|
||||
if (!form.amountDollars) amountInputRef.current?.focus();
|
||||
else if (!form.tags.length) tagsInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
const amountDollars = Number(form.amountDollars || 0);
|
||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0) {
|
||||
return;
|
||||
}
|
||||
if (!form.occurredAt) {
|
||||
return;
|
||||
}
|
||||
if (!form.tags.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const purchaseType = form.tags.join(", ") || "General";
|
||||
|
||||
const nextRunAt = form.isRecurring ? form.occurredAt : null;
|
||||
const createdEntry = await createEntry({
|
||||
entryType: form.entryType,
|
||||
amountDollars,
|
||||
occurredAt: form.occurredAt,
|
||||
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||
purchaseType,
|
||||
notes: form.notes.trim() || undefined,
|
||||
tags: form.tags,
|
||||
isRecurring: form.isRecurring,
|
||||
frequency: form.isRecurring ? form.frequency : null,
|
||||
intervalCount: form.intervalCount,
|
||||
endCondition: form.isRecurring ? form.endCondition : null,
|
||||
endCount: form.endCondition === "AFTER_COUNT" ? Number(form.endCount || 0) || null : null,
|
||||
endDate: form.endCondition === "BY_DATE" ? form.endDate || null : null,
|
||||
nextRunAt
|
||||
});
|
||||
if (createdEntry) {
|
||||
setForm({
|
||||
amountDollars: "",
|
||||
occurredAt: today,
|
||||
necessity: "NECESSARY",
|
||||
notes: "",
|
||||
tags: [],
|
||||
entryType: "SPENDING",
|
||||
isRecurring: false,
|
||||
frequency: "MONTHLY",
|
||||
intervalCount: 1,
|
||||
endCondition: "NEVER",
|
||||
endCount: "",
|
||||
endDate: ""
|
||||
});
|
||||
setIsModalOpen(false);
|
||||
notify({
|
||||
title: "Entry added",
|
||||
message: `${form.tags.join(", ")} · $${amountDollars.toFixed(2)}`,
|
||||
tone: "success"
|
||||
});
|
||||
emitEntryMutated({ before: null, after: createdEntry });
|
||||
}
|
||||
}
|
||||
|
||||
function setDetailsFromEntry(entry: typeof entries[number], index: number) {
|
||||
const nextId = Number(entry.id);
|
||||
if (!Number.isFinite(nextId) || nextId <= 0) {
|
||||
alert("Invalid entry id");
|
||||
return;
|
||||
}
|
||||
const nextForm = {
|
||||
amountDollars: String(entry.amountDollars),
|
||||
occurredAt: new Date(entry.occurredAt).toISOString().slice(0, 10),
|
||||
necessity: entry.necessity,
|
||||
notes: entry.notes || "",
|
||||
tags: entry.tags || [],
|
||||
entryType: entry.entryType,
|
||||
isRecurring: entry.isRecurring,
|
||||
frequency: entry.frequency || "MONTHLY",
|
||||
intervalCount: entry.intervalCount || 1,
|
||||
endCondition: entry.endCondition || "NEVER",
|
||||
endCount: entry.endCount ? String(entry.endCount) : "",
|
||||
endDate: entry.endDate || ""
|
||||
};
|
||||
setSelectedId(nextId);
|
||||
setSelectedIndex(index);
|
||||
setRemovedTags([]);
|
||||
setDetailsForm(nextForm);
|
||||
setDetailsOriginal(nextForm);
|
||||
setDetailsOpen(true);
|
||||
}
|
||||
|
||||
function handleOpenDetails(entry: typeof entries[number], index: number) {
|
||||
requestDiscard({ type: "open", entry, index });
|
||||
}
|
||||
|
||||
function normalizeTags(tags: string[]) {
|
||||
return tags.map(tag => tag.toLowerCase()).sort();
|
||||
}
|
||||
|
||||
function handleFilterAddTag(tag: string) {
|
||||
setFilters(prev => (prev.tags.includes(tag) ? prev : { ...prev, tags: [...prev.tags, tag] }));
|
||||
}
|
||||
|
||||
function handleFilterToggleTag(tag: string) {
|
||||
setFilters(prev => ({ ...prev, tags: prev.tags.filter(item => item !== tag) }));
|
||||
}
|
||||
|
||||
function handleClearFilters() {
|
||||
setFilters(emptyFilters);
|
||||
}
|
||||
function hasDetailsChanges() {
|
||||
if (!detailsOriginal) return false;
|
||||
const currentTags = detailsForm.tags.filter(tag => !removedTags.includes(tag));
|
||||
const currentTagsKey = normalizeTags(currentTags).join("|");
|
||||
const originalTagsKey = normalizeTags(detailsOriginal.tags).join("|");
|
||||
return (
|
||||
detailsForm.amountDollars !== detailsOriginal.amountDollars ||
|
||||
detailsForm.occurredAt !== detailsOriginal.occurredAt ||
|
||||
detailsForm.necessity !== detailsOriginal.necessity ||
|
||||
detailsForm.notes !== detailsOriginal.notes ||
|
||||
detailsForm.entryType !== detailsOriginal.entryType ||
|
||||
detailsForm.isRecurring !== detailsOriginal.isRecurring ||
|
||||
detailsForm.frequency !== detailsOriginal.frequency ||
|
||||
detailsForm.intervalCount !== detailsOriginal.intervalCount ||
|
||||
detailsForm.endCondition !== detailsOriginal.endCondition ||
|
||||
detailsForm.endCount !== detailsOriginal.endCount ||
|
||||
detailsForm.endDate !== detailsOriginal.endDate ||
|
||||
currentTagsKey !== originalTagsKey
|
||||
);
|
||||
}
|
||||
|
||||
function requestDiscard(action: NonNullable<typeof pendingDiscardRef.current>) {
|
||||
if (!detailsOpen || !hasDetailsChanges()) {
|
||||
runDiscardAction(action);
|
||||
return;
|
||||
}
|
||||
pendingDiscardRef.current = action;
|
||||
setDiscardOpen(true);
|
||||
}
|
||||
|
||||
function runDiscardAction(action: NonNullable<typeof pendingDiscardRef.current>) {
|
||||
if (action.type === "close") {
|
||||
setDetailsOpen(false);
|
||||
setDetailsOriginal(null);
|
||||
setRemovedTags([]);
|
||||
return;
|
||||
}
|
||||
if (action.type === "open") {
|
||||
setDetailsFromEntry(action.entry, action.index);
|
||||
return;
|
||||
}
|
||||
if (!totalEntries) return;
|
||||
const current = selectedIndex ?? 0;
|
||||
if (action.type === "prev") {
|
||||
const nextIndex = current === 0 ? totalEntries - 1 : current - 1;
|
||||
setDetailsFromEntry(visibleEntries[nextIndex], nextIndex);
|
||||
}
|
||||
if (action.type === "next") {
|
||||
const nextIndex = current === totalEntries - 1 ? 0 : current + 1;
|
||||
setDetailsFromEntry(visibleEntries[nextIndex], nextIndex);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!selectedId) return;
|
||||
if (!hasDetailsChanges()) return;
|
||||
|
||||
const amountDollars = Number(detailsForm.amountDollars || 0);
|
||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0) {
|
||||
alert("Enter a valid amount");
|
||||
return;
|
||||
}
|
||||
if (!detailsForm.occurredAt) {
|
||||
alert("Select a date");
|
||||
return;
|
||||
}
|
||||
const nextTags = detailsForm.tags.filter(tag => !removedTags.includes(tag));
|
||||
if (!nextTags.length) {
|
||||
alert("Add at least one tag");
|
||||
return;
|
||||
}
|
||||
|
||||
const purchaseType = nextTags.join(", ") || "General";
|
||||
|
||||
const nextRunAt = detailsForm.isRecurring ? detailsForm.occurredAt : null;
|
||||
const beforeEntry = entries.find(entry => Number(entry.id) === Number(selectedId)) || null;
|
||||
const updatedEntry = await updateEntry({
|
||||
id: selectedId,
|
||||
entryType: detailsForm.entryType,
|
||||
amountDollars,
|
||||
occurredAt: detailsForm.occurredAt,
|
||||
necessity: detailsForm.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||
purchaseType,
|
||||
notes: detailsForm.notes.trim() || undefined,
|
||||
tags: nextTags,
|
||||
isRecurring: detailsForm.isRecurring,
|
||||
frequency: detailsForm.isRecurring ? detailsForm.frequency : null,
|
||||
intervalCount: detailsForm.intervalCount,
|
||||
endCondition: detailsForm.isRecurring ? detailsForm.endCondition : null,
|
||||
endCount: detailsForm.endCondition === "AFTER_COUNT" ? Number(detailsForm.endCount || 0) || null : null,
|
||||
endDate: detailsForm.endCondition === "BY_DATE" ? detailsForm.endDate || null : null,
|
||||
nextRunAt
|
||||
});
|
||||
if (updatedEntry) {
|
||||
setDetailsOpen(false);
|
||||
setDetailsOriginal(null);
|
||||
setRemovedTags([]);
|
||||
notify({
|
||||
title: "Entry updated",
|
||||
message: `${nextTags.join(", ")} · $${amountDollars.toFixed(2)}`
|
||||
});
|
||||
emitEntryMutated({ before: beforeEntry, after: updatedEntry });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selectedId || !Number.isFinite(selectedId)) return;
|
||||
const beforeEntry = entries.find(entry => Number(entry.id) === Number(selectedId)) || null;
|
||||
const deletedEntry = await deleteEntry(selectedId);
|
||||
if (deletedEntry || beforeEntry) {
|
||||
setDetailsOpen(false);
|
||||
setDetailsOriginal(null);
|
||||
setRemovedTags([]);
|
||||
notify({
|
||||
title: "Entry deleted",
|
||||
message: detailsForm.tags.join(", ") || "Entry removed",
|
||||
tone: "danger"
|
||||
});
|
||||
emitEntryMutated({ before: deletedEntry || beforeEntry, after: null });
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleTag(tag: string) {
|
||||
setRemovedTags(prev => prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag]);
|
||||
}
|
||||
|
||||
function handleAddTag(tag: string) {
|
||||
setDetailsForm(prev => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags : [...prev.tags, tag] }));
|
||||
setRemovedTags(prev => prev.filter(item => item !== tag));
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
if (!totalEntries) return;
|
||||
requestDiscard({ type: "prev" });
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (!totalEntries) return;
|
||||
requestDiscard({ type: "next" });
|
||||
}
|
||||
|
||||
function handleCloseDetails() {
|
||||
requestDiscard({ type: "close" });
|
||||
}
|
||||
|
||||
function handleConfirmDiscard() {
|
||||
const action = pendingDiscardRef.current;
|
||||
pendingDiscardRef.current = null;
|
||||
setDiscardOpen(false);
|
||||
if (action) runDiscardAction(action);
|
||||
}
|
||||
|
||||
function handleCancelDiscard() {
|
||||
pendingDiscardRef.current = null;
|
||||
setDiscardOpen(false);
|
||||
}
|
||||
|
||||
function handleRevertDetails() {
|
||||
if (!detailsOriginal) return;
|
||||
setDetailsForm(detailsOriginal);
|
||||
setRemovedTags([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="panel panel-accent p-4">
|
||||
<div className="card-header">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="card-title text-lg">Entries</h2>
|
||||
<div className="flex items-center gap-0 rounded-full border border-accent-weak bg-panel">
|
||||
<button
|
||||
type="button"
|
||||
className={`mr-[-10px] w-20 rounded-full px-3 py-2 text-xs font-semibold ${entryTab === "SINGLE" ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => setEntryTab(prev => prev === "SINGLE" ? "RECURRING" : "SINGLE")}
|
||||
>
|
||||
Existing
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full w-20 px-3 py-2 text-xs font-semibold ${entryTab === "RECURRING" ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => setEntryTab(prev => prev === "RECURRING" ? "SINGLE" : "RECURRING")}
|
||||
>
|
||||
Scheduled
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterOpen(true)}
|
||||
className="rounded-lg btn-outline-accent px-3 py-1 text-xs font-semibold disabled:opacity-50"
|
||||
disabled={!activeGroupId}
|
||||
>
|
||||
Filters{activeFilterCount ? ` (${activeFilterCount})` : ""}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex h-8 w-8 -translate-y-0.5 items-center justify-center rounded-full border border-accent bg-panel text-lg font-semibold leading-none hover:border-accent-strong disabled:opacity-50"
|
||||
disabled={!activeGroupId}
|
||||
aria-label="Add entry"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{!activeGroupId ? (
|
||||
<div className="text-sm text-muted">Select a group to view entries.</div>
|
||||
) : loading ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map(row => (
|
||||
<div key={row} className="rounded-lg border border-accent-weak bg-panel px-3 py-3">
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-4 w-28 rounded bg-surface" />
|
||||
<div className="h-3 w-40 rounded bg-surface" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="h-5 w-14 rounded-full bg-surface" />
|
||||
<div className="h-5 w-12 rounded-full bg-surface" />
|
||||
<div className="h-5 w-16 rounded-full bg-surface" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : entries.length ? (
|
||||
visibleEntries.length ? (
|
||||
visibleEntries.map((entry, index) => {
|
||||
const tags = entry.tags ?? [];
|
||||
const mobileTagLimit = 2;
|
||||
const mobileTags = tags.slice(0, mobileTagLimit);
|
||||
const extraTagCount = Math.max(tags.length - mobileTagLimit, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm transition hover:border-accent hover:bg-accent-soft"
|
||||
onClick={() => handleOpenDetails(entry, index)}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-base font-semibold">${entry.amountDollars.toFixed(2)}</div>
|
||||
<div className="text-xs text-muted">
|
||||
{new Date(entry.occurredAt).toISOString().slice(0, 10)} · {entry.necessity}
|
||||
</div>
|
||||
</div>
|
||||
{tags.length ? (
|
||||
<>
|
||||
<div className="flex flex-wrap justify-end gap-2 md:hidden">
|
||||
{mobileTags.map(tag => (
|
||||
<span key={tag} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{extraTagCount ? (
|
||||
<span className="rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-xs text-soft">
|
||||
{extraTagCount} more...
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="hidden flex-wrap justify-end gap-2 md:flex">
|
||||
{tags.map(tag => (
|
||||
<span key={tag} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="rounded-full border border-accent-weak px-2 py-0.5 text-xs text-soft">No tags</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="space-y-2 text-sm text-muted">
|
||||
<div>No matching entries.</div>
|
||||
{activeFilterCount ? (
|
||||
<button type="button" className="rounded-lg btn-outline-accent px-3 py-1 text-xs" onClick={handleClearFilters}>
|
||||
Clear filters
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-sm text-muted">No entries yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NewEntryModal
|
||||
isOpen={isModalOpen && Boolean(activeGroupId)}
|
||||
form={form}
|
||||
error={error}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSubmit={handleCreate}
|
||||
onChange={next => setForm(prev => ({ ...prev, ...next }))}
|
||||
tagSuggestions={tagSuggestions}
|
||||
emptyTagActionLabel={emptyTagActionLabel}
|
||||
emptyTagActionDisabled={!canManageTags}
|
||||
onEmptyTagAction={handleEmptyTagAction}
|
||||
amountInputRef={amountInputRef}
|
||||
tagsInputRef={tagsInputRef}
|
||||
/>
|
||||
<EntryDetailsModal
|
||||
isOpen={detailsOpen}
|
||||
form={detailsForm}
|
||||
originalForm={detailsOriginal}
|
||||
isDirty={hasDetailsChanges()}
|
||||
error={error}
|
||||
onClose={handleCloseDetails}
|
||||
onSubmit={handleUpdate}
|
||||
onRequestDelete={() => setConfirmDeleteOpen(true)}
|
||||
onRevert={handleRevertDetails}
|
||||
onChange={next => setDetailsForm(prev => ({ ...prev, ...next }))}
|
||||
onAddTag={handleAddTag}
|
||||
onToggleTag={handleToggleTag}
|
||||
removedTags={removedTags}
|
||||
tagSuggestions={tagSuggestions}
|
||||
emptyTagActionLabel={emptyTagActionLabel}
|
||||
emptyTagActionDisabled={!canManageTags}
|
||||
onEmptyTagAction={handleEmptyTagAction}
|
||||
onPrev={handlePrev}
|
||||
onNext={handleNext}
|
||||
loopHintPrev={selectedIndex === 0 && totalEntries > 1 ? "Loop" : ""}
|
||||
loopHintNext={selectedIndex === totalEntries - 1 && totalEntries > 1 ? "Loop" : ""}
|
||||
canNavigate={totalEntries > 1}
|
||||
/>
|
||||
{filterOpen ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={() => setFilterOpen(false)}>
|
||||
<div
|
||||
className="w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
|
||||
onClick={event => event.stopPropagation()}
|
||||
onKeyDown={event => {
|
||||
if (event.key === "Escape") setFilterOpen(false);
|
||||
}}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Filter Entries</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterOpen(false)}
|
||||
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<label className="text-sm text-muted md:col-span-2">
|
||||
Amount Range
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
className="w-full input-base px-3 py-2 text-sm"
|
||||
value={filters.amountMin}
|
||||
placeholder="none"
|
||||
onChange={e => setFilters(prev => ({ ...prev, amountMin: e.target.value }))}
|
||||
/>
|
||||
<span className="text-xs text-soft">-</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
className="w-full input-base px-3 py-2 text-sm"
|
||||
value={filters.amountMax}
|
||||
placeholder="none"
|
||||
onChange={e => setFilters(prev => ({ ...prev, amountMax: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label className="text-sm text-muted md:col-span-2">
|
||||
Date Range
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
className="no-date-icon w-full input-base px-3 py-2 text-sm"
|
||||
value={filters.dateFrom}
|
||||
placeholder="none"
|
||||
onChange={e => setFilters(prev => ({ ...prev, dateFrom: e.target.value }))}
|
||||
/>
|
||||
<span className="text-xs text-soft">-</span>
|
||||
<input
|
||||
type="date"
|
||||
className="no-date-icon w-full input-base px-3 py-2 text-sm"
|
||||
value={filters.dateTo}
|
||||
placeholder="none"
|
||||
onChange={e => setFilters(prev => ({ ...prev, dateTo: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div className="text-sm text-muted">
|
||||
<div className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel" role="group" aria-label="Necessity">
|
||||
{([
|
||||
{ value: "ANY", label: "Any" },
|
||||
{ value: "NECESSARY", label: "Necessary" },
|
||||
{ value: "BOTH", label: "Both" },
|
||||
{ value: "UNNECESSARY", label: "Unnecessary" }
|
||||
] as const).map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-2 text-xs font-semibold ${filters.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => setFilters(prev => ({ ...prev, necessity: prev.necessity === option.value ? "ANY" : option.value }))}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="text-sm text-muted">
|
||||
Notes contains
|
||||
<input
|
||||
type="text"
|
||||
className="mt-1 w-full input-base px-3 py-2 text-sm"
|
||||
value={filters.notesQuery}
|
||||
onChange={e => setFilters(prev => ({ ...prev, notesQuery: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
<TagInput
|
||||
label="Tags"
|
||||
labelAction={
|
||||
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-2 text-xs font-semibold ${filters.tagsMode === "ANY" ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => setFilters(prev => ({ ...prev, tagsMode: prev.tagsMode === "ANY" ? "ALL" : "ANY" }))}
|
||||
>
|
||||
Any
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-2 text-xs font-semibold ${filters.tagsMode === "ALL" ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => setFilters(prev => ({ ...prev, tagsMode: prev.tagsMode === "ALL" ? "ANY" : "ALL" }))}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
tags={filters.tags}
|
||||
suggestions={tagSuggestions}
|
||||
allowCustom={false}
|
||||
onToggleTag={handleFilterToggleTag}
|
||||
onAddTag={handleFilterAddTag}
|
||||
emptySuggestionLabel={emptyTagActionLabel}
|
||||
emptySuggestionDisabled={!canManageTags}
|
||||
onEmptySuggestionClick={handleEmptyTagAction}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="text-xs text-soft">
|
||||
{activeFilterCount ? `${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"} applied` : "No filters applied"}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" className="rounded-lg btn-outline-accent px-3 py-2 text-xs" onClick={handleClearFilters}>
|
||||
Clear Filters
|
||||
</button>
|
||||
<button type="button" className="rounded-lg btn-accent px-3 py-2 text-xs" onClick={() => setFilterOpen(false)}>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{discardOpen ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div
|
||||
className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5 shadow-xl"
|
||||
onKeyDown={event => {
|
||||
if (event.key === "Escape") handleCancelDiscard();
|
||||
}}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="text-lg font-semibold">Discard changes?</div>
|
||||
<p className="mt-2 text-sm text-muted">You have unsaved changes. Do you want to discard them?</p>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
|
||||
onClick={handleCancelDiscard}
|
||||
>
|
||||
Keep editing
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-sm font-semibold text-red-200"
|
||||
onClick={handleConfirmDiscard}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<ConfirmSlideModal
|
||||
isOpen={confirmDeleteOpen}
|
||||
title="Delete entry"
|
||||
description="This will permanently remove the entry and its tags."
|
||||
confirmLabel="Delete entry"
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={() => {
|
||||
setConfirmDeleteOpen(false);
|
||||
handleDelete();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -3,8 +3,6 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import TagInput from "@/components/tag-input";
|
||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||
import DatePicker from "@/components/date-picker";
|
||||
|
||||
export type EntryDetailsForm = {
|
||||
amountDollars: string;
|
||||
@ -13,6 +11,12 @@ export type EntryDetailsForm = {
|
||||
notes: string;
|
||||
tags: string[];
|
||||
entryType: "SPENDING" | "INCOME";
|
||||
isRecurring: boolean;
|
||||
frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY";
|
||||
intervalCount: number;
|
||||
endCondition: "NEVER" | "AFTER_COUNT" | "BY_DATE";
|
||||
endCount: string;
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
type EntryDetailsModalProps = {
|
||||
@ -77,6 +81,7 @@ export default function EntryDetailsModal({
|
||||
const notesChanged = form.notes !== baseline.notes;
|
||||
const changedInputClass = "border-2 border-[color:var(--color-accent)]";
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const touchDeltaX = useRef(0);
|
||||
|
||||
@ -112,7 +117,10 @@ export default function EntryDetailsModal({
|
||||
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="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
|
||||
onClick={event => event.stopPropagation()}
|
||||
@ -121,14 +129,26 @@ export default function EntryDetailsModal({
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div className="grid grid-cols-3 items-center gap-3">
|
||||
<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">
|
||||
<span aria-hidden>{loopHintPrev ? "o" : "<"}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrev}
|
||||
className="flex w-24 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50"
|
||||
disabled={!canNavigate}
|
||||
aria-label="Previous entry"
|
||||
>
|
||||
<span aria-hidden>{loopHintPrev ? "↺" : "←"}</span>
|
||||
<span className="text-xs text-soft">{loopHintPrev ? "To Last" : "Prev"}</span>
|
||||
</button>
|
||||
<h2 className="text-center text-lg font-semibold">Entry Details</h2>
|
||||
<button type="button" onClick={onNext} className="ml-auto flex w-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">
|
||||
<h2 className="text-center text-lg font-semibold">Entry details</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
className="ml-auto flex w-24 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50"
|
||||
disabled={!canNavigate}
|
||||
aria-label="Next entry"
|
||||
>
|
||||
<span className="text-xs text-soft">{loopHintNext ? "To First" : "Next"}</span>
|
||||
<span aria-hidden>{loopHintNext ? "o" : ">"}</span>
|
||||
<span aria-hidden>{loopHintNext ? "↻" : "→"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
@ -144,16 +164,31 @@ export default function EntryDetailsModal({
|
||||
className="mt-3 grid gap-3 md:grid-cols-2"
|
||||
>
|
||||
<div className="md:col-span-2 flex flex-wrap 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" }
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full border text-lg ${form.isRecurring ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
|
||||
onClick={() => onChange({ isRecurring: !form.isRecurring })}
|
||||
title="Toggle Recurring Entry"
|
||||
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
|
||||
>
|
||||
<span aria-hidden>⟳</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 rounded-full border border-accent-weak bg-panel">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-4 py-2.5 text-xs font-semibold ${form.entryType === "SPENDING" ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ entryType: form.entryType === "SPENDING" ? "INCOME" : "SPENDING" })}
|
||||
>
|
||||
Spending
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-4 py-2.5 text-xs font-semibold ${form.entryType === "INCOME" ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ entryType: form.entryType === "INCOME" ? "SPENDING" : "INCOME" })}
|
||||
>
|
||||
Income
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label className="text-sm text-muted">
|
||||
Amount ($)
|
||||
@ -169,27 +204,32 @@ export default function EntryDetailsModal({
|
||||
/>
|
||||
</label>
|
||||
<div className="text-sm text-muted">
|
||||
<DatePicker
|
||||
<input
|
||||
name="occurredAt"
|
||||
type="date"
|
||||
className={`no-date-icon mt-6 w-full input-base px-3 py-2 text-sm ${dateChanged ? changedInputClass : ""} ${form.occurredAt ? "" : "border-red-400/70"}`}
|
||||
value={form.occurredAt}
|
||||
onChange={occurredAt => onChange({ occurredAt })}
|
||||
onChange={e => onChange({ occurredAt: e.target.value })}
|
||||
required
|
||||
className={`mt-1 ${dateChanged ? changedInputClass : ""}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-muted">
|
||||
<ToggleButtonGroup
|
||||
value={form.necessity}
|
||||
onChange={necessity => onChange({ necessity })}
|
||||
ariaLabel="Necessity"
|
||||
className={`flex items-center gap-2 rounded-full border border-accent-weak bg-panel ${necessityChanged ? changedInputClass : ""}`}
|
||||
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 className={`mt-6 flex items-center gap-2 rounded-full border border-accent-weak bg-panel ${necessityChanged ? changedInputClass : ""}`} role="group" aria-label="Necessity">
|
||||
{([
|
||||
{ value: "NECESSARY", label: "Necessary" },
|
||||
{ value: "BOTH", label: "Both" },
|
||||
{ value: "UNNECESSARY", label: "Unnecessary" }
|
||||
] as const).map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ necessity: option.value })}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<TagInput
|
||||
label="Tags"
|
||||
@ -206,6 +246,80 @@ export default function EntryDetailsModal({
|
||||
onEmptySuggestionClick={onEmptyTagAction}
|
||||
invalid={!currentTags.length}
|
||||
/>
|
||||
{form.isRecurring ? (
|
||||
<div className="md:col-span-2">
|
||||
<div className="text-sm text-muted">Frequency Conditions</div>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-20 input-base px-3 py-2 text-center text-sm"
|
||||
value={form.intervalCount}
|
||||
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
|
||||
/>
|
||||
<select
|
||||
className="min-w-[120px] input-base px-3 py-2 text-center text-sm"
|
||||
value={form.frequency}
|
||||
onChange={e => onChange({ frequency: e.target.value as EntryDetailsForm["frequency"] })}
|
||||
>
|
||||
<option value="DAILY">day(s)</option>
|
||||
<option value="WEEKLY">week(s)</option>
|
||||
<option value="BIWEEKLY">biweekly</option>
|
||||
<option value="MONTHLY">month(s)</option>
|
||||
<option value="QUARTERLY">quarter(s)</option>
|
||||
<option value="YEARLY">year(s)</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel" role="group" aria-label="End condition">
|
||||
{([
|
||||
{ value: "NEVER", label: "Forever" },
|
||||
{ value: "BY_DATE", label: "Until" },
|
||||
{ value: "AFTER_COUNT", label: "After" }
|
||||
] as const).map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-2 text-xs font-semibold ${form.endCondition === option.value ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ endCondition: option.value })}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{form.endCondition === "AFTER_COUNT" ? (
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-24 input-base px-3 py-2 text-center text-sm"
|
||||
value={form.endCount}
|
||||
placeholder="Count"
|
||||
onChange={e => onChange({ endCount: e.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
{form.endCondition === "BY_DATE" ? (
|
||||
<div className={`inline-flex items-center overflow-hidden rounded-full border ${form.endDate ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
|
||||
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
|
||||
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
|
||||
if (Number.isNaN(base.getTime())) return;
|
||||
base.setDate(base.getDate() - 1);
|
||||
onChange({ endDate: base.toISOString().slice(0, 10) });
|
||||
}}>‹</button>
|
||||
<input
|
||||
type="date"
|
||||
className="no-date-icon h-11 w-40 bg-transparent px-3 text-center text-sm text-[color:var(--color-text)] outline-none"
|
||||
value={form.endDate}
|
||||
onChange={e => onChange({ endDate: e.target.value })}
|
||||
/>
|
||||
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
|
||||
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
|
||||
if (Number.isNaN(base.getTime())) return;
|
||||
base.setDate(base.getDate() + 1);
|
||||
onChange({ endDate: base.toISOString().slice(0, 10) });
|
||||
}}>›</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<label className="text-sm text-muted md:col-span-2">
|
||||
Notes
|
||||
<textarea
|
||||
@ -218,7 +332,7 @@ export default function EntryDetailsModal({
|
||||
/>
|
||||
</label>
|
||||
<div className="md:col-span-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRevert}
|
||||
@ -226,16 +340,41 @@ export default function EntryDetailsModal({
|
||||
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"
|
||||
>
|
||||
R
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
||||
<path d="M3 4v4h4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold disabled:opacity-35 disabled:cursor-not-allowed" disabled={!isDirty}>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold disabled:opacity-35 disabled:cursor-not-allowed disabled:grayscale disabled:shadow-none"
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Save changes
|
||||
</button>
|
||||
<button type="button" className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold" onClick={onRequestDelete}>
|
||||
<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" aria-label="Close">
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="right rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold"
|
||||
aria-label="Close"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -2,18 +2,16 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useTags from "@/features/tags/hooks/use-tags";
|
||||
import useGroupSettings from "@/features/groups/hooks/use-group-settings";
|
||||
import useGroupMembers from "@/features/groups/hooks/use-group-members";
|
||||
import useGroupInvites from "@/features/groups/hooks/use-group-invites";
|
||||
import useGroupAudit from "@/features/groups/hooks/use-group-audit";
|
||||
import useTags from "@/hooks/use-tags";
|
||||
import useGroupSettings from "@/hooks/use-group-settings";
|
||||
import useGroupMembers from "@/hooks/use-group-members";
|
||||
import useGroupInvites from "@/hooks/use-group-invites";
|
||||
import useGroupAudit from "@/hooks/use-group-audit";
|
||||
import { useGroupsContext } from "@/hooks/groups-context";
|
||||
import TagInput from "@/components/tag-input";
|
||||
import { useNotificationsContext } from "@/hooks/notifications-context";
|
||||
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
||||
import ConfirmRetypeModal from "@/components/confirm-retype-modal";
|
||||
import { groupsDelete, groupsRename } from "@/lib/client/groups";
|
||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||
|
||||
export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
||||
const router = useRouter();
|
||||
@ -269,24 +267,6 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
||||
return { userId: top, count: topCount, name, searchValue };
|
||||
})();
|
||||
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() {
|
||||
const result = await groupsDelete();
|
||||
@ -308,9 +288,30 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
const renameDirty = renameValue.trim() !== group.name;
|
||||
const visibleTags = showAllTags ? tags : tags.slice(0, 5);
|
||||
const hasMoreTags = tags.length > 5;
|
||||
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 (
|
||||
<>
|
||||
@ -424,21 +425,23 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold">Join policy</div>
|
||||
<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={[
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ value: "NOT_ACCEPTING", label: "Disabled" },
|
||||
{ value: "AUTO_ACCEPT", label: "Auto" },
|
||||
{ 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 className="divider" />
|
||||
@ -515,55 +518,42 @@ 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"}`}
|
||||
>
|
||||
<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>
|
||||
{(() => {
|
||||
const showRevive = link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt);
|
||||
const showRevoke = !showRevive && !link.revokedAt && !(link.singleUse && link.usedAt) && !isInviteExpired(link.expiresAt);
|
||||
const options = [
|
||||
{
|
||||
value: "COPY",
|
||||
label: "Copy link",
|
||||
className: "btn-outline-accent",
|
||||
disabled: localJoinPolicy === "NOT_ACCEPTING",
|
||||
onClick: () => handleCopyInvite(link.token)
|
||||
},
|
||||
...(showRevive
|
||||
? [{
|
||||
value: "REVIVE",
|
||||
label: "Revive",
|
||||
className: "btn-outline-accent",
|
||||
disabled: localJoinPolicy === "NOT_ACCEPTING",
|
||||
onClick: () => reviveInvite(link.id, inviteTtlDays)
|
||||
}]
|
||||
: showRevoke
|
||||
? [{
|
||||
value: "REVOKE",
|
||||
label: "Revoke",
|
||||
className: "border border-red-400/60 bg-red-500/10 text-red-200",
|
||||
onClick: () => revokeInvite(link.id)
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
value: "DELETE",
|
||||
label: "Delete",
|
||||
className: "border border-red-400/60 bg-red-500/10 text-red-200",
|
||||
onClick: () => setConfirmDeleteInvite({ id: link.id, token: link.token })
|
||||
}
|
||||
];
|
||||
|
||||
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 className="text-xs text-soft">Token: {link.token.slice(0, 6)}…{link.token.slice(-4)}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
|
||||
onClick={() => handleCopyInvite(link.token)}
|
||||
disabled={localJoinPolicy === "NOT_ACCEPTING"}
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
{(link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt)) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
|
||||
onClick={() => reviveInvite(link.id, inviteTtlDays)}
|
||||
disabled={localJoinPolicy === "NOT_ACCEPTING"}
|
||||
>
|
||||
Revive
|
||||
</button>
|
||||
) : (!link.revokedAt && !(link.singleUse && link.usedAt) && !isInviteExpired(link.expiresAt)) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-red-400/60 bg-red-500/10 px-2 py-1 text-xs text-red-200"
|
||||
onClick={() => revokeInvite(link.id)}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-red-400/60 bg-red-500/10 px-2 py-1 text-xs text-red-200"
|
||||
onClick={() => setConfirmDeleteInvite({ id: link.id, token: link.token })}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-[11px] text-soft">
|
||||
<span>Expires {formatInviteExpiry(link.expiresAt)}</span>
|
||||
@ -587,10 +577,10 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
||||
{members.map(member => {
|
||||
const isSelf = member.userId === currentUserId;
|
||||
const privilegeLabel = member.role === "GROUP_OWNER"
|
||||
? "Owner - Full control"
|
||||
? "👑 Owner · Full control"
|
||||
: member.role === "GROUP_ADMIN"
|
||||
? "Admin - Manage members"
|
||||
: "Member - Entries only";
|
||||
? "🛡️ Admin · Manage members"
|
||||
: "👤 Member · Entries only";
|
||||
return (
|
||||
<div
|
||||
key={member.userId}
|
||||
@ -816,7 +806,7 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
||||
onClick={handleCloseRenameModal}
|
||||
aria-label="Close"
|
||||
>
|
||||
x
|
||||
✕
|
||||
</button>
|
||||
<div className="text-lg font-semibold">Change group name</div>
|
||||
<input
|
||||
@ -877,7 +867,9 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<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">x</button>
|
||||
<button type="button" className="rounded-lg btn-outline-accent px-2 py-1 text-sm" onClick={() => setTagModalOpen(false)} aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<TagInput
|
||||
@ -946,7 +938,7 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
||||
<ConfirmSlideModal
|
||||
isOpen={Boolean(confirmDeleteInvite)}
|
||||
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"
|
||||
onClose={() => setConfirmDeleteInvite(null)}
|
||||
onConfirm={async () => {
|
||||
@ -1008,17 +1000,46 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
||||
if (ok) notify({ title: "Ownership transferred", message: target.name });
|
||||
}}
|
||||
/>
|
||||
<ConfirmRetypeModal
|
||||
isOpen={confirmDeleteGroupOpen}
|
||||
title="Delete group"
|
||||
description="Type DELETE to confirm. This cannot be undone."
|
||||
expectedText="DELETE"
|
||||
value={deleteConfirmText}
|
||||
onChange={setDeleteConfirmText}
|
||||
confirmLabel="Delete"
|
||||
onClose={() => setConfirmDeleteGroupOpen(false)}
|
||||
onConfirm={handleDeleteGroup}
|
||||
/>
|
||||
{confirmDeleteGroupOpen ? (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={() => setConfirmDeleteGroupOpen(false)}>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5"
|
||||
onClick={event => event.stopPropagation()}
|
||||
onKeyDown={event => {
|
||||
if (event.key === "Escape") 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}
|
||||
onChange={e => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="DELETE"
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -127,7 +127,8 @@ export default function Navbar() {
|
||||
className="w-full rounded-lg btn-outline-accent px-3 py-2 text-xs font-semibold"
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
router.push("/settings");
|
||||
if (activeGroupId) router.push("/groups/settings");
|
||||
else router.push("/");
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
|
||||
@ -4,7 +4,6 @@ import type React from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import TagInput from "@/components/tag-input";
|
||||
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||
|
||||
type BucketForm = {
|
||||
name: string;
|
||||
@ -25,11 +24,9 @@ type NewBucketModalProps = {
|
||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
onChange: (next: Partial<BucketForm>) => void;
|
||||
tagSuggestions: string[];
|
||||
canDelete?: boolean;
|
||||
onDelete?: () => void;
|
||||
};
|
||||
|
||||
export default function NewBucketModal({ isOpen, title, form, error, onClose, onSubmit, onChange, tagSuggestions, canDelete = false, onDelete }: NewBucketModalProps) {
|
||||
export default function NewBucketModal({ isOpen, title, form, error, onClose, onSubmit, onChange, tagSuggestions }: NewBucketModalProps) {
|
||||
const [iconModalOpen, setIconModalOpen] = useState(false);
|
||||
const [iconSearch, setIconSearch] = useState("");
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
@ -128,18 +125,22 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on
|
||||
/>
|
||||
</label>
|
||||
<div className="text-sm text-muted md:col-span-2">
|
||||
<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 className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel" role="group" aria-label="Necessity">
|
||||
{([
|
||||
{ value: "NECESSARY", label: "Necessary" },
|
||||
{ value: "BOTH", label: "Both" },
|
||||
{ value: "UNNECESSARY", label: "Unnecessary" }
|
||||
] as const).map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ necessity: option.value })}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="text-sm text-muted md:col-span-2">
|
||||
Description
|
||||
@ -159,22 +160,11 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on
|
||||
onToggleTag={tag => onChange({ tags: form.tags.filter(item => item !== tag) })}
|
||||
onAddTag={tag => onChange({ tags: [...form.tags, tag] })}
|
||||
/>
|
||||
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
||||
<div className="md:col-span-2 flex items-center justify-between">
|
||||
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold">
|
||||
Save bucket
|
||||
</button>
|
||||
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold">
|
||||
Save bucket
|
||||
</button>
|
||||
{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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -3,8 +3,6 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import TagInput from "@/components/tag-input";
|
||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||
import DatePicker from "@/components/date-picker";
|
||||
|
||||
type NewEntryForm = {
|
||||
amountDollars: string;
|
||||
@ -13,6 +11,12 @@ type NewEntryForm = {
|
||||
notes: string;
|
||||
tags: string[];
|
||||
entryType: "SPENDING" | "INCOME";
|
||||
isRecurring: boolean;
|
||||
frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY";
|
||||
intervalCount: number;
|
||||
endCondition: "NEVER" | "AFTER_COUNT" | "BY_DATE";
|
||||
endCount: string;
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
type NewEntryModalProps = {
|
||||
@ -31,6 +35,7 @@ type 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 formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
@ -42,12 +47,23 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
||||
|
||||
if (!form.occurredAt) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
onChange({ occurredAt: today });
|
||||
onChange({ occurredAt: today, endDate: form.endDate || today });
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [form.occurredAt, isOpen, onChange, onClose]);
|
||||
}, [form.endDate, form.occurredAt, isOpen, onChange, onClose]);
|
||||
|
||||
|
||||
|
||||
function shiftDate(days: number) {
|
||||
const base = form.occurredAt ? new Date(form.occurredAt) : new Date();
|
||||
if (Number.isNaN(base.getTime())) return;
|
||||
base.setDate(base.getDate() + days);
|
||||
onChange({ occurredAt: base.toISOString().slice(0, 10) });
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@ -61,28 +77,44 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">New {typeLabel} Entry</h2>
|
||||
<h2 className="text-lg font-semibold">New {recurrenceLabel} {typeLabel} Entry</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
||||
aria-label="Close"
|
||||
>
|
||||
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" }
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full mr-[-10px] px-4 py-2.5 text-xs font-semibold ${form.entryType === "SPENDING" ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ entryType: form.entryType === "SPENDING" ? "INCOME" : "SPENDING" })}
|
||||
>
|
||||
Spending
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-4 py-2.5 text-xs font-semibold ${form.entryType === "INCOME" ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ entryType: form.entryType === "INCOME" ? "SPENDING" : "INCOME" })}
|
||||
>
|
||||
Income
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full border text-lg ${form.isRecurring ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
|
||||
onClick={() => onChange({ isRecurring: !form.isRecurring })}
|
||||
title="Toggle Recurring Entry"
|
||||
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
|
||||
>
|
||||
<span aria-hidden>⟳</span>
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
ref={formRef}
|
||||
@ -97,9 +129,10 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
||||
className="mt-3 grid gap-3 md:grid-cols-2"
|
||||
>
|
||||
<label className="text-sm text-muted">
|
||||
{/* Amount ($) */}
|
||||
<div className="relative mt-1">
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-soft">
|
||||
{form.entryType === "INCOME" ? "$" : "$"}
|
||||
{form.entryType === "INCOME" ? "🤑 $" : "😭 $"}
|
||||
</span>
|
||||
<input
|
||||
ref={amountInputRef}
|
||||
@ -115,28 +148,41 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
||||
</div>
|
||||
</label>
|
||||
<div className="text-sm text-muted">
|
||||
<DatePicker
|
||||
name="occurredAt"
|
||||
value={form.occurredAt}
|
||||
onChange={occurredAt => onChange({ occurredAt })}
|
||||
required
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className={`mt-1 inline-flex w-full items-center overflow-hidden rounded-full border ${form.occurredAt ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
|
||||
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(-7)}>«</button>
|
||||
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(-1)}>‹</button>
|
||||
<input
|
||||
name="occurredAt"
|
||||
type="date"
|
||||
className="no-date-icon h-11 w-40 bg-transparent px-3 text-sm text-[color:var(--color-text)] outline-none"
|
||||
value={form.occurredAt}
|
||||
onChange={e => onChange({ occurredAt: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(1)}>›</button>
|
||||
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(7)}>»</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted">
|
||||
<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 className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel" role="group" aria-label="Necessity">
|
||||
{([
|
||||
{ value: "NECESSARY", label: "Necessary" },
|
||||
{ value: "BOTH", label: "Both" },
|
||||
{ value: "UNNECESSARY", label: "Unnecessary" }
|
||||
] as const).map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ necessity: option.value })}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TAGS */}
|
||||
<TagInput
|
||||
label="Tags"
|
||||
tags={form.tags}
|
||||
@ -150,6 +196,80 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
||||
invalid={!form.tags.length}
|
||||
inputRef={tagsInputRef}
|
||||
/>
|
||||
|
||||
{/* RECURRING OPTIONS */}
|
||||
{form.isRecurring ? (
|
||||
<div className="md:col-span-2">
|
||||
<div className="text-sm text-muted">Frequency Conditions</div>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-14 input-base px-3 py-2 text-center text-sm"
|
||||
value={form.intervalCount}
|
||||
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
|
||||
/>
|
||||
<select
|
||||
className="w-20 min-w-[110px] input-base px-3 py-2 text-center text-sm"
|
||||
value={form.frequency}
|
||||
onChange={e => onChange({ frequency: e.target.value as NewEntryForm["frequency"] })}
|
||||
>
|
||||
<option value="DAILY">day(s)</option>
|
||||
<option value="WEEKLY">week(s)</option>
|
||||
<option value="MONTHLY">month(s)</option>
|
||||
<option value="YEARLY">year(s)</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel" role="group" aria-label="End condition">
|
||||
{([
|
||||
{ value: "NEVER", label: "Forever" },
|
||||
{ value: "BY_DATE", label: "Until" },
|
||||
{ value: "AFTER_COUNT", label: "After" }
|
||||
] as const).map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-3 text-xs font-semibold ${form.endCondition === option.value ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ endCondition: option.value })}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{form.endCondition === "AFTER_COUNT" ? (
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-24 input-base px-3 py-2 text-center text-sm"
|
||||
value={form.endCount}
|
||||
placeholder="Count"
|
||||
onChange={e => onChange({ endCount: e.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
{form.endCondition === "BY_DATE" ? (
|
||||
<div className={`inline-flex items-center overflow-hidden rounded-full border ${form.endDate ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
|
||||
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
|
||||
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
|
||||
if (Number.isNaN(base.getTime())) return;
|
||||
base.setDate(base.getDate() - 1);
|
||||
onChange({ endDate: base.toISOString().slice(0, 10) });
|
||||
}}>‹</button>
|
||||
<input
|
||||
type="date"
|
||||
className="no-date-icon h-11 w-40 bg-transparent px-3 text-center text-sm text-[color:var(--color-text)] outline-none"
|
||||
value={form.endDate}
|
||||
onChange={e => onChange({ endDate: e.target.value })}
|
||||
/>
|
||||
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
|
||||
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
|
||||
if (Number.isNaN(base.getTime())) return;
|
||||
base.setDate(base.getDate() + 1);
|
||||
onChange({ endDate: base.toISOString().slice(0, 10) });
|
||||
}}>›</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<label className="text-sm text-muted md:col-span-2">
|
||||
Notes
|
||||
<textarea
|
||||
|
||||
@ -1,226 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
73
apps/web/components/recurring-entries-panel.tsx
Normal file
73
apps/web/components/recurring-entries-panel.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import useEntries from "@/hooks/use-entries";
|
||||
import { useGroupsContext } from "@/hooks/groups-context";
|
||||
|
||||
function monthlyMultiplier(frequency: string, intervalCount: number) {
|
||||
const count = intervalCount || 1;
|
||||
switch (frequency) {
|
||||
case "DAILY":
|
||||
return (30 / count);
|
||||
case "WEEKLY":
|
||||
return (52 / 12) / count;
|
||||
case "BIWEEKLY":
|
||||
return (26 / 12) / count;
|
||||
case "MONTHLY":
|
||||
return (1 / count);
|
||||
case "QUARTERLY":
|
||||
return (1 / 3) / count;
|
||||
case "YEARLY":
|
||||
return (1 / 12) / count;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export default function RecurringEntriesPanel() {
|
||||
const { activeGroupId } = useGroupsContext();
|
||||
const { entries, loading } = useEntries(activeGroupId);
|
||||
|
||||
const recurring = useMemo(() => entries.filter(entry => entry.isRecurring), [entries]);
|
||||
|
||||
return (
|
||||
<div className="panel panel-accent p-4">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title text-lg">Recurring entries</h2>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{!activeGroupId ? (
|
||||
<div className="text-sm text-muted">Select a group to view recurring entries.</div>
|
||||
) : loading ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1].map(row => (
|
||||
<div key={row} className="rounded-lg border border-accent-weak bg-panel px-3 py-3">
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-4 w-28 rounded bg-surface" />
|
||||
<div className="h-3 w-40 rounded bg-surface" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : recurring.length ? (
|
||||
recurring.map(entry => {
|
||||
const monthly = entry.frequency ? monthlyMultiplier(entry.frequency, entry.intervalCount) * entry.amountDollars : 0;
|
||||
return (
|
||||
<div key={entry.id} className="rounded-lg border border-accent-weak bg-panel px-3 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold">${entry.amountDollars.toFixed(2)} · {entry.tags.join(", ") || "No tags"}</div>
|
||||
<div className="text-xs text-soft">{entry.entryType}</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-soft">
|
||||
Next run: {entry.nextRunAt || entry.occurredAt} · Monthly est: ${monthly.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-sm text-muted">No recurring entries yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,261 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
"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,6 +106,7 @@ export default function TagInput({ label, labelAction, tags, suggestions, remove
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (false && event.key === "Backspace" && !value && tags.length) {
|
||||
console.log("Backspace pressed with empty input, removing last tag");
|
||||
event.preventDefault();
|
||||
onToggleTag?.(tags[tags.length - 1]);
|
||||
return;
|
||||
|
||||
@ -1,77 +0,0 @@
|
||||
"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 }) => {
|
||||
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
||||
await expect(page).toHaveURL("/");
|
||||
await expect(page.getByRole("button", { name: "Entries" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Entries" })).toBeVisible();
|
||||
});
|
||||
|
||||
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 expect(page).toHaveURL("/");
|
||||
|
||||
const dropdown = page.getByRole("button", { name: /▼$/ });
|
||||
const dropdown = page.getByRole("button", { name: /Group:/ });
|
||||
await dropdown.click();
|
||||
|
||||
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 page.getByRole("button", { name: "Group settings" }).click();
|
||||
await expect(page).toHaveURL(/\/groups\/settings/);
|
||||
await expect(page).toHaveURL(/\/groups\/[0-9]+\/settings/);
|
||||
|
||||
await expect(page.getByText("Join requests")).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 expect(page).toHaveURL("/");
|
||||
|
||||
await expect(page.getByRole("button", { name: "Entries" })).toBeVisible();
|
||||
await expect(page.getByText("$12.50").first()).toBeVisible();
|
||||
await expect(page.locator("span:visible", { hasText: "#Food" }).first()).toBeVisible();
|
||||
await expect(page.locator("span:visible", { hasText: "#Travel" }).first()).toBeVisible();
|
||||
await expect(page.locator("span:visible", { hasText: "No tags" }).first()).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Entries" })).toBeVisible();
|
||||
await expect(page.getByText("$12.50")).toBeVisible();
|
||||
await expect(page.getByText("#Food")).toBeVisible();
|
||||
await expect(page.getByText("#Travel")).toBeVisible();
|
||||
await expect(page.getByText("No tags")).toBeVisible();
|
||||
});
|
||||
|
||||
test("entry details modal opens", async ({ page }) => {
|
||||
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
await page.getByText("$12.50").first().click();
|
||||
await page.getByText("$12.50").click();
|
||||
await expect(page.getByRole("heading", { name: "Entry details" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Entry details" })).toBeHidden();
|
||||
@ -26,14 +26,13 @@ test("empty tag callout shows contact admin for members", async ({ page }) => {
|
||||
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
const dropdown = page.getByRole("button", { name: /▼$/ });
|
||||
const dropdown = page.getByRole("button", { name: /Group:/ });
|
||||
await dropdown.click();
|
||||
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
||||
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
||||
await waitSetActive;
|
||||
|
||||
await page.getByRole("button", { name: "Add entry" }).click();
|
||||
await page.getByPlaceholder("Add tags... (who, where, why, etc.)").fill("missing");
|
||||
const callout = page.getByRole("button", { name: "No Tags Assigned Yet - Contact Your Group Admin" });
|
||||
await expect(callout).toBeVisible();
|
||||
await expect(callout).toBeDisabled();
|
||||
@ -43,14 +42,13 @@ test("empty tag callout navigates to settings for admins", async ({ page }) => {
|
||||
await login(page, "member1@fiddy.dev", "FiddyDev123!");
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
const dropdown = page.getByRole("button", { name: /▼$/ });
|
||||
const dropdown = page.getByRole("button", { name: /Group:/ });
|
||||
await dropdown.click();
|
||||
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
||||
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
||||
await waitSetActive;
|
||||
|
||||
await page.getByRole("button", { name: "Add entry" }).click();
|
||||
await page.getByPlaceholder("Add tags... (who, where, why, etc.)").fill("missing");
|
||||
await page.getByRole("button", { name: "No Tags Assigned Yet - Click To Assign Tags" }).click();
|
||||
await expect(page).toHaveURL(/\/groups\/settings/);
|
||||
await expect(page).toHaveURL(/\/groups\/[0-9]+\/settings/);
|
||||
});
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
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;
|
||||
@ -1,9 +0,0 @@
|
||||
# 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.
|
||||
@ -1,3 +0,0 @@
|
||||
# Auth Feature
|
||||
|
||||
Reserved for auth domain modules (components/hooks/lib) during incremental migration.
|
||||
@ -1,181 +0,0 @@
|
||||
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
|
||||
));
|
||||
@ -1,43 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -1,185 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -1,232 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -1,779 +0,0 @@
|
||||
"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();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,85 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -1,119 +0,0 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
# Groups Feature
|
||||
|
||||
Reserved for groups domain modules (components/hooks/lib) during incremental migration.
|
||||
@ -1,3 +0,0 @@
|
||||
# Tags Feature
|
||||
|
||||
Reserved for tags domain modules (components/hooks/lib) during incremental migration.
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import useAuth from "@/features/auth/hooks/use-auth";
|
||||
import useAuth from "@/hooks/use-auth";
|
||||
|
||||
const AuthContext = createContext<ReturnType<typeof useAuth> | null>(null);
|
||||
|
||||
@ -19,4 +19,3 @@ export function useAuthContext() {
|
||||
if (!ctx) throw new Error("AuthProvider is missing");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import useGroups from "@/features/groups/hooks/use-groups";
|
||||
import useGroups from "@/hooks/use-groups";
|
||||
|
||||
const GroupsContext = createContext<ReturnType<typeof useGroups> | null>(null);
|
||||
|
||||
@ -19,4 +19,3 @@ export function useGroupsContext() {
|
||||
if (!ctx) throw new Error("GroupsProvider is missing");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,13 @@ type CreateEntryInput = {
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
bucketId?: number | null;
|
||||
isRecurring?: boolean;
|
||||
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
||||
intervalCount?: number;
|
||||
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
||||
endCount?: number | null;
|
||||
endDate?: string | null;
|
||||
nextRunAt?: string | null;
|
||||
};
|
||||
|
||||
type UpdateEntryInput = CreateEntryInput & { id: number };
|
||||
@ -56,7 +63,7 @@ export default function useEntries(activeGroupId?: number | null) {
|
||||
setLoading(false);
|
||||
}, [activeGroupId]);
|
||||
|
||||
const createEntry = useCallback(async (input: CreateEntryInput): Promise<Entry | null> => {
|
||||
const createEntry = useCallback(async (input: CreateEntryInput) => {
|
||||
setError("");
|
||||
const result = await entriesCreate(input);
|
||||
if (isError(result)) {
|
||||
@ -68,7 +75,7 @@ export default function useEntries(activeGroupId?: number | null) {
|
||||
return created;
|
||||
}, []);
|
||||
|
||||
const updateEntry = useCallback(async (input: UpdateEntryInput): Promise<Entry | null> => {
|
||||
const updateEntry = useCallback(async (input: UpdateEntryInput) => {
|
||||
setError("");
|
||||
const result = await entriesUpdate(input);
|
||||
if (isError(result)) {
|
||||
@ -82,7 +89,7 @@ export default function useEntries(activeGroupId?: number | null) {
|
||||
return updated;
|
||||
}, []);
|
||||
|
||||
const deleteEntry = useCallback(async (id: number | string): Promise<Entry | null> => {
|
||||
const deleteEntry = useCallback(async (id: number | string) => {
|
||||
setError("");
|
||||
const numericId = Number(id);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) return null;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user