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)
|
# Copilot Instructions — Fiddy (External DB)
|
||||||
|
|
||||||
## Authority
|
## Source of truth
|
||||||
- **Source of truth:** `PROJECT_INSTRUCTIONS.md` (repo root). If conflict, follow it.
|
- Always consult PROJECT_INSTRUCTIONS.md at the repo root.
|
||||||
- **Bugfix work:** follow `DEBUGGING_INSTRUCTIONS.md` (repo root).
|
- If any guidance conflicts, PROJECT_INSTRUCTIONS.md takes precedence.
|
||||||
- Keep this file short: it’s a guide for Copilot behavior, not the full spec.
|
- 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
|
## Stack
|
||||||
- Make the **smallest change** that resolves the bug or request.
|
- Monorepo (npm workspaces)
|
||||||
- **Scan the repo first** for existing patterns (don’t invent files/endpoints unless necessary).
|
- Next.js (App Router) + TypeScript + Tailwind
|
||||||
- Respect layering: **route → server service → client wrapper → hook → UI**.
|
- External Postgres (on-prem server) via node-postgres (pg). No ORM.
|
||||||
- Keep diffs tight; avoid large refactors unless required.
|
- Docker Compose dev/prod
|
||||||
|
- Gitea + act-runner CI/CD
|
||||||
|
|
||||||
## Hard rules (do not violate)
|
## Environment
|
||||||
- External DB: `DATABASE_URL` points to on-prem Postgres (NOT a container).
|
- Dev and Prod must use the same schema/migrations (`packages/db/migrations`).
|
||||||
- No cron/worker jobs.
|
- `DATABASE_URL` points to the external DB server (NOT a container).
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Architecture quick map (follow existing patterns)
|
## Auth
|
||||||
- API routes: `app/api/**/route.ts` (thin parse/validate + call service)
|
- Custom email/password auth.
|
||||||
- Server services: `lib/server/*` (DB + authz, must include `import "server-only";`)
|
- Use HttpOnly session cookies backed by DB table `sessions`.
|
||||||
- Client wrappers: `lib/client/*` (typed fetch + error normalization, credentials included)
|
- NEVER trust client-side RBAC checks.
|
||||||
- Hooks: `hooks/use-*.ts` (UI-facing API layer; components avoid raw `fetch()`)
|
|
||||||
|
|
||||||
## API conventions
|
## Receipts
|
||||||
- Prefer error shape: `{ error: { code, message }, request_id? }`
|
- Store receipt images in Postgres `bytea` table `receipts`.
|
||||||
- Validate input at the route boundary; authorize in services.
|
- 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)
|
## UI
|
||||||
- In `app/api/**/[param]/route.ts`, treat `context.params` as async:
|
|
||||||
- `const { id } = await context.params;`
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
- When changing API behavior, add/update tests.
|
|
||||||
- Prefer including negative cases: unauthorized / not-a-member / invalid input.
|
|
||||||
|
|
||||||
## UI expectations
|
|
||||||
- Dark mode, minimal, mobile-first.
|
- Dark mode, minimal, mobile-first.
|
||||||
- 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)
|
# Project Instructions — Fiddy (External DB)
|
||||||
|
|
||||||
## 1) Core expectation
|
## Core expectation
|
||||||
This project connects to an **external Postgres instance (on-prem server)**. Dev and Prod must share the **same schema** through **migrations**.
|
This project connects to an external Postgres instance (on-prem server). Dev and Prod must share the same schema through migrations.
|
||||||
|
|
||||||
## 2) Authority & doc order
|
## Decisions / constraints (Group Settings)
|
||||||
1) **PROJECT_INSTRUCTIONS.md** (this file) is the source of truth.
|
|
||||||
2) **DEBUGGING_INSTRUCTIONS.md** (repo root) is required for bugfix work.
|
|
||||||
3) Other instruction files (e.g. `.github/copilot-instructions.md`) must not conflict with this doc.
|
|
||||||
|
|
||||||
If anything conflicts, follow **this** doc.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3) Non-negotiables (hard rules)
|
|
||||||
|
|
||||||
### External DB + migrations
|
|
||||||
- `DATABASE_URL` points to **on-prem Postgres** (**NOT** a container).
|
|
||||||
- Dev/Prod share schema via migrations in: `packages/db/migrations`.
|
|
||||||
- Active migration runbook: `docs/DB_MIGRATION_WORKFLOW.md` (active set + status commands).
|
|
||||||
|
|
||||||
### No background jobs
|
|
||||||
- **No cron/worker jobs**. Any fix must work without background tasks.
|
|
||||||
|
|
||||||
### Security / logging
|
|
||||||
- **Never log secrets** (passwords, tokens, session cookies).
|
|
||||||
- **Never log receipt bytes**.
|
|
||||||
- **Never log full invite codes** — logs/audit store **last4 only**.
|
|
||||||
|
|
||||||
### Server-side authorization only
|
|
||||||
- **Server-side RBAC only.** Client checks are UX only and must not be trusted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4) Non-regression contracts (do not break)
|
|
||||||
|
|
||||||
### Auth
|
|
||||||
- Custom email/password auth.
|
|
||||||
- Sessions are **DB-backed** and stored in table `sessions`.
|
|
||||||
- Session cookies are **HttpOnly**.
|
|
||||||
|
|
||||||
### Receipts
|
|
||||||
- Receipt images are stored in Postgres `bytea` table `receipts`.
|
|
||||||
- **Entries list endpoints must never return receipt image bytes.**
|
|
||||||
- Receipt bytes are fetched only via a **separate endpoint** when inspecting a single item.
|
|
||||||
|
|
||||||
### Request IDs + audit
|
|
||||||
- API must generate a **`request_id`** and return it in responses.
|
|
||||||
- Audit logs must include `request_id`.
|
|
||||||
- Audit logs must never store full invite codes (store **last4 only**).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5) Architecture contract (Backend ↔ Client ↔ Hooks ↔ UI)
|
|
||||||
|
|
||||||
### No-assumptions rule (required)
|
|
||||||
Before making structural changes, first scan the repo and identify:
|
|
||||||
- where `app/`, `components/`, `features/`, `hooks/`, `lib/` live
|
|
||||||
- existing API routes and helpers
|
|
||||||
- patterns already in use
|
|
||||||
Do not invent files/endpoints/conventions. If something is missing, add it **minimally** and **consistently**.
|
|
||||||
|
|
||||||
### Single mechanism rule (required)
|
|
||||||
For any cross-component state propagation concern, keep **one** canonical mechanism only:
|
|
||||||
- Context **OR** custom events **OR** cache invalidation
|
|
||||||
Do not keep old and new mechanisms in parallel. Remove superseded utilities/imports/files in the same PR.
|
|
||||||
|
|
||||||
### Layering (hard boundaries)
|
|
||||||
For every domain (auth, groups, entries, receipts, etc.) follow this flow:
|
|
||||||
|
|
||||||
1) **API Route Handlers** — `app/api/.../route.ts`
|
|
||||||
- Thin: parse/validate input, call a server service, return JSON.
|
|
||||||
- No direct DB queries in route files unless there is no existing server service.
|
|
||||||
|
|
||||||
2) **Server Services (DB + authorization)** — `lib/server/*`
|
|
||||||
- Own all DB access and authorization helpers.
|
|
||||||
- Server-only modules must include: `import "server-only";`
|
|
||||||
- Prefer small domain modules: `lib/server/auth.ts`, `lib/server/groups.ts`, `lib/server/entries.ts`, `lib/server/receipts.ts`, `lib/server/session.ts`.
|
|
||||||
|
|
||||||
3) **Client API Wrappers** — `lib/client/*`
|
|
||||||
- Typed fetch helpers only (no React state).
|
|
||||||
- Centralize fetch + error normalization.
|
|
||||||
- Always send credentials (cookies) and never trust client-side RBAC.
|
|
||||||
|
|
||||||
4) **Hooks (UI-facing API layer)** — `hooks/use-*.ts`
|
|
||||||
- Hooks are the primary interface for components/pages to call APIs.
|
|
||||||
- Components should not call `fetch()` directly unless there is a strong reason.
|
|
||||||
|
|
||||||
### API conventions
|
|
||||||
- Prefer consistent JSON error shape:
|
|
||||||
- `{ error: { code: string, message: string }, request_id?: string }`
|
|
||||||
- Validate inputs at the route boundary (shape/type), authorize in server services.
|
|
||||||
- Mirror existing REST style used in the project.
|
|
||||||
|
|
||||||
### Next.js route params checklist (required)
|
|
||||||
For `app/api/**/[param]/route.ts`:
|
|
||||||
- Treat `context.params` as **async** and `await` it before reading properties.
|
|
||||||
- Example: `const { id } = await context.params;`
|
|
||||||
|
|
||||||
### Frontend structure preference
|
|
||||||
- Prefer domain-first structure: `features/<domain>/...` + `shared/...`.
|
|
||||||
- Use `components/*` only for compatibility shims during migrations (remove them after imports are migrated).
|
|
||||||
|
|
||||||
### Maintainability thresholds (refactor triggers)
|
|
||||||
- Component files > **400 lines** should be split into container/presentational parts.
|
|
||||||
- Hook files > **150 lines** should extract helper functions/services.
|
|
||||||
- Functions with more than **3 nested branches** should be extracted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6) Decisions / constraints (Group Settings)
|
|
||||||
- Add `GROUP_OWNER` role to group roles; migrate existing groups so the first admin becomes owner.
|
- Add `GROUP_OWNER` role to group roles; migrate existing groups so the first admin becomes owner.
|
||||||
- Join policy default is `NOT_ACCEPTING`. Policies: `NOT_ACCEPTING`, `AUTO_ACCEPT`, `APPROVAL_REQUIRED`.
|
- Join policy default is `NOT_ACCEPTING`. Policies: `NOT_ACCEPTING`, `AUTO_ACCEPT`, `APPROVAL_REQUIRED`.
|
||||||
- Both owner and admins can approve join requests and manage invite links.
|
- Both owner and admins can approve join requests and manage invite links.
|
||||||
- Invite links:
|
- Invite links:
|
||||||
- TTL limited to 1–7 days.
|
- TTL limited to 1–7 days.
|
||||||
- Settings are immutable after creation (policy, single-use, etc.).
|
- Settings are immutable after creation (policy, single-use, etc.).
|
||||||
- Single-use does not override approval-required.
|
- Single-use does not override approval-required.
|
||||||
- Expired links are retained and can be revived.
|
- Expired links are retained and can be revived.
|
||||||
- Single-use links are deleted after successful use.
|
- Single-use links are deleted after successful use.
|
||||||
- Revive resets `used_at` and `revoked_at`, refreshes `expires_at`, and creates a new audit event.
|
- Revive resets `used_at` and `revoked_at`, refreshes `expires_at`, and creates a new audit event.
|
||||||
- No cron/worker jobs for now (auto ownership transfer and invite rotation are paused).
|
- No cron/worker jobs for now (auto ownership transfer and invite rotation are paused).
|
||||||
- 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).
|
||||||
|
|
||||||
---
|
## Do first (vertical slice)
|
||||||
|
|
||||||
## 7) Do first (vertical slice)
|
|
||||||
1) DB migrate command + schema
|
1) DB migrate command + schema
|
||||||
2) Register/Login/Logout (custom sessions)
|
2) Register/Login/Logout (custom sessions)
|
||||||
3) Protected dashboard page
|
3) Protected dashboard page
|
||||||
@ -133,57 +27,24 @@ For `app/api/**/[param]/route.ts`:
|
|||||||
6) Receipt upload/download endpoints
|
6) Receipt upload/download endpoints
|
||||||
7) Settings + Reports
|
7) Settings + Reports
|
||||||
|
|
||||||
---
|
## Definition of done
|
||||||
|
- Works via docker-compose.dev.yml with external DB
|
||||||
## 8) Definition of done
|
|
||||||
- Works via `docker-compose.dev.yml` with external DB
|
|
||||||
- Migrations applied via `npm run db:migrate`
|
- Migrations applied via `npm run db:migrate`
|
||||||
- Tests + lint pass
|
- Tests + lint pass
|
||||||
- RBAC enforced server-side
|
- RBAC enforced server-side
|
||||||
- No large files
|
- No large files
|
||||||
- No TypeScript warnings or lint errors in touched files
|
- No TypeScript warnings or lint errors in touched files
|
||||||
- No new cron/worker dependencies unless explicitly approved
|
- No new cron/worker dependencies unless explicitly approved
|
||||||
- No orphaned utilities/hooks/contexts after refactors
|
|
||||||
- No duplicate mechanisms for the same state flow
|
|
||||||
- Text encoding remains clean in user-facing strings/docs
|
|
||||||
|
|
||||||
---
|
## Desktop + mobile UX checklist (required)
|
||||||
|
|
||||||
## 9) Desktop + mobile UX checklist (required)
|
|
||||||
- Touch: long-press affordance for item-level actions when no visible button.
|
- Touch: long-press affordance for item-level actions when no visible button.
|
||||||
- Mouse: hover affordance on interactive rows/cards.
|
- Mouse: hover affordance on interactive rows/cards.
|
||||||
- Tap targets remain >= 40px on mobile.
|
- Tap targets remain >= 40px on mobile.
|
||||||
- Modal overlays must close on outside click/tap.
|
- Modal overlays must close on outside click/tap.
|
||||||
- Use bubble notifications for main actions (create/update/delete/join).
|
- Use bubble notifications for main actions (create/update/delete/join).
|
||||||
- Add Playwright UI tests for new UI features and critical flows.
|
- Add Playwright UI tests for new UI features and critical flows.
|
||||||
|
- Group role icons must be consistent: 👑 owner, 🛡️ admin, 👤 member.
|
||||||
|
|
||||||
---
|
## PR review checklist
|
||||||
|
- Desktop + mobile UX checklist satisfied (hover + long-press where applicable).
|
||||||
## 10) Tests (required)
|
- No TypeScript warnings or lint errors introduced.
|
||||||
- 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.
|
|
||||||
|
|||||||
@ -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";
|
const today = "2026-02-11";
|
||||||
|
|
||||||
test("calculateBucketUsage matches tag subset", () => {
|
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 = [
|
const entries = [
|
||||||
{ amountDollars: 20, occurredAt: "2026-02-10", necessity: "NECESSARY" as const, tags: ["groceries", "weekly", "extra"], 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" as const, tags: ["groceries"], 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);
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
@ -16,10 +16,10 @@ test("calculateBucketUsage matches tag subset", () => {
|
|||||||
assert.equal(result.matchedCount, 1);
|
assert.equal(result.matchedCount, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("calculateBucketUsage ignores non-spending entries", () => {
|
test("calculateBucketUsage excludes recurring entries", () => {
|
||||||
const bucket = { tags: ["rent"], necessity: "BOTH" as const, windowDays: 30 };
|
const bucket = { tags: ["rent"], necessity: "BOTH", windowDays: 30 };
|
||||||
const entries = [
|
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);
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
@ -28,11 +28,11 @@ test("calculateBucketUsage ignores non-spending entries", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("calculateBucketUsage applies windowDays filtering", () => {
|
test("calculateBucketUsage applies windowDays filtering", () => {
|
||||||
const bucket = { tags: [], necessity: "BOTH" as const, windowDays: 3 };
|
const bucket = { tags: [], necessity: "BOTH", windowDays: 3 };
|
||||||
const entries = [
|
const entries = [
|
||||||
{ amountDollars: 10, occurredAt: "2026-02-11", 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" as const, tags: [], entryType: "SPENDING" as const },
|
{ amountDollars: 10, occurredAt: "2026-02-09", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const },
|
||||||
{ amountDollars: 10, occurredAt: "2026-02-08", necessity: "NECESSARY" as const, tags: [], 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);
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
@ -41,10 +41,10 @@ test("calculateBucketUsage applies windowDays filtering", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("calculateBucketUsage accepts all when bucket necessity is BOTH", () => {
|
test("calculateBucketUsage accepts all when bucket necessity is BOTH", () => {
|
||||||
const bucket = { tags: [], necessity: "BOTH" as const, windowDays: 30 };
|
const bucket = { tags: [], necessity: "BOTH", windowDays: 30 };
|
||||||
const entries = [
|
const entries = [
|
||||||
{ amountDollars: 12, occurredAt: "2026-02-10", necessity: "NECESSARY" 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" as const, tags: [], 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);
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
@ -53,10 +53,10 @@ test("calculateBucketUsage accepts all when bucket necessity is BOTH", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("calculateBucketUsage halves BOTH entries when bucket not BOTH", () => {
|
test("calculateBucketUsage halves BOTH entries when bucket not BOTH", () => {
|
||||||
const bucket = { tags: [], necessity: "NECESSARY" as const, windowDays: 30 };
|
const bucket = { tags: [], necessity: "NECESSARY", windowDays: 30 };
|
||||||
const entries = [
|
const entries = [
|
||||||
{ amountDollars: 10, occurredAt: "2026-02-10", necessity: "BOTH" 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" as const, tags: [], 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);
|
const result = calculateBucketUsage(bucket, entries, today);
|
||||||
|
|||||||
@ -75,7 +75,7 @@ test("buckets CRUD", async t => {
|
|||||||
assert.equal(updated?.name, "Groceries+");
|
assert.equal(updated?.name, "Groceries+");
|
||||||
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
|
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
|
||||||
|
|
||||||
await deleteBucket({ id: bucket.id, groupId, userId });
|
await deleteBucket({ id: bucket.id, groupId });
|
||||||
const listAfter = await listBuckets(groupId);
|
const listAfter = await listBuckets(groupId);
|
||||||
assert.equal(listAfter.length, 0);
|
assert.equal(listAfter.length, 0);
|
||||||
} finally {
|
} 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(
|
await assert.rejects(
|
||||||
() => setGroupSettings({ userId: memberId!, groupId: groupId!, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" }),
|
() => setGroupSettings({ userId: memberId!, groupId, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" }),
|
||||||
{ message: "FORBIDDEN" }
|
{ message: "FORBIDDEN" }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -61,11 +61,11 @@ test("invite link auto-accept adds member", async t => {
|
|||||||
const result = await acceptInviteLink({ userId: memberId!, token: link.token, requestId: "test-accept" });
|
const result = await acceptInviteLink({ userId: memberId!, token: link.token, requestId: "test-accept" });
|
||||||
assert.equal(result.status, "JOINED");
|
assert.equal(result.status, "JOINED");
|
||||||
|
|
||||||
const queryResult = await client.query(
|
const { rowCount } = await client.query(
|
||||||
"select 1 from group_members where group_id=$1 and user_id=$2",
|
"select 1 from group_members where group_id=$1 and user_id=$2",
|
||||||
[groupId, memberId]
|
[groupId, memberId]
|
||||||
);
|
);
|
||||||
assert.equal(queryResult.rows.length, 1);
|
assert.equal(rowCount, 1);
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
|
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
|
||||||
client.release();
|
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",
|
purchaseType: "Rent",
|
||||||
notes: "Monthly rent",
|
notes: "Monthly rent",
|
||||||
tags: ["rent"],
|
tags: ["rent"],
|
||||||
|
isRecurring: true,
|
||||||
frequency: "MONTHLY",
|
frequency: "MONTHLY",
|
||||||
intervalCount: 1,
|
intervalCount: 1,
|
||||||
endCondition: "NEVER"
|
endCondition: "NEVER",
|
||||||
|
nextRunAt: "2026-02-01"
|
||||||
});
|
});
|
||||||
|
|
||||||
const list = await listRecurringEntries(groupId);
|
const list = await listRecurringEntries(groupId);
|
||||||
@ -67,7 +69,7 @@ test("recurring entries list", async t => {
|
|||||||
} finally {
|
} finally {
|
||||||
if (groupId) {
|
if (groupId) {
|
||||||
const list = await listRecurringEntries(groupId);
|
const list = await listRecurringEntries(groupId);
|
||||||
for (const entry of list) await deleteRecurringEntry({ id: entry.id, groupId, userId: userId! });
|
for (const entry of list) await deleteRecurringEntry({ id: entry.id, groupId });
|
||||||
}
|
}
|
||||||
await cleanupTestData(client, { userIds: [userId], groupId });
|
await cleanupTestData(client, { userIds: [userId], groupId });
|
||||||
client.release();
|
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",
|
necessity: "NECESSARY",
|
||||||
purchaseType: "Groceries",
|
purchaseType: "Groceries",
|
||||||
notes: "Test",
|
notes: "Test",
|
||||||
tags: ["groceries", "weekly"]
|
tags: ["groceries", "weekly"],
|
||||||
|
isRecurring: false,
|
||||||
|
intervalCount: 1
|
||||||
});
|
});
|
||||||
|
|
||||||
const list = await listEntries(groupId);
|
const list = await listEntries(groupId);
|
||||||
@ -74,14 +76,19 @@ test("entries CRUD", async t => {
|
|||||||
necessity: "BOTH",
|
necessity: "BOTH",
|
||||||
purchaseType: "Groceries",
|
purchaseType: "Groceries",
|
||||||
notes: "Updated",
|
notes: "Updated",
|
||||||
tags: ["groceries"]
|
tags: ["groceries"],
|
||||||
|
isRecurring: true,
|
||||||
|
frequency: "MONTHLY",
|
||||||
|
intervalCount: 1,
|
||||||
|
endCondition: "NEVER",
|
||||||
|
nextRunAt: "2026-02-02"
|
||||||
});
|
});
|
||||||
assert.ok(updated);
|
assert.ok(updated);
|
||||||
assert.equal(updated?.amountDollars, 15);
|
assert.equal(updated?.amountDollars, 15);
|
||||||
assert.equal(updated?.entryType, "INCOME");
|
assert.equal(updated?.entryType, "INCOME");
|
||||||
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
|
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
|
||||||
|
|
||||||
await deleteEntry({ id: entry.id, groupId, userId });
|
await deleteEntry({ id: entry.id, groupId });
|
||||||
const listAfter = await listEntries(groupId);
|
const listAfter = await listEntries(groupId);
|
||||||
assert.equal(listAfter.length, 0);
|
assert.equal(listAfter.length, 0);
|
||||||
} finally {
|
} 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)",
|
"delete from entry_tags where entry_id in (select id from entries where group_id=$1)",
|
||||||
[groupId]
|
[groupId]
|
||||||
);
|
);
|
||||||
await safeQuery(
|
|
||||||
"delete from schedule_tags where schedule_id in (select id from schedules where group_id=$1)",
|
|
||||||
[groupId]
|
|
||||||
);
|
|
||||||
await safeQuery(
|
|
||||||
"delete from entries where source_schedule_id in (select id from schedules where group_id=$1)",
|
|
||||||
[groupId]
|
|
||||||
);
|
|
||||||
await safeQuery("delete from schedules where group_id=$1", [groupId]);
|
|
||||||
await safeQuery("delete from bucket_tags where bucket_id in (select id from buckets where group_id=$1)", [groupId]);
|
await safeQuery("delete from bucket_tags where bucket_id in (select id from buckets where group_id=$1)", [groupId]);
|
||||||
await safeQuery("delete from buckets where group_id=$1", [groupId]);
|
await safeQuery("delete from buckets where group_id=$1", [groupId]);
|
||||||
await safeQuery("delete from entries where group_id=$1", [groupId]);
|
await safeQuery("delete from entries where group_id=$1", [groupId]);
|
||||||
@ -73,3 +64,4 @@ export async function cleanupTestDataFromPool(pool: Pool, args: CleanupArgs) {
|
|||||||
client.release();
|
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 { cookies } from "next/headers";
|
||||||
import { getSessionCookieName } from "@/lib/server/auth";
|
import { getSessionCookieName } from "@/lib/server/auth";
|
||||||
import { loginUser } from "@/lib/server/auth-service";
|
import { loginUser } from "@/lib/server/auth-service";
|
||||||
import { enforceAuthRateLimit } from "@/lib/server/rate-limit";
|
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
import { getRequestMeta } from "@/lib/server/request";
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const { requestId, ip } = await getRequestMeta();
|
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const email = String(body?.email || "").trim().toLowerCase();
|
const email = String(body?.email || "").trim().toLowerCase();
|
||||||
const password = String(body?.password || "");
|
const password = String(body?.password || "");
|
||||||
const remember = Boolean(body?.remember ?? true);
|
const remember = Boolean(body?.remember ?? true);
|
||||||
|
|
||||||
|
if (!email || !password)
|
||||||
|
return NextResponse.json({ error: { code: "MISSING_CREDENTIALS", message: "Missing credentials" } }, { status: 400 });
|
||||||
|
|
||||||
let user;
|
let user;
|
||||||
let session;
|
let session;
|
||||||
try {
|
try {
|
||||||
await enforceAuthRateLimit({ route: "login", ip, identifier: email });
|
|
||||||
if (!email || !password)
|
|
||||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_CREDENTIALS", message: "Missing credentials" } }, { status: 400 });
|
|
||||||
const result = await loginUser({ email, password, remember });
|
const result = await loginUser({ email, password, remember });
|
||||||
user = result.user;
|
user = result.user;
|
||||||
session = result.session;
|
session = result.session;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/auth/login", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/auth/login");
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
@ -35,5 +32,5 @@ export async function POST(req: Request) {
|
|||||||
path: "/"
|
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 { cookies } from "next/headers";
|
||||||
import { getSessionCookieName } from "@/lib/server/auth";
|
import { getSessionCookieName } from "@/lib/server/auth";
|
||||||
import { logoutUser } from "@/lib/server/auth-service";
|
import { logoutUser } from "@/lib/server/auth-service";
|
||||||
import { getRequestMeta } from "@/lib/server/request";
|
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const { requestId } = await getRequestMeta();
|
const cookieStore = await cookies();
|
||||||
try {
|
const token = cookieStore.get(getSessionCookieName())?.value;
|
||||||
const cookieStore = await cookies();
|
if (token)
|
||||||
const token = cookieStore.get(getSessionCookieName())?.value;
|
await logoutUser(token);
|
||||||
if (token)
|
cookieStore.set(getSessionCookieName(), "", {
|
||||||
await logoutUser(token);
|
httpOnly: true,
|
||||||
cookieStore.set(getSessionCookieName(), "", {
|
sameSite: "lax",
|
||||||
httpOnly: true,
|
secure: process.env.NODE_ENV === "production",
|
||||||
sameSite: "lax",
|
maxAge: 0,
|
||||||
secure: process.env.NODE_ENV === "production",
|
path: "/"
|
||||||
maxAge: 0,
|
});
|
||||||
path: "/"
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (e) {
|
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/auth/logout", requestId);
|
|
||||||
return NextResponse.json(body, { status });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getSessionUser } from "@/lib/server/session";
|
import { getSessionUser } from "@/lib/server/session";
|
||||||
import { getRequestMeta } from "@/lib/server/request";
|
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const { requestId } = await getRequestMeta();
|
const user = await getSessionUser();
|
||||||
try {
|
if (!user)
|
||||||
const user = await getSessionUser();
|
return NextResponse.json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } }, { status: 401 });
|
||||||
if (!user)
|
return NextResponse.json({ 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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,30 +2,27 @@ import { NextResponse } from "next/server";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getSessionCookieName, getSessionTtlMs } from "@/lib/server/auth";
|
import { getSessionCookieName, getSessionTtlMs } from "@/lib/server/auth";
|
||||||
import { registerUser } from "@/lib/server/auth-service";
|
import { registerUser } from "@/lib/server/auth-service";
|
||||||
import { enforceAuthRateLimit } from "@/lib/server/rate-limit";
|
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
import { getRequestMeta } from "@/lib/server/request";
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const { requestId, ip } = await getRequestMeta();
|
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const email = String(body?.email || "").trim().toLowerCase();
|
const email = String(body?.email || "").trim().toLowerCase();
|
||||||
const password = String(body?.password || "");
|
const password = String(body?.password || "");
|
||||||
const displayName = String(body?.displayName || "").trim();
|
const displayName = String(body?.displayName || "").trim();
|
||||||
|
|
||||||
|
if (!email || !email.includes("@"))
|
||||||
|
return NextResponse.json({ error: { code: "INVALID_EMAIL", message: "Invalid email" } }, { status: 400 });
|
||||||
|
if (password.length < 8)
|
||||||
|
return NextResponse.json({ error: { code: "PASSWORD_TOO_SHORT", message: "Password too short" } }, { status: 400 });
|
||||||
|
|
||||||
let user;
|
let user;
|
||||||
let session;
|
let session;
|
||||||
try {
|
try {
|
||||||
await enforceAuthRateLimit({ route: "register", ip, identifier: email });
|
|
||||||
if (!email || !email.includes("@"))
|
|
||||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_EMAIL", message: "Invalid email" } }, { status: 400 });
|
|
||||||
if (password.length < 8)
|
|
||||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "PASSWORD_TOO_SHORT", message: "Password too short" } }, { status: 400 });
|
|
||||||
const result = await registerUser({ email, password, displayName });
|
const result = await registerUser({ email, password, displayName });
|
||||||
user = result.user;
|
user = result.user;
|
||||||
session = result.session;
|
session = result.session;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/auth/register", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/auth/register");
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
@ -37,5 +34,5 @@ export async function POST(req: Request) {
|
|||||||
path: "/"
|
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 groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, 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 body = await req.json().catch(() => null);
|
||||||
const name = String(body?.name || "").trim();
|
const name = String(body?.name || "").trim();
|
||||||
@ -30,13 +30,13 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
|
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
|
||||||
|
|
||||||
if (!name)
|
if (!name)
|
||||||
return NextResponse.json({ requestId, 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))
|
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))
|
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)
|
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({
|
const bucket = await updateBucket({
|
||||||
id,
|
id,
|
||||||
@ -51,9 +51,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
windowDays
|
windowDays
|
||||||
});
|
});
|
||||||
if (!bucket) return NextResponse.json({ requestId, 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) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "PATCH /api/buckets/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "PATCH /api/buckets/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -67,10 +67,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, 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 });
|
await deleteBucket({ id, groupId });
|
||||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
return NextResponse.json({ requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "DELETE /api/buckets/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "DELETE /api/buckets/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { createBucket, listBuckets, requireActiveGroup } from "@/lib/server/buckets";
|
import { createBucket, listBuckets, requireActiveGroup } from "@/lib/server/buckets";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -16,7 +16,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const buckets = await listBuckets(groupId);
|
const buckets = await listBuckets(groupId);
|
||||||
return NextResponse.json({ requestId, request_id: requestId, buckets });
|
return NextResponse.json({ requestId, buckets });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/buckets", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/buckets", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -38,13 +38,13 @@ export async function POST(req: Request) {
|
|||||||
const necessity = String(body?.necessity || "BOTH").toUpperCase();
|
const necessity = String(body?.necessity || "BOTH").toUpperCase();
|
||||||
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
|
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
|
||||||
|
|
||||||
if (!name) return NextResponse.json({ requestId, 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))
|
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))
|
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)
|
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({
|
const bucket = await createBucket({
|
||||||
groupId,
|
groupId,
|
||||||
@ -59,10 +59,9 @@ export async function POST(req: Request) {
|
|||||||
windowDays
|
windowDays
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ requestId, request_id: requestId, bucket });
|
return NextResponse.json({ requestId, bucket });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/buckets", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/buckets", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, 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 body = await req.json().catch(() => null);
|
||||||
const amountDollars = Number(body?.amountDollars || 0);
|
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 purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
|
||||||
const notes = String(body?.notes || "").trim();
|
const notes = String(body?.notes || "").trim();
|
||||||
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
|
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
|
||||||
|
const isRecurring = Boolean(body?.isRecurring);
|
||||||
|
const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null;
|
||||||
|
const intervalCount = Number(body?.intervalCount || 1);
|
||||||
|
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null;
|
||||||
|
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||||
|
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||||
|
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : (isRecurring ? occurredAt : null);
|
||||||
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
||||||
|
|
||||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
return NextResponse.json({ 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 (!occurredAt) return NextResponse.json({ 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 });
|
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
return NextResponse.json({ requestId, 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))
|
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({
|
const entry = await updateEntry({
|
||||||
id,
|
id,
|
||||||
groupId,
|
groupId,
|
||||||
@ -48,12 +62,19 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
purchaseType,
|
purchaseType,
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
tags,
|
tags,
|
||||||
|
isRecurring,
|
||||||
|
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
|
||||||
|
intervalCount,
|
||||||
|
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
||||||
|
endCount,
|
||||||
|
endDate,
|
||||||
|
nextRunAt,
|
||||||
bucketId
|
bucketId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!entry) return NextResponse.json({ requestId, 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) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "PATCH /api/entries/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "PATCH /api/entries/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -67,10 +88,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, 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 });
|
await deleteEntry({ id, groupId });
|
||||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
return NextResponse.json({ requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "DELETE /api/entries/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "DELETE /api/entries/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { createEntry, listEntries, requireActiveGroup } from "@/lib/server/entries";
|
import { createEntry, listEntries, requireActiveGroup } from "@/lib/server/entries";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -16,7 +16,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const entries = await listEntries(groupId);
|
const entries = await listEntries(groupId);
|
||||||
return NextResponse.json({ requestId, request_id: requestId, entries });
|
return NextResponse.json({ requestId, entries });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/entries", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/entries", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -36,16 +36,30 @@ export async function POST(req: Request) {
|
|||||||
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
|
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
|
||||||
const notes = String(body?.notes || "").trim();
|
const notes = String(body?.notes || "").trim();
|
||||||
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
|
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
|
||||||
|
const isRecurring = Boolean(body?.isRecurring);
|
||||||
|
const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null;
|
||||||
|
const intervalCount = Number(body?.intervalCount || 1);
|
||||||
|
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null;
|
||||||
|
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||||
|
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||||
|
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : (isRecurring ? occurredAt : null);
|
||||||
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
||||||
|
|
||||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
return NextResponse.json({ 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 (!occurredAt) return NextResponse.json({ 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 });
|
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
return NextResponse.json({ requestId, 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))
|
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({
|
const entry = await createEntry({
|
||||||
groupId,
|
groupId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@ -56,13 +70,19 @@ export async function POST(req: Request) {
|
|||||||
purchaseType,
|
purchaseType,
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
tags,
|
tags,
|
||||||
|
isRecurring,
|
||||||
|
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
|
||||||
|
intervalCount,
|
||||||
|
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
||||||
|
endCount,
|
||||||
|
endDate,
|
||||||
|
nextRunAt,
|
||||||
bucketId
|
bucketId
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ requestId, request_id: requestId, entry });
|
return NextResponse.json({ requestId, entry });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/entries", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/entries", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { getActiveGroupId, listGroups, setActiveGroupForUser } from "@/lib/server/groups";
|
import { getActiveGroupId, listGroups, setActiveGroupForUser } from "@/lib/server/groups";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -11,7 +11,7 @@ export async function GET() {
|
|||||||
const groups = await listGroups(user.id);
|
const groups = await listGroups(user.id);
|
||||||
const activeGroupId = await getActiveGroupId(user.id);
|
const activeGroupId = await getActiveGroupId(user.id);
|
||||||
const active = groups.find(group => Number(group.id) === activeGroupId) || null;
|
const active = groups.find(group => Number(group.id) === activeGroupId) || null;
|
||||||
return NextResponse.json({ requestId, request_id: requestId, active });
|
return NextResponse.json({ requestId, active });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups/active", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups/active", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -24,13 +24,12 @@ export async function POST(req: Request) {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const groupId = Number(body?.groupId || 0);
|
const groupId = Number(body?.groupId || 0);
|
||||||
if (!groupId) return NextResponse.json({ requestId, 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);
|
await setActiveGroupForUser(user.id, groupId);
|
||||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
return NextResponse.json({ requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/active", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/active", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { listGroupAudit } from "@/lib/server/group-audit";
|
import { listGroupAudit } from "@/lib/server/group-audit";
|
||||||
@ -11,10 +11,9 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const events = await listGroupAudit({ userId: user.id, groupId });
|
const events = await listGroupAudit({ userId: user.id, groupId });
|
||||||
return NextResponse.json({ requestId, request_id: requestId, events });
|
return NextResponse.json({ requestId, events });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups/audit", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups/audit", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup, deleteGroup } from "@/lib/server/groups";
|
import { requireActiveGroup, deleteGroup } from "@/lib/server/groups";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -10,10 +10,9 @@ export async function POST() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
await deleteGroup({ userId: user.id, groupId, requestId, ip, userAgent });
|
await deleteGroup({ userId: user.id, groupId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
return NextResponse.json({ requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/delete", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/delete", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { deleteInviteLink } from "@/lib/server/group-invites";
|
import { deleteInviteLink } from "@/lib/server/group-invites";
|
||||||
@ -13,12 +13,11 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const linkId = Number(body?.linkId || 0);
|
const linkId = Number(body?.linkId || 0);
|
||||||
if (!linkId)
|
if (!linkId)
|
||||||
return NextResponse.json({ requestId, 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 });
|
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) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/delete", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/delete", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { reviveInviteLink } from "@/lib/server/group-invites";
|
import { reviveInviteLink } from "@/lib/server/group-invites";
|
||||||
@ -14,15 +14,14 @@ export async function POST(req: Request) {
|
|||||||
const linkId = Number(body?.linkId || 0);
|
const linkId = Number(body?.linkId || 0);
|
||||||
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
|
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
|
||||||
if (!linkId)
|
if (!linkId)
|
||||||
return NextResponse.json({ requestId, 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)
|
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 expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
|
||||||
await reviveInviteLink({ userId: user.id, groupId, linkId, expiresAt, requestId, ip, userAgent });
|
await reviveInviteLink({ userId: user.id, groupId, linkId, expiresAt, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
return NextResponse.json({ requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revive", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revive", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { revokeInviteLink } from "@/lib/server/group-invites";
|
import { revokeInviteLink } from "@/lib/server/group-invites";
|
||||||
@ -13,12 +13,11 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const linkId = Number(body?.linkId || 0);
|
const linkId = Number(body?.linkId || 0);
|
||||||
if (!linkId)
|
if (!linkId)
|
||||||
return NextResponse.json({ requestId, 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 });
|
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) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revoke", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revoke", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { createInviteLink, listInviteLinks } from "@/lib/server/group-invites";
|
import { createInviteLink, listInviteLinks } from "@/lib/server/group-invites";
|
||||||
@ -11,7 +11,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const links = await listInviteLinks({ userId: user.id, groupId });
|
const links = await listInviteLinks({ userId: user.id, groupId });
|
||||||
return NextResponse.json({ requestId, request_id: requestId, links });
|
return NextResponse.json({ requestId, links });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups/invites", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups/invites", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -30,13 +30,12 @@ export async function POST(req: Request) {
|
|||||||
const singleUse = Boolean(body?.singleUse);
|
const singleUse = Boolean(body?.singleUse);
|
||||||
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
|
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
|
||||||
if (!ttlDays)
|
if (!ttlDays)
|
||||||
return NextResponse.json({ requestId, 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 expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
|
||||||
const link = await createInviteLink({ userId: user.id, groupId, policy, singleUse, expiresAt, requestId, ip, userAgent });
|
const link = await createInviteLink({ userId: user.id, groupId, policy, singleUse, expiresAt, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, request_id: requestId, link });
|
return NextResponse.json({ requestId, link });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/invites", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/invites", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,25 +1,22 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { joinGroup } from "@/lib/server/groups";
|
import { joinGroup } from "@/lib/server/groups";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
import { getRequestMeta } from "@/lib/server/request";
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
import { enforceIpRateLimit } from "@/lib/server/rate-limit";
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const { requestId, ip } = await getRequestMeta();
|
const { requestId } = await getRequestMeta();
|
||||||
try {
|
try {
|
||||||
await enforceIpRateLimit({ scope: "groups:join:ip", ip, limit: 60 });
|
|
||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const inviteCode = String(body?.inviteCode || "").trim().toUpperCase();
|
const inviteCode = String(body?.inviteCode || "").trim().toUpperCase();
|
||||||
if (!inviteCode)
|
if (!inviteCode)
|
||||||
return NextResponse.json({ requestId, 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);
|
const group = await joinGroup(user.id, inviteCode);
|
||||||
return NextResponse.json({ requestId, request_id: requestId, group });
|
return NextResponse.json({ requestId, group });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/join", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/join", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { approveJoinRequest } from "@/lib/server/group-members";
|
import { approveJoinRequest } from "@/lib/server/group-members";
|
||||||
@ -14,12 +14,11 @@ export async function POST(req: Request) {
|
|||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
const joinRequestId = Number(body?.requestId || 0);
|
const joinRequestId = Number(body?.requestId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, 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 });
|
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) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/approve", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/approve", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { demoteAdmin } from "@/lib/server/group-members";
|
import { demoteAdmin } from "@/lib/server/group-members";
|
||||||
@ -13,12 +13,11 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, 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 });
|
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) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/demote", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/demote", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { denyJoinRequest } from "@/lib/server/group-members";
|
import { denyJoinRequest } from "@/lib/server/group-members";
|
||||||
@ -13,12 +13,11 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, 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 });
|
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) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/deny", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/deny", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { kickMember } from "@/lib/server/group-members";
|
import { kickMember } from "@/lib/server/group-members";
|
||||||
@ -13,12 +13,11 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, 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 });
|
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) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/kick", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/kick", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { leaveGroup } from "@/lib/server/group-members";
|
import { leaveGroup } from "@/lib/server/group-members";
|
||||||
@ -11,10 +11,9 @@ export async function POST() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
await leaveGroup({ userId: user.id, groupId, requestId, ip, userAgent });
|
await leaveGroup({ userId: user.id, groupId, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
return NextResponse.json({ requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/leave", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/leave", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { promoteToAdmin } from "@/lib/server/group-members";
|
import { promoteToAdmin } from "@/lib/server/group-members";
|
||||||
@ -13,12 +13,11 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, 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 });
|
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) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/promote", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/promote", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { listGroupMembers, listJoinRequests } from "@/lib/server/group-members";
|
import { listGroupMembers, listJoinRequests } from "@/lib/server/group-members";
|
||||||
@ -12,10 +12,9 @@ export async function GET() {
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const members = await listGroupMembers(groupId);
|
const members = await listGroupMembers(groupId);
|
||||||
const requests = await listJoinRequests({ userId: user.id, groupId });
|
const requests = await listJoinRequests({ userId: user.id, groupId });
|
||||||
return NextResponse.json({ requestId, request_id: requestId, members, requests, currentUserId: user.id });
|
return NextResponse.json({ requestId, members, requests, currentUserId: user.id });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups/members", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups/members", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { transferOwnership } from "@/lib/server/group-members";
|
import { transferOwnership } from "@/lib/server/group-members";
|
||||||
@ -13,12 +13,11 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const userId = Number(body?.userId || 0);
|
const userId = Number(body?.userId || 0);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
return NextResponse.json({ requestId, 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 });
|
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) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/members/transfer-owner", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/members/transfer-owner", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup, renameGroup } from "@/lib/server/groups";
|
import { requireActiveGroup, renameGroup } from "@/lib/server/groups";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -12,12 +12,11 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const name = String(body?.name || "").trim();
|
const name = String(body?.name || "").trim();
|
||||||
if (!name)
|
if (!name)
|
||||||
return NextResponse.json({ requestId, 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 });
|
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) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/rename", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/rename", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { createGroup, listGroups } from "@/lib/server/groups";
|
import { createGroup, listGroups } from "@/lib/server/groups";
|
||||||
import { toErrorResponse } from "@/lib/server/errors";
|
import { toErrorResponse } from "@/lib/server/errors";
|
||||||
@ -9,7 +9,7 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groups = await listGroups(user.id);
|
const groups = await listGroups(user.id);
|
||||||
return NextResponse.json({ requestId, request_id: requestId, groups });
|
return NextResponse.json({ requestId, groups });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -22,13 +22,12 @@ export async function POST(req: Request) {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const name = String(body?.name || "").trim();
|
const name = String(body?.name || "").trim();
|
||||||
if (!name) return NextResponse.json({ requestId, 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);
|
const group = await createGroup(user.id, name);
|
||||||
return NextResponse.json({ requestId, request_id: requestId, group });
|
return NextResponse.json({ requestId, group });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
import { requireActiveGroup } from "@/lib/server/groups";
|
||||||
import { getGroupSettings, setGroupSettings } from "@/lib/server/group-settings";
|
import { getGroupSettings, setGroupSettings } from "@/lib/server/group-settings";
|
||||||
@ -11,7 +11,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const settings = await getGroupSettings(groupId);
|
const settings = await getGroupSettings(groupId);
|
||||||
return NextResponse.json({ requestId, request_id: requestId, settings });
|
return NextResponse.json({ requestId, settings });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/groups/settings", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/groups/settings", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -30,10 +30,9 @@ export async function POST(req: Request) {
|
|||||||
: "NOT_ACCEPTING";
|
: "NOT_ACCEPTING";
|
||||||
await setGroupSettings({ userId: user.id, groupId, allowMemberTagManage, joinPolicy });
|
await setGroupSettings({ userId: user.id, groupId, allowMemberTagManage, joinPolicy });
|
||||||
const settings = await getGroupSettings(groupId);
|
const settings = await getGroupSettings(groupId);
|
||||||
return NextResponse.json({ requestId, request_id: requestId, settings });
|
return NextResponse.json({ requestId, settings });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/groups/settings", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/groups/settings", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { apiError, toErrorResponse } from "@/lib/server/errors";
|
||||||
import { getRequestMeta } from "@/lib/server/request";
|
import { getRequestMeta } from "@/lib/server/request";
|
||||||
import { acceptInviteLink, getInviteLinkSummaryByToken, getInviteViewerStatus } from "@/lib/server/group-invites";
|
import { acceptInviteLink, getInviteLinkSummaryByToken, getInviteViewerStatus } from "@/lib/server/group-invites";
|
||||||
import { enforceIpRateLimit } from "@/lib/server/rate-limit";
|
|
||||||
|
|
||||||
export async function GET(_: Request, context: { params: Promise<{ token: string }> }) {
|
export async function GET(_: Request, context: { params: Promise<{ token: string }> }) {
|
||||||
const { requestId, ip } = await getRequestMeta();
|
const { requestId } = await getRequestMeta();
|
||||||
try {
|
try {
|
||||||
await enforceIpRateLimit({ scope: "invite-links:get:ip", ip, limit: 120 });
|
|
||||||
const { token } = await context.params;
|
const { token } = await context.params;
|
||||||
const normalized = String(token || "").trim();
|
const normalized = String(token || "").trim();
|
||||||
if (!normalized) apiError("INVITE_NOT_FOUND");
|
if (!normalized) apiError("INVITE_NOT_FOUND");
|
||||||
@ -18,9 +16,9 @@ export async function GET(_: Request, context: { params: Promise<{ token: string
|
|||||||
if (user) {
|
if (user) {
|
||||||
const viewerStatus = await getInviteViewerStatus({ userId: user.id, groupId: link.groupId });
|
const viewerStatus = await getInviteViewerStatus({ userId: user.id, groupId: link.groupId });
|
||||||
if (viewerStatus)
|
if (viewerStatus)
|
||||||
return NextResponse.json({ requestId, 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) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/invite-links/[token]", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/invite-links/[token]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -30,13 +28,12 @@ export async function GET(_: Request, context: { params: Promise<{ token: string
|
|||||||
export async function POST(_: Request, context: { params: Promise<{ token: string }> }) {
|
export async function POST(_: Request, context: { params: Promise<{ token: string }> }) {
|
||||||
const { requestId, ip, userAgent } = await getRequestMeta();
|
const { requestId, ip, userAgent } = await getRequestMeta();
|
||||||
try {
|
try {
|
||||||
await enforceIpRateLimit({ scope: "invite-links:accept:ip", ip, limit: 60 });
|
|
||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const { token } = await context.params;
|
const { token } = await context.params;
|
||||||
const normalized = String(token || "").trim();
|
const normalized = String(token || "").trim();
|
||||||
if (!normalized) apiError("INVITE_NOT_FOUND");
|
if (!normalized) apiError("INVITE_NOT_FOUND");
|
||||||
const result = await acceptInviteLink({ userId: user.id, token: normalized, requestId, ip, userAgent });
|
const result = await acceptInviteLink({ userId: user.id, token: normalized, requestId, ip, userAgent });
|
||||||
return NextResponse.json({ requestId, request_id: requestId, result });
|
return NextResponse.json({ requestId, result });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/invite-links/[token]", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/invite-links/[token]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, 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 body = await req.json().catch(() => null);
|
||||||
const amountDollars = Number(body?.amountDollars || 0);
|
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 endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||||
const endDate = body?.endDate ? String(body.endDate) : null;
|
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||||
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
|
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
|
||||||
|
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
||||||
|
|
||||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
return NextResponse.json({ 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 (!occurredAt) return NextResponse.json({ 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 });
|
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
return NextResponse.json({ requestId, 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))
|
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)
|
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
||||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
||||||
if (frequency && !['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency))
|
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', '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_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
|
||||||
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
|
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
|
||||||
return NextResponse.json({ requestId, 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({
|
const entry = await updateRecurringEntry({
|
||||||
id,
|
id,
|
||||||
@ -60,17 +61,19 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
purchaseType,
|
purchaseType,
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
tags,
|
tags,
|
||||||
frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null,
|
isRecurring: true,
|
||||||
|
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
|
||||||
intervalCount,
|
intervalCount,
|
||||||
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
||||||
endCount,
|
endCount,
|
||||||
endDate,
|
endDate,
|
||||||
nextRunAt
|
nextRunAt,
|
||||||
|
bucketId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!entry) return NextResponse.json({ requestId, 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) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "PATCH /api/recurring-entries/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "PATCH /api/recurring-entries/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -84,10 +87,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
|
|||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const { id: idParam } = await params;
|
const { id: idParam } = await params;
|
||||||
const id = Number(idParam || 0);
|
const id = Number(idParam || 0);
|
||||||
if (!id) return NextResponse.json({ requestId, 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 });
|
await deleteRecurringEntry({ id, groupId });
|
||||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
return NextResponse.json({ requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "DELETE /api/recurring-entries/[id]", requestId);
|
const { status, body } = toErrorResponse(e, "DELETE /api/recurring-entries/[id]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const entries = await listRecurringEntries(groupId);
|
const entries = await listRecurringEntries(groupId);
|
||||||
return NextResponse.json({ requestId, request_id: requestId, entries });
|
return NextResponse.json({ requestId, entries });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/recurring-entries", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/recurring-entries", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -42,21 +42,22 @@ export async function POST(req: Request) {
|
|||||||
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
const endCount = body?.endCount != null ? Number(body.endCount) : null;
|
||||||
const endDate = body?.endDate ? String(body.endDate) : null;
|
const endDate = body?.endDate ? String(body.endDate) : null;
|
||||||
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
|
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
|
||||||
|
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
|
||||||
|
|
||||||
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
|
||||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
|
return NextResponse.json({ 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 (!occurredAt) return NextResponse.json({ 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 });
|
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
|
||||||
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
|
||||||
return NextResponse.json({ requestId, 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))
|
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)
|
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
|
||||||
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
|
||||||
if (frequency && !['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency))
|
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', '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_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
|
||||||
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
|
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
|
||||||
return NextResponse.json({ requestId, 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({
|
const entry = await createRecurringEntry({
|
||||||
groupId,
|
groupId,
|
||||||
@ -68,17 +69,19 @@ export async function POST(req: Request) {
|
|||||||
purchaseType,
|
purchaseType,
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
tags,
|
tags,
|
||||||
frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null,
|
isRecurring: true,
|
||||||
|
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
|
||||||
intervalCount,
|
intervalCount,
|
||||||
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
|
||||||
endCount,
|
endCount,
|
||||||
endDate
|
endDate,
|
||||||
|
nextRunAt,
|
||||||
|
bucketId
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ requestId, request_id: requestId, entry });
|
return NextResponse.json({ requestId, entry });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/recurring-entries", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/recurring-entries", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 groupId = await requireActiveGroup(user.id);
|
||||||
const { name } = await params;
|
const { name } = await params;
|
||||||
await deleteTagForGroup({ userId: user.id, groupId, name: decodeURIComponent(name) });
|
await deleteTagForGroup({ userId: user.id, groupId, name: decodeURIComponent(name) });
|
||||||
return NextResponse.json({ requestId, request_id: requestId, ok: true });
|
return NextResponse.json({ requestId, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "DELETE /api/tags/[name]", requestId);
|
const { status, body } = toErrorResponse(e, "DELETE /api/tags/[name]", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireSessionUser } from "@/lib/server/session";
|
import { requireSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/entries";
|
import { requireActiveGroup } from "@/lib/server/entries";
|
||||||
import { ensureTagsForGroup, listGroupTags } from "@/lib/server/tags";
|
import { ensureTagsForGroup, listGroupTags } from "@/lib/server/tags";
|
||||||
@ -11,7 +11,7 @@ export async function GET() {
|
|||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
const groupId = await requireActiveGroup(user.id);
|
const groupId = await requireActiveGroup(user.id);
|
||||||
const tags = await listGroupTags(groupId);
|
const tags = await listGroupTags(groupId);
|
||||||
return NextResponse.json({ requestId, request_id: requestId, tags });
|
return NextResponse.json({ requestId, tags });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "GET /api/tags", requestId);
|
const { status, body } = toErrorResponse(e, "GET /api/tags", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
@ -27,10 +27,9 @@ export async function POST(req: Request) {
|
|||||||
const tags = Array.isArray(body?.tags) ? body.tags.map((tag: unknown) => String(tag)) : [];
|
const tags = Array.isArray(body?.tags) ? body.tags.map((tag: unknown) => String(tag)) : [];
|
||||||
await ensureTagsForGroup({ userId: user.id, groupId, tags });
|
await ensureTagsForGroup({ userId: user.id, groupId, tags });
|
||||||
const list = await listGroupTags(groupId);
|
const list = await listGroupTags(groupId);
|
||||||
return NextResponse.json({ requestId, request_id: requestId, tags: list });
|
return NextResponse.json({ requestId, tags: list });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { status, body } = toErrorResponse(e, "POST /api/tags", requestId);
|
const { status, body } = toErrorResponse(e, "POST /api/tags", requestId);
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { useEffect, useMemo, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useAuthContext } from "@/hooks/auth-context";
|
import { useAuthContext } from "@/hooks/auth-context";
|
||||||
import useInviteLink from "@/features/groups/hooks/use-invite-link";
|
import useInviteLink from "@/hooks/use-invite-link";
|
||||||
|
|
||||||
export default function InvitePage() {
|
export default function InvitePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@ -154,7 +154,7 @@ export default function InvitePage() {
|
|||||||
<div className="card-title">Invite details</div>
|
<div className="card-title">Invite details</div>
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-sm text-muted">Loading invite...</div>
|
<div className="text-sm text-muted">Loading invite…</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="text-sm text-red-400">{error}</div>
|
<div className="text-sm text-red-400">{error}</div>
|
||||||
) : link ? (
|
) : link ? (
|
||||||
@ -203,7 +203,7 @@ export default function InvitePage() {
|
|||||||
<div className="card-title">Join this group</div>
|
<div className="card-title">Join this group</div>
|
||||||
</div>
|
</div>
|
||||||
{checkingSession ? (
|
{checkingSession ? (
|
||||||
<div className="text-sm text-muted">Checking session...</div>
|
<div className="text-sm text-muted">Checking session…</div>
|
||||||
) : !hasSession ? (
|
) : !hasSession ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-sm text-muted">Sign in to accept this invite.</div>
|
<div className="text-sm text-muted">Sign in to accept this invite.</div>
|
||||||
@ -244,7 +244,7 @@ export default function InvitePage() {
|
|||||||
disabled={!link || Boolean(result) || accepting}
|
disabled={!link || Boolean(result) || accepting}
|
||||||
onClick={accept}
|
onClick={accept}
|
||||||
>
|
>
|
||||||
{accepting ? "Joining..." : actionLabel}
|
{accepting ? "Joining…" : actionLabel}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
@ -280,4 +280,3 @@ export default function InvitePage() {
|
|||||||
</div>
|
</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 { useEffect, useMemo, useState } from "react";
|
||||||
import { useGroupsContext } from "@/hooks/groups-context";
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
import useBuckets from "@/features/buckets/hooks/use-buckets";
|
import useBuckets from "@/hooks/use-buckets";
|
||||||
import useTags from "@/features/tags/hooks/use-tags";
|
import useTags from "@/hooks/use-tags";
|
||||||
import NewBucketModal from "@/components/new-bucket-modal";
|
import NewBucketModal from "@/components/new-bucket-modal";
|
||||||
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
||||||
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
||||||
import BucketCard from "./bucket-card";
|
import BucketCard from "./bucket-card";
|
||||||
import { useEntryMutation } from "@/hooks/entry-mutation-context";
|
import { useEntryMutation } from "@/hooks/entry-mutation-context";
|
||||||
import { useNotificationsContext } from "@/hooks/notifications-context";
|
|
||||||
|
|
||||||
export default function BucketsPanel() {
|
export default function BucketsPanel() {
|
||||||
const { activeGroupId } = useGroupsContext();
|
const { activeGroupId } = useGroupsContext();
|
||||||
const { buckets, loading, error, createBucket, updateBucket, deleteBucket, reload } = useBuckets(activeGroupId);
|
const { buckets, loading, error, createBucket, updateBucket, deleteBucket, reload } = useBuckets(activeGroupId);
|
||||||
const { mutationVersion } = useEntryMutation();
|
const { mutationVersion } = useEntryMutation();
|
||||||
const { notify } = useNotificationsContext();
|
|
||||||
const { tags: tagSuggestions } = useTags(activeGroupId);
|
const { tags: tagSuggestions } = useTags(activeGroupId);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editId, setEditId] = useState<number | null>(null);
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
|
||||||
|
const [menuOpenId, setMenuOpenId] = useState<number | null>(null);
|
||||||
|
const [expandedIds, setExpandedIds] = useState<number[]>([]);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
@ -33,6 +33,18 @@ export default function BucketsPanel() {
|
|||||||
const iconMap = useMemo(() => new Map(bucketIcons.map(item => [item.key, item.icon])), []);
|
const iconMap = useMemo(() => new Map(bucketIcons.map(item => [item.key, item.icon])), []);
|
||||||
const orderedBuckets = useMemo(() => [...buckets].sort((a, b) => a.position - b.position || a.name.localeCompare(b.name)), [buckets]);
|
const orderedBuckets = useMemo(() => [...buckets].sort((a, b) => a.position - b.position || a.name.localeCompare(b.name)), [buckets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (!menuOpenId) return;
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (!target) return;
|
||||||
|
if (target.closest("[data-bucket-menu]") || target.closest("[data-bucket-menu-button]")) return;
|
||||||
|
setMenuOpenId(null);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [menuOpenId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeGroupId) return;
|
if (!activeGroupId) return;
|
||||||
if (mutationVersion === 0) return;
|
if (mutationVersion === 0) return;
|
||||||
@ -63,6 +75,7 @@ export default function BucketsPanel() {
|
|||||||
windowDays: bucket.windowDays ? String(bucket.windowDays) : "30"
|
windowDays: bucket.windowDays ? String(bucket.windowDays) : "30"
|
||||||
});
|
});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
|
setMenuOpenId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
@ -84,10 +97,7 @@ export default function BucketsPanel() {
|
|||||||
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
windowDays
|
windowDays
|
||||||
});
|
});
|
||||||
if (ok) {
|
if (ok) setModalOpen(false);
|
||||||
notify({ title: "Bucket updated", message: form.name.trim(), tone: "success" });
|
|
||||||
setModalOpen(false);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const ok = await createBucket({
|
const ok = await createBucket({
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
@ -98,17 +108,40 @@ export default function BucketsPanel() {
|
|||||||
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
|
||||||
windowDays
|
windowDays
|
||||||
});
|
});
|
||||||
if (ok) {
|
if (ok) setModalOpen(false);
|
||||||
notify({ title: "Bucket created", message: form.name.trim(), tone: "success" });
|
|
||||||
setModalOpen(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleExpanded(bucketId: number) {
|
||||||
|
setExpandedIds(prev => prev.includes(bucketId)
|
||||||
|
? prev.filter(id => id !== bucketId)
|
||||||
|
: [...prev, bucketId]);
|
||||||
|
}
|
||||||
|
|
||||||
function budgetUsage(bucket: typeof buckets[number]) {
|
function budgetUsage(bucket: typeof buckets[number]) {
|
||||||
const limit = bucket.budgetLimitDollars || 0;
|
const limit = bucket.budgetLimitDollars || 0;
|
||||||
const spent = bucket.totalUsage || 0;
|
const spent = bucket.totalUsage || 0;
|
||||||
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 (
|
return (
|
||||||
@ -126,7 +159,7 @@ export default function BucketsPanel() {
|
|||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ? (
|
{!activeGroupId ? (
|
||||||
@ -148,14 +181,21 @@ export default function BucketsPanel() {
|
|||||||
orderedBuckets.map(bucket => {
|
orderedBuckets.map(bucket => {
|
||||||
const icon = bucket.iconKey ? iconMap.get(bucket.iconKey) : null;
|
const icon = bucket.iconKey ? iconMap.get(bucket.iconKey) : null;
|
||||||
const { limit, spent } = budgetUsage(bucket);
|
const { limit, spent } = budgetUsage(bucket);
|
||||||
|
const isExpanded = expandedIds.includes(bucket.id);
|
||||||
const usageLabel = limit ? `$${spent.toFixed(2)} / $${limit.toFixed(2)}` : "";
|
const usageLabel = limit ? `$${spent.toFixed(2)} / $${limit.toFixed(2)}` : "";
|
||||||
return <BucketCard
|
return <BucketCard
|
||||||
key={bucket.id}
|
key={bucket.id}
|
||||||
bucket={bucket}
|
bucket={bucket}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
toggleExpanded={toggleExpanded}
|
||||||
|
isMenuOpen={menuOpenId === bucket.id}
|
||||||
|
setMenuOpenId={setMenuOpenId}
|
||||||
|
setConfirmDeleteId={setConfirmDeleteId}
|
||||||
openEdit={openEdit}
|
openEdit={openEdit}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
usageLabel={usageLabel}
|
usageLabel={usageLabel}
|
||||||
|
renderUsageBar={renderUsageBar}
|
||||||
/>
|
/>
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
@ -172,11 +212,6 @@ export default function BucketsPanel() {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onChange={next => setForm(prev => ({ ...prev, ...next }))}
|
onChange={next => setForm(prev => ({ ...prev, ...next }))}
|
||||||
tagSuggestions={tagSuggestions}
|
tagSuggestions={tagSuggestions}
|
||||||
canDelete={Boolean(editId)}
|
|
||||||
onDelete={() => {
|
|
||||||
if (!editId) return;
|
|
||||||
setConfirmDeleteId(editId);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<ConfirmSlideModal
|
<ConfirmSlideModal
|
||||||
isOpen={Boolean(confirmDeleteId)}
|
isOpen={Boolean(confirmDeleteId)}
|
||||||
@ -186,21 +221,10 @@ export default function BucketsPanel() {
|
|||||||
onClose={() => setConfirmDeleteId(null)}
|
onClose={() => setConfirmDeleteId(null)}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
if (!confirmDeleteId) return;
|
if (!confirmDeleteId) return;
|
||||||
const deletedBucket = buckets.find(bucket => bucket.id === confirmDeleteId) || null;
|
|
||||||
const ok = await deleteBucket(confirmDeleteId);
|
const ok = await deleteBucket(confirmDeleteId);
|
||||||
if (ok) {
|
if (ok) setConfirmDeleteId(null);
|
||||||
notify({
|
|
||||||
title: "Bucket deleted",
|
|
||||||
message: deletedBucket?.name || "Bucket removed",
|
|
||||||
tone: "danger"
|
|
||||||
});
|
|
||||||
setConfirmDeleteId(null);
|
|
||||||
setModalOpen(false);
|
|
||||||
resetForm();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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
|
onConfirm
|
||||||
}: ConfirmSlideModalProps) {
|
}: ConfirmSlideModalProps) {
|
||||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||||
const endFlashTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const reachedEndRef = useRef(false);
|
|
||||||
const [dragX, setDragX] = useState(0);
|
const [dragX, setDragX] = useState(0);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [isAtEnd, setIsAtEnd] = useState(false);
|
const handleSize = 44;
|
||||||
const [endFlash, setEndFlash] = useState(false);
|
|
||||||
const handleSize = 40;
|
|
||||||
|
|
||||||
function getDragPositionFromClientX(clientX: number) {
|
|
||||||
const track = trackRef.current;
|
|
||||||
if (!track) return 0;
|
|
||||||
const rect = track.getBoundingClientRect();
|
|
||||||
return Math.min(Math.max(0, clientX - rect.left - handleSize / 2), rect.width - handleSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEndPosition(position: number) {
|
|
||||||
const track = trackRef.current;
|
|
||||||
if (!track) return false;
|
|
||||||
const maxDrag = track.clientWidth - handleSize;
|
|
||||||
const endTolerancePx = 1;
|
|
||||||
return position >= maxDrag - endTolerancePx;
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerEndFeedback() {
|
|
||||||
setEndFlash(true);
|
|
||||||
if (endFlashTimeoutRef.current) clearTimeout(endFlashTimeoutRef.current);
|
|
||||||
endFlashTimeoutRef.current = setTimeout(() => setEndFlash(false), 140);
|
|
||||||
|
|
||||||
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
|
|
||||||
navigator.vibrate(16);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerDown(event: React.PointerEvent<HTMLButtonElement>) {
|
function handlePointerDown(event: React.PointerEvent<HTMLButtonElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
reachedEndRef.current = false;
|
|
||||||
setIsAtEnd(false);
|
|
||||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerMove(event: React.PointerEvent<HTMLButtonElement>) {
|
function handlePointerMove(event: React.PointerEvent<HTMLButtonElement>) {
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
const next = getDragPositionFromClientX(event.clientX);
|
const track = trackRef.current;
|
||||||
const nextAtEnd = isEndPosition(next);
|
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);
|
setDragX(next);
|
||||||
setIsAtEnd(prev => (prev === nextAtEnd ? prev : nextAtEnd));
|
|
||||||
|
|
||||||
if (nextAtEnd && !reachedEndRef.current) {
|
|
||||||
reachedEndRef.current = true;
|
|
||||||
triggerEndFeedback();
|
|
||||||
}
|
|
||||||
if (!nextAtEnd) reachedEndRef.current = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerUp(event: React.PointerEvent<HTMLButtonElement>) {
|
function handlePointerUp(event: React.PointerEvent<HTMLButtonElement>) {
|
||||||
@ -82,35 +45,14 @@ export default function ConfirmSlideModal({
|
|||||||
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
||||||
const track = trackRef.current;
|
const track = trackRef.current;
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
const releaseX = getDragPositionFromClientX(event.clientX);
|
const threshold = (track.clientWidth - handleSize) * 0.8;
|
||||||
const releaseAtEnd = isEndPosition(releaseX);
|
if (dragX >= threshold) {
|
||||||
|
|
||||||
setIsAtEnd(prev => (prev ? false : prev));
|
|
||||||
|
|
||||||
if (releaseAtEnd && !reachedEndRef.current) {
|
|
||||||
triggerEndFeedback();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (releaseAtEnd) {
|
|
||||||
setDragX(0);
|
setDragX(0);
|
||||||
onConfirm();
|
onConfirm();
|
||||||
} else {
|
} else {
|
||||||
setDragX(0);
|
setDragX(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerCancel(event: React.PointerEvent<HTMLButtonElement>) {
|
|
||||||
if (!dragging) return;
|
|
||||||
setDragging(false);
|
|
||||||
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
|
||||||
setIsAtEnd(prev => (prev ? false : prev));
|
|
||||||
setDragX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
|
||||||
if (endFlashTimeoutRef.current) clearTimeout(endFlashTimeoutRef.current);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
@ -131,26 +73,20 @@ export default function ConfirmSlideModal({
|
|||||||
<div className="text-xs text-soft">Slide to confirm</div>
|
<div className="text-xs text-soft">Slide to confirm</div>
|
||||||
<div
|
<div
|
||||||
ref={trackRef}
|
ref={trackRef}
|
||||||
className={`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
|
<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 }}
|
style={{ width: dragX + handleSize }}
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
className={`pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-semibold text-[color:var(--color-accent)] transition-all ${isAtEnd || endFlash ? "scale-100 opacity-100" : "scale-90 opacity-0"}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
click
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`absolute 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)` }}
|
style={{ transform: `translateX(${dragX}px)` }}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerCancel={handlePointerCancel}
|
onPointerCancel={handlePointerUp}
|
||||||
aria-label="Slide to confirm"
|
aria-label="Slide to confirm"
|
||||||
>
|
>
|
||||||
▸
|
▸
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useGroupsContext } from "@/hooks/groups-context";
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
import EntriesPanel from "@/features/entries/components/entries-panel";
|
import EntriesPanel from "@/components/entries-panel";
|
||||||
import BucketsPanel from "@/features/buckets/components/buckets-panel";
|
import BucketsPanel from "@/components/buckets-panel";
|
||||||
import { EntryMutationProvider } from "@/hooks/entry-mutation-context";
|
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 type React from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import TagInput from "@/components/tag-input";
|
import TagInput from "@/components/tag-input";
|
||||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
|
||||||
import DatePicker from "@/components/date-picker";
|
|
||||||
|
|
||||||
export type EntryDetailsForm = {
|
export type EntryDetailsForm = {
|
||||||
amountDollars: string;
|
amountDollars: string;
|
||||||
@ -13,6 +11,12 @@ export type EntryDetailsForm = {
|
|||||||
notes: string;
|
notes: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
entryType: "SPENDING" | "INCOME";
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
isRecurring: boolean;
|
||||||
|
frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY";
|
||||||
|
intervalCount: number;
|
||||||
|
endCondition: "NEVER" | "AFTER_COUNT" | "BY_DATE";
|
||||||
|
endCount: string;
|
||||||
|
endDate: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EntryDetailsModalProps = {
|
type EntryDetailsModalProps = {
|
||||||
@ -77,6 +81,7 @@ export default function EntryDetailsModal({
|
|||||||
const notesChanged = form.notes !== baseline.notes;
|
const notesChanged = form.notes !== baseline.notes;
|
||||||
const changedInputClass = "border-2 border-[color:var(--color-accent)]";
|
const changedInputClass = "border-2 border-[color:var(--color-accent)]";
|
||||||
const formRef = useRef<HTMLFormElement | null>(null);
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
const touchStartX = useRef<number | null>(null);
|
const touchStartX = useRef<number | null>(null);
|
||||||
const touchDeltaX = useRef(0);
|
const touchDeltaX = useRef(0);
|
||||||
|
|
||||||
@ -112,7 +117,10 @@ export default function EntryDetailsModal({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className="relative w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
|
className="relative w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
|
||||||
onClick={event => event.stopPropagation()}
|
onClick={event => event.stopPropagation()}
|
||||||
@ -121,14 +129,26 @@ export default function EntryDetailsModal({
|
|||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-3 items-center gap-3">
|
<div className="grid grid-cols-3 items-center gap-3">
|
||||||
<button 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">
|
<button
|
||||||
<span aria-hidden>{loopHintPrev ? "o" : "<"}</span>
|
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>
|
<span className="text-xs text-soft">{loopHintPrev ? "To Last" : "Prev"}</span>
|
||||||
</button>
|
</button>
|
||||||
<h2 className="text-center text-lg font-semibold">Entry Details</h2>
|
<h2 className="text-center text-lg font-semibold">Entry details</h2>
|
||||||
<button 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">
|
<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 className="text-xs text-soft">{loopHintNext ? "To First" : "Next"}</span>
|
||||||
<span aria-hidden>{loopHintNext ? "o" : ">"}</span>
|
<span aria-hidden>{loopHintNext ? "↻" : "→"}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
@ -144,16 +164,31 @@ export default function EntryDetailsModal({
|
|||||||
className="mt-3 grid gap-3 md:grid-cols-2"
|
className="mt-3 grid gap-3 md:grid-cols-2"
|
||||||
>
|
>
|
||||||
<div className="md:col-span-2 flex flex-wrap items-center gap-2">
|
<div className="md:col-span-2 flex flex-wrap items-center gap-2">
|
||||||
<ToggleButtonGroup
|
<button
|
||||||
value={form.entryType}
|
type="button"
|
||||||
onChange={entryType => onChange({ entryType })}
|
className={`flex h-9 w-9 items-center justify-center rounded-full border text-lg ${form.isRecurring ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
|
||||||
ariaLabel="Entry type"
|
onClick={() => onChange({ isRecurring: !form.isRecurring })}
|
||||||
sizeClassName="px-4 py-2.5 text-xs font-semibold"
|
title="Toggle Recurring Entry"
|
||||||
options={[
|
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
|
||||||
{ value: "SPENDING", label: "Spending" },
|
>
|
||||||
{ value: "INCOME", label: "Income" }
|
<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>
|
</div>
|
||||||
<label className="text-sm text-muted">
|
<label className="text-sm text-muted">
|
||||||
Amount ($)
|
Amount ($)
|
||||||
@ -169,27 +204,32 @@ export default function EntryDetailsModal({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="text-sm text-muted">
|
<div className="text-sm text-muted">
|
||||||
<DatePicker
|
<input
|
||||||
name="occurredAt"
|
name="occurredAt"
|
||||||
|
type="date"
|
||||||
|
className={`no-date-icon mt-6 w-full input-base px-3 py-2 text-sm ${dateChanged ? changedInputClass : ""} ${form.occurredAt ? "" : "border-red-400/70"}`}
|
||||||
value={form.occurredAt}
|
value={form.occurredAt}
|
||||||
onChange={occurredAt => onChange({ occurredAt })}
|
onChange={e => onChange({ occurredAt: e.target.value })}
|
||||||
required
|
required
|
||||||
className={`mt-1 ${dateChanged ? changedInputClass : ""}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted">
|
<div className="text-sm text-muted">
|
||||||
<ToggleButtonGroup
|
<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={form.necessity}
|
{([
|
||||||
onChange={necessity => onChange({ necessity })}
|
{ value: "NECESSARY", label: "Necessary" },
|
||||||
ariaLabel="Necessity"
|
{ value: "BOTH", label: "Both" },
|
||||||
className={`flex items-center gap-2 rounded-full border border-accent-weak bg-panel ${necessityChanged ? changedInputClass : ""}`}
|
{ value: "UNNECESSARY", label: "Unnecessary" }
|
||||||
sizeClassName="px-3 py-2.5 text-xs font-semibold"
|
] as const).map(option => (
|
||||||
options={[
|
<button
|
||||||
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
|
key={option.value}
|
||||||
{ value: "BOTH", label: "Both", className: "flex-1" },
|
type="button"
|
||||||
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
|
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
||||||
]}
|
onClick={() => onChange({ necessity: option.value })}
|
||||||
/>
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TagInput
|
<TagInput
|
||||||
label="Tags"
|
label="Tags"
|
||||||
@ -206,6 +246,80 @@ export default function EntryDetailsModal({
|
|||||||
onEmptySuggestionClick={onEmptyTagAction}
|
onEmptySuggestionClick={onEmptyTagAction}
|
||||||
invalid={!currentTags.length}
|
invalid={!currentTags.length}
|
||||||
/>
|
/>
|
||||||
|
{form.isRecurring ? (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="text-sm text-muted">Frequency Conditions</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-20 input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.intervalCount}
|
||||||
|
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="min-w-[120px] input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.frequency}
|
||||||
|
onChange={e => onChange({ frequency: e.target.value as EntryDetailsForm["frequency"] })}
|
||||||
|
>
|
||||||
|
<option value="DAILY">day(s)</option>
|
||||||
|
<option value="WEEKLY">week(s)</option>
|
||||||
|
<option value="BIWEEKLY">biweekly</option>
|
||||||
|
<option value="MONTHLY">month(s)</option>
|
||||||
|
<option value="QUARTERLY">quarter(s)</option>
|
||||||
|
<option value="YEARLY">year(s)</option>
|
||||||
|
</select>
|
||||||
|
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel" role="group" aria-label="End condition">
|
||||||
|
{([
|
||||||
|
{ value: "NEVER", label: "Forever" },
|
||||||
|
{ value: "BY_DATE", label: "Until" },
|
||||||
|
{ value: "AFTER_COUNT", label: "After" }
|
||||||
|
] as const).map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full px-3 py-2 text-xs font-semibold ${form.endCondition === option.value ? "btn-accent" : "text-muted"}`}
|
||||||
|
onClick={() => onChange({ endCondition: option.value })}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{form.endCondition === "AFTER_COUNT" ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-24 input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.endCount}
|
||||||
|
placeholder="Count"
|
||||||
|
onChange={e => onChange({ endCount: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{form.endCondition === "BY_DATE" ? (
|
||||||
|
<div className={`inline-flex items-center overflow-hidden rounded-full border ${form.endDate ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
|
||||||
|
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
|
||||||
|
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
|
||||||
|
if (Number.isNaN(base.getTime())) return;
|
||||||
|
base.setDate(base.getDate() - 1);
|
||||||
|
onChange({ endDate: base.toISOString().slice(0, 10) });
|
||||||
|
}}>‹</button>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="no-date-icon h-11 w-40 bg-transparent px-3 text-center text-sm text-[color:var(--color-text)] outline-none"
|
||||||
|
value={form.endDate}
|
||||||
|
onChange={e => onChange({ endDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
|
||||||
|
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
|
||||||
|
if (Number.isNaN(base.getTime())) return;
|
||||||
|
base.setDate(base.getDate() + 1);
|
||||||
|
onChange({ endDate: base.toISOString().slice(0, 10) });
|
||||||
|
}}>›</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<label className="text-sm text-muted md:col-span-2">
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
Notes
|
Notes
|
||||||
<textarea
|
<textarea
|
||||||
@ -218,7 +332,7 @@ export default function EntryDetailsModal({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="md:col-span-2 flex items-center justify-between">
|
<div className="md:col-span-2 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRevert}
|
onClick={onRevert}
|
||||||
@ -226,16 +340,41 @@ export default function EntryDetailsModal({
|
|||||||
aria-label="Revert changes"
|
aria-label="Revert changes"
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-accent-weak bg-panel text-sm hover:border-accent disabled:opacity-40 disabled:cursor-not-allowed"
|
className="flex h-9 w-9 items-center justify-center rounded-lg border border-accent-weak bg-panel text-sm hover:border-accent disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
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>
|
||||||
<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
|
Save changes
|
||||||
</button>
|
</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
|
Delete
|
||||||
</button>
|
</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
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,18 +2,16 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import useTags from "@/features/tags/hooks/use-tags";
|
import useTags from "@/hooks/use-tags";
|
||||||
import useGroupSettings from "@/features/groups/hooks/use-group-settings";
|
import useGroupSettings from "@/hooks/use-group-settings";
|
||||||
import useGroupMembers from "@/features/groups/hooks/use-group-members";
|
import useGroupMembers from "@/hooks/use-group-members";
|
||||||
import useGroupInvites from "@/features/groups/hooks/use-group-invites";
|
import useGroupInvites from "@/hooks/use-group-invites";
|
||||||
import useGroupAudit from "@/features/groups/hooks/use-group-audit";
|
import useGroupAudit from "@/hooks/use-group-audit";
|
||||||
import { useGroupsContext } from "@/hooks/groups-context";
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
import TagInput from "@/components/tag-input";
|
import TagInput from "@/components/tag-input";
|
||||||
import { useNotificationsContext } from "@/hooks/notifications-context";
|
import { useNotificationsContext } from "@/hooks/notifications-context";
|
||||||
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
||||||
import ConfirmRetypeModal from "@/components/confirm-retype-modal";
|
|
||||||
import { groupsDelete, groupsRename } from "@/lib/client/groups";
|
import { groupsDelete, groupsRename } from "@/lib/client/groups";
|
||||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
|
||||||
|
|
||||||
export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -269,24 +267,6 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|||||||
return { userId: top, count: topCount, name, searchValue };
|
return { userId: top, count: topCount, name, searchValue };
|
||||||
})();
|
})();
|
||||||
const mostActiveCount = mostActiveUser?.count ?? 0;
|
const mostActiveCount = mostActiveUser?.count ?? 0;
|
||||||
const renameDirty = Boolean(group && renameValue.trim() !== group.name);
|
|
||||||
const memberCount = members.length;
|
|
||||||
const showLeaveGroup = role !== "GROUP_OWNER" && !isLastAdmin;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!renameModalOpen && !tagModalOpen) return;
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
if (tagModalOpen) setTagModalOpen(false);
|
|
||||||
if (renameModalOpen) handleCloseRenameModal();
|
|
||||||
}
|
|
||||||
if (event.key === "Enter" && !event.shiftKey && !event.defaultPrevented) {
|
|
||||||
if (renameModalOpen && renameDirty && isAdmin && renameValue.trim()) setConfirmRenameOpen(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [renameModalOpen, tagModalOpen, renameDirty, isAdmin, renameValue, handleCloseRenameModal]);
|
|
||||||
|
|
||||||
async function handleDeleteGroup() {
|
async function handleDeleteGroup() {
|
||||||
const result = await groupsDelete();
|
const result = await groupsDelete();
|
||||||
@ -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 visibleTags = showAllTags ? tags : tags.slice(0, 5);
|
||||||
const hasMoreTags = tags.length > 5;
|
const hasMoreTags = tags.length > 5;
|
||||||
const tagsScrollable = showAllTags && tags.length > 15;
|
const tagsScrollable = showAllTags && tags.length > 15;
|
||||||
|
const memberCount = members.length;
|
||||||
|
const showLeaveGroup = role !== "GROUP_OWNER" && !isLastAdmin;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!renameModalOpen && !tagModalOpen && !confirmDeleteGroupOpen) return;
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
if (confirmDeleteGroupOpen) setConfirmDeleteGroupOpen(false);
|
||||||
|
if (tagModalOpen) setTagModalOpen(false);
|
||||||
|
if (renameModalOpen) handleCloseRenameModal();
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" && !event.shiftKey && !event.defaultPrevented) {
|
||||||
|
if (renameModalOpen && renameDirty && isAdmin && renameValue.trim()) setConfirmRenameOpen(true);
|
||||||
|
// if (tagModalOpen && pendingTags.length && canManageTags) handleSaveTags();
|
||||||
|
if (confirmDeleteGroupOpen && deleteConfirmText.trim().toUpperCase() === "DELETE") handleDeleteGroup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [renameModalOpen, tagModalOpen, confirmDeleteGroupOpen, renameDirty, isAdmin, renameValue, pendingTags.length, canManageTags, deleteConfirmText, handleDeleteGroup, handleSaveTags, handleCloseRenameModal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -424,21 +425,23 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="text-sm font-semibold">Join policy</div>
|
<div className="text-sm font-semibold">Join policy</div>
|
||||||
<ToggleButtonGroup
|
<div className="flex flex-wrap gap-2">
|
||||||
value={localJoinPolicy}
|
{[
|
||||||
onChange={policy => handleJoinPolicyChange(policy)}
|
|
||||||
ariaLabel="Join policy"
|
|
||||||
className="flex flex-wrap gap-2"
|
|
||||||
buttonBaseClassName="rounded-lg border"
|
|
||||||
sizeClassName="px-3 py-1.5 text-xs font-semibold transition"
|
|
||||||
activeClassName="border-accent bg-accent-soft"
|
|
||||||
inactiveClassName="border-accent-weak bg-panel hover:border-accent"
|
|
||||||
options={[
|
|
||||||
{ value: "NOT_ACCEPTING", label: "Disabled" },
|
{ value: "NOT_ACCEPTING", label: "Disabled" },
|
||||||
{ value: "AUTO_ACCEPT", label: "Auto" },
|
{ value: "AUTO_ACCEPT", label: "Auto" },
|
||||||
{ value: "APPROVAL_REQUIRED", label: "Manual" }
|
{ value: "APPROVAL_REQUIRED", label: "Manual" }
|
||||||
]}
|
].map(option => (
|
||||||
/>
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`rounded-lg border px-3 py-1.5 text-xs font-semibold transition ${localJoinPolicy === option.value ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel hover:border-accent"}`}
|
||||||
|
onClick={() => handleJoinPolicyChange(option.value as "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED")}
|
||||||
|
aria-pressed={localJoinPolicy === option.value}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="divider" />
|
<div className="divider" />
|
||||||
@ -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"}`}
|
className={`rounded-lg border px-3 py-2 text-sm ${link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt) ? "border-red-400/60 bg-red-500/5" : "border-accent-weak bg-panel"}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="text-xs text-soft">Token: {link.token.slice(0, 6)}...{link.token.slice(-4)}</div>
|
<div className="text-xs text-soft">Token: {link.token.slice(0, 6)}…{link.token.slice(-4)}</div>
|
||||||
{(() => {
|
<div className="flex items-center gap-2">
|
||||||
const showRevive = link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt);
|
<button
|
||||||
const showRevoke = !showRevive && !link.revokedAt && !(link.singleUse && link.usedAt) && !isInviteExpired(link.expiresAt);
|
type="button"
|
||||||
const options = [
|
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
|
||||||
{
|
onClick={() => handleCopyInvite(link.token)}
|
||||||
value: "COPY",
|
disabled={localJoinPolicy === "NOT_ACCEPTING"}
|
||||||
label: "Copy link",
|
>
|
||||||
className: "btn-outline-accent",
|
Copy link
|
||||||
disabled: localJoinPolicy === "NOT_ACCEPTING",
|
</button>
|
||||||
onClick: () => handleCopyInvite(link.token)
|
{(link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt)) ? (
|
||||||
},
|
<button
|
||||||
...(showRevive
|
type="button"
|
||||||
? [{
|
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
|
||||||
value: "REVIVE",
|
onClick={() => reviveInvite(link.id, inviteTtlDays)}
|
||||||
label: "Revive",
|
disabled={localJoinPolicy === "NOT_ACCEPTING"}
|
||||||
className: "btn-outline-accent",
|
>
|
||||||
disabled: localJoinPolicy === "NOT_ACCEPTING",
|
Revive
|
||||||
onClick: () => reviveInvite(link.id, inviteTtlDays)
|
</button>
|
||||||
}]
|
) : (!link.revokedAt && !(link.singleUse && link.usedAt) && !isInviteExpired(link.expiresAt)) ? (
|
||||||
: showRevoke
|
<button
|
||||||
? [{
|
type="button"
|
||||||
value: "REVOKE",
|
className="rounded-lg border border-red-400/60 bg-red-500/10 px-2 py-1 text-xs text-red-200"
|
||||||
label: "Revoke",
|
onClick={() => revokeInvite(link.id)}
|
||||||
className: "border border-red-400/60 bg-red-500/10 text-red-200",
|
>
|
||||||
onClick: () => revokeInvite(link.id)
|
Revoke
|
||||||
}]
|
</button>
|
||||||
: []),
|
) : null}
|
||||||
{
|
<button
|
||||||
value: "DELETE",
|
type="button"
|
||||||
label: "Delete",
|
className="rounded-lg border border-red-400/60 bg-red-500/10 px-2 py-1 text-xs text-red-200"
|
||||||
className: "border border-red-400/60 bg-red-500/10 text-red-200",
|
onClick={() => setConfirmDeleteInvite({ id: link.id, token: link.token })}
|
||||||
onClick: () => setConfirmDeleteInvite({ id: link.id, token: link.token })
|
>
|
||||||
}
|
Delete
|
||||||
];
|
</button>
|
||||||
|
</div>
|
||||||
return (
|
|
||||||
<ToggleButtonGroup
|
|
||||||
value={null}
|
|
||||||
ariaLabel="Invite actions"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
buttonBaseClassName="rounded-lg"
|
|
||||||
sizeClassName="px-2 py-1 text-xs"
|
|
||||||
activeClassName=""
|
|
||||||
inactiveClassName=""
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-3 text-[11px] text-soft">
|
<div className="mt-2 flex flex-wrap gap-3 text-[11px] text-soft">
|
||||||
<span>Expires {formatInviteExpiry(link.expiresAt)}</span>
|
<span>Expires {formatInviteExpiry(link.expiresAt)}</span>
|
||||||
@ -587,10 +577,10 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|||||||
{members.map(member => {
|
{members.map(member => {
|
||||||
const isSelf = member.userId === currentUserId;
|
const isSelf = member.userId === currentUserId;
|
||||||
const privilegeLabel = member.role === "GROUP_OWNER"
|
const privilegeLabel = member.role === "GROUP_OWNER"
|
||||||
? "Owner - Full control"
|
? "👑 Owner · Full control"
|
||||||
: member.role === "GROUP_ADMIN"
|
: member.role === "GROUP_ADMIN"
|
||||||
? "Admin - Manage members"
|
? "🛡️ Admin · Manage members"
|
||||||
: "Member - Entries only";
|
: "👤 Member · Entries only";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={member.userId}
|
key={member.userId}
|
||||||
@ -816,7 +806,7 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|||||||
onClick={handleCloseRenameModal}
|
onClick={handleCloseRenameModal}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
x
|
✕
|
||||||
</button>
|
</button>
|
||||||
<div className="text-lg font-semibold">Change group name</div>
|
<div className="text-lg font-semibold">Change group name</div>
|
||||||
<input
|
<input
|
||||||
@ -877,7 +867,9 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-lg font-semibold">Edit tags</div>
|
<div className="text-lg font-semibold">Edit tags</div>
|
||||||
<button type="button" className="rounded-lg btn-outline-accent px-2 py-1 text-sm" onClick={() => setTagModalOpen(false)} aria-label="Close">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>
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
<TagInput
|
<TagInput
|
||||||
@ -946,7 +938,7 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|||||||
<ConfirmSlideModal
|
<ConfirmSlideModal
|
||||||
isOpen={Boolean(confirmDeleteInvite)}
|
isOpen={Boolean(confirmDeleteInvite)}
|
||||||
title="Delete invite link"
|
title="Delete invite link"
|
||||||
description={confirmDeleteInvite ? `Delete invite ${confirmDeleteInvite.token.slice(0, 6)}...${confirmDeleteInvite.token.slice(-4)}?` : ""}
|
description={confirmDeleteInvite ? `Delete invite ${confirmDeleteInvite.token.slice(0, 6)}…${confirmDeleteInvite.token.slice(-4)}?` : ""}
|
||||||
confirmLabel="Delete link"
|
confirmLabel="Delete link"
|
||||||
onClose={() => setConfirmDeleteInvite(null)}
|
onClose={() => setConfirmDeleteInvite(null)}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
@ -1008,17 +1000,46 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
|
|||||||
if (ok) notify({ title: "Ownership transferred", message: target.name });
|
if (ok) notify({ title: "Ownership transferred", message: target.name });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ConfirmRetypeModal
|
{confirmDeleteGroupOpen ? (
|
||||||
isOpen={confirmDeleteGroupOpen}
|
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={() => setConfirmDeleteGroupOpen(false)}>
|
||||||
title="Delete group"
|
<div
|
||||||
description="Type DELETE to confirm. This cannot be undone."
|
className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5"
|
||||||
expectedText="DELETE"
|
onClick={event => event.stopPropagation()}
|
||||||
value={deleteConfirmText}
|
onKeyDown={event => {
|
||||||
onChange={setDeleteConfirmText}
|
if (event.key === "Escape") setConfirmDeleteGroupOpen(false);
|
||||||
confirmLabel="Delete"
|
if (event.key === "Enter" && deleteConfirmText.trim().toUpperCase() === "DELETE") handleDeleteGroup();
|
||||||
onClose={() => setConfirmDeleteGroupOpen(false)}
|
}}
|
||||||
onConfirm={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>
|
</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"
|
className="w-full rounded-lg btn-outline-accent px-3 py-2 text-xs font-semibold"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUserMenuOpen(false);
|
setUserMenuOpen(false);
|
||||||
router.push("/settings");
|
if (activeGroupId) router.push("/groups/settings");
|
||||||
|
else router.push("/");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Settings
|
Settings
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import type React from "react";
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import TagInput from "@/components/tag-input";
|
import TagInput from "@/components/tag-input";
|
||||||
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
||||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
|
||||||
|
|
||||||
type BucketForm = {
|
type BucketForm = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -25,11 +24,9 @@ type NewBucketModalProps = {
|
|||||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||||
onChange: (next: Partial<BucketForm>) => void;
|
onChange: (next: Partial<BucketForm>) => void;
|
||||||
tagSuggestions: string[];
|
tagSuggestions: string[];
|
||||||
canDelete?: boolean;
|
|
||||||
onDelete?: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NewBucketModal({ isOpen, title, form, error, onClose, onSubmit, onChange, tagSuggestions, canDelete = false, onDelete }: NewBucketModalProps) {
|
export default function NewBucketModal({ isOpen, title, form, error, onClose, onSubmit, onChange, tagSuggestions }: NewBucketModalProps) {
|
||||||
const [iconModalOpen, setIconModalOpen] = useState(false);
|
const [iconModalOpen, setIconModalOpen] = useState(false);
|
||||||
const [iconSearch, setIconSearch] = useState("");
|
const [iconSearch, setIconSearch] = useState("");
|
||||||
const formRef = useRef<HTMLFormElement | null>(null);
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
@ -128,18 +125,22 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="text-sm text-muted md:col-span-2">
|
<div className="text-sm text-muted md:col-span-2">
|
||||||
<ToggleButtonGroup
|
<div className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel" role="group" aria-label="Necessity">
|
||||||
value={form.necessity}
|
{([
|
||||||
onChange={necessity => onChange({ necessity })}
|
{ value: "NECESSARY", label: "Necessary" },
|
||||||
ariaLabel="Necessity"
|
{ value: "BOTH", label: "Both" },
|
||||||
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
|
{ value: "UNNECESSARY", label: "Unnecessary" }
|
||||||
sizeClassName="px-3 py-2.5 text-xs font-semibold"
|
] as const).map(option => (
|
||||||
options={[
|
<button
|
||||||
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
|
key={option.value}
|
||||||
{ value: "BOTH", label: "Both", className: "flex-1" },
|
type="button"
|
||||||
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
|
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
||||||
]}
|
onClick={() => onChange({ necessity: option.value })}
|
||||||
/>
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="text-sm text-muted md:col-span-2">
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
Description
|
Description
|
||||||
@ -159,22 +160,11 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on
|
|||||||
onToggleTag={tag => onChange({ tags: form.tags.filter(item => item !== tag) })}
|
onToggleTag={tag => onChange({ tags: form.tags.filter(item => item !== tag) })}
|
||||||
onAddTag={tag => onChange({ tags: [...form.tags, tag] })}
|
onAddTag={tag => onChange({ tags: [...form.tags, tag] })}
|
||||||
/>
|
/>
|
||||||
<div className="md:col-span-2 flex items-center justify-between 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}
|
{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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,8 +3,6 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import TagInput from "@/components/tag-input";
|
import TagInput from "@/components/tag-input";
|
||||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
|
||||||
import DatePicker from "@/components/date-picker";
|
|
||||||
|
|
||||||
type NewEntryForm = {
|
type NewEntryForm = {
|
||||||
amountDollars: string;
|
amountDollars: string;
|
||||||
@ -13,6 +11,12 @@ type NewEntryForm = {
|
|||||||
notes: string;
|
notes: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
entryType: "SPENDING" | "INCOME";
|
entryType: "SPENDING" | "INCOME";
|
||||||
|
isRecurring: boolean;
|
||||||
|
frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY";
|
||||||
|
intervalCount: number;
|
||||||
|
endCondition: "NEVER" | "AFTER_COUNT" | "BY_DATE";
|
||||||
|
endCount: string;
|
||||||
|
endDate: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type NewEntryModalProps = {
|
type NewEntryModalProps = {
|
||||||
@ -31,6 +35,7 @@ type NewEntryModalProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit, onChange, tagSuggestions, emptyTagActionLabel, emptyTagActionDisabled = false, onEmptyTagAction, amountInputRef, tagsInputRef }: NewEntryModalProps) {
|
export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit, onChange, tagSuggestions, emptyTagActionLabel, emptyTagActionDisabled = false, onEmptyTagAction, amountInputRef, tagsInputRef }: NewEntryModalProps) {
|
||||||
|
const recurrenceLabel = form.isRecurring ? "Recurring" : "One-Time";
|
||||||
const typeLabel = form.entryType === "INCOME" ? "Income" : "Expense";
|
const typeLabel = form.entryType === "INCOME" ? "Income" : "Expense";
|
||||||
const formRef = useRef<HTMLFormElement | null>(null);
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
@ -42,12 +47,23 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
|
|
||||||
if (!form.occurredAt) {
|
if (!form.occurredAt) {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
onChange({ occurredAt: today });
|
onChange({ occurredAt: today, endDate: form.endDate || today });
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@ -61,28 +77,44 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
onClick={event => event.stopPropagation()}
|
onClick={event => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">New {typeLabel} Entry</h2>
|
<h2 className="text-lg font-semibold">New {recurrenceLabel} {typeLabel} Entry</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
x
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<ToggleButtonGroup
|
<div
|
||||||
value={form.entryType}
|
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel">
|
||||||
onChange={entryType => onChange({ entryType })}
|
<button
|
||||||
ariaLabel="Entry type"
|
type="button"
|
||||||
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel"
|
className={`rounded-full mr-[-10px] px-4 py-2.5 text-xs font-semibold ${form.entryType === "SPENDING" ? "btn-accent" : "text-muted"}`}
|
||||||
sizeClassName="px-4 py-2.5 text-xs font-semibold"
|
onClick={() => onChange({ entryType: form.entryType === "SPENDING" ? "INCOME" : "SPENDING" })}
|
||||||
options={[
|
>
|
||||||
{ value: "SPENDING", label: "Spending", className: "mr-[-10px]" },
|
Spending
|
||||||
{ value: "INCOME", label: "Income" }
|
</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>
|
</div>
|
||||||
<form
|
<form
|
||||||
ref={formRef}
|
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"
|
className="mt-3 grid gap-3 md:grid-cols-2"
|
||||||
>
|
>
|
||||||
<label className="text-sm text-muted">
|
<label className="text-sm text-muted">
|
||||||
|
{/* Amount ($) */}
|
||||||
<div className="relative mt-1">
|
<div className="relative mt-1">
|
||||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-soft">
|
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-soft">
|
||||||
{form.entryType === "INCOME" ? "$" : "$"}
|
{form.entryType === "INCOME" ? "🤑 $" : "😭 $"}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
ref={amountInputRef}
|
ref={amountInputRef}
|
||||||
@ -115,28 +148,41 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<div className="text-sm text-muted">
|
<div className="text-sm text-muted">
|
||||||
<DatePicker
|
<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`}>
|
||||||
name="occurredAt"
|
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(-7)}>«</button>
|
||||||
value={form.occurredAt}
|
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(-1)}>‹</button>
|
||||||
onChange={occurredAt => onChange({ occurredAt })}
|
<input
|
||||||
required
|
name="occurredAt"
|
||||||
className="mt-1"
|
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>
|
||||||
<div className="text-sm text-muted">
|
<div className="text-sm text-muted">
|
||||||
<ToggleButtonGroup
|
<div className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel" role="group" aria-label="Necessity">
|
||||||
value={form.necessity}
|
{([
|
||||||
onChange={necessity => onChange({ necessity })}
|
{ value: "NECESSARY", label: "Necessary" },
|
||||||
ariaLabel="Necessity"
|
{ value: "BOTH", label: "Both" },
|
||||||
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
|
{ value: "UNNECESSARY", label: "Unnecessary" }
|
||||||
sizeClassName="px-3 py-2.5 text-xs font-semibold"
|
] as const).map(option => (
|
||||||
options={[
|
<button
|
||||||
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
|
key={option.value}
|
||||||
{ value: "BOTH", label: "Both", className: "flex-1" },
|
type="button"
|
||||||
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
|
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
||||||
]}
|
onClick={() => onChange({ necessity: option.value })}
|
||||||
/>
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* TAGS */}
|
||||||
<TagInput
|
<TagInput
|
||||||
label="Tags"
|
label="Tags"
|
||||||
tags={form.tags}
|
tags={form.tags}
|
||||||
@ -150,6 +196,80 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
invalid={!form.tags.length}
|
invalid={!form.tags.length}
|
||||||
inputRef={tagsInputRef}
|
inputRef={tagsInputRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* RECURRING OPTIONS */}
|
||||||
|
{form.isRecurring ? (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="text-sm text-muted">Frequency Conditions</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-14 input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.intervalCount}
|
||||||
|
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="w-20 min-w-[110px] input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.frequency}
|
||||||
|
onChange={e => onChange({ frequency: e.target.value as NewEntryForm["frequency"] })}
|
||||||
|
>
|
||||||
|
<option value="DAILY">day(s)</option>
|
||||||
|
<option value="WEEKLY">week(s)</option>
|
||||||
|
<option value="MONTHLY">month(s)</option>
|
||||||
|
<option value="YEARLY">year(s)</option>
|
||||||
|
</select>
|
||||||
|
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel" role="group" aria-label="End condition">
|
||||||
|
{([
|
||||||
|
{ value: "NEVER", label: "Forever" },
|
||||||
|
{ value: "BY_DATE", label: "Until" },
|
||||||
|
{ value: "AFTER_COUNT", label: "After" }
|
||||||
|
] as const).map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full px-3 py-3 text-xs font-semibold ${form.endCondition === option.value ? "btn-accent" : "text-muted"}`}
|
||||||
|
onClick={() => onChange({ endCondition: option.value })}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{form.endCondition === "AFTER_COUNT" ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-24 input-base px-3 py-2 text-center text-sm"
|
||||||
|
value={form.endCount}
|
||||||
|
placeholder="Count"
|
||||||
|
onChange={e => onChange({ endCount: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{form.endCondition === "BY_DATE" ? (
|
||||||
|
<div className={`inline-flex items-center overflow-hidden rounded-full border ${form.endDate ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
|
||||||
|
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
|
||||||
|
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
|
||||||
|
if (Number.isNaN(base.getTime())) return;
|
||||||
|
base.setDate(base.getDate() - 1);
|
||||||
|
onChange({ endDate: base.toISOString().slice(0, 10) });
|
||||||
|
}}>‹</button>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="no-date-icon h-11 w-40 bg-transparent px-3 text-center text-sm text-[color:var(--color-text)] outline-none"
|
||||||
|
value={form.endDate}
|
||||||
|
onChange={e => onChange({ endDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
|
||||||
|
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
|
||||||
|
if (Number.isNaN(base.getTime())) return;
|
||||||
|
base.setDate(base.getDate() + 1);
|
||||||
|
onChange({ endDate: base.toISOString().slice(0, 10) });
|
||||||
|
}}>›</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<label className="text-sm text-muted md:col-span-2">
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
Notes
|
Notes
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@ -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>) {
|
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
if (false && event.key === "Backspace" && !value && tags.length) {
|
if (false && event.key === "Backspace" && !value && tags.length) {
|
||||||
|
console.log("Backspace pressed with empty input, removing last tag");
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onToggleTag?.(tags[tags.length - 1]);
|
onToggleTag?.(tags[tags.length - 1]);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -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 }) => {
|
test("login shows entries for seeded owner", async ({ page }) => {
|
||||||
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
await expect(page.getByRole("button", { name: "Entries" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Entries" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("no-group user sees empty state", async ({ page }) => {
|
test("no-group user sees empty state", async ({ page }) => {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ test("group dropdown lists seeded groups", async ({ page }) => {
|
|||||||
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
const dropdown = page.getByRole("button", { name: /▼$/ });
|
const dropdown = page.getByRole("button", { name: /Group:/ });
|
||||||
await dropdown.click();
|
await dropdown.click();
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Alpha Household GROUP_ADMIN" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Alpha Household GROUP_ADMIN" })).toBeVisible();
|
||||||
@ -18,7 +18,7 @@ test("group settings show join requests and policy", async ({ page }) => {
|
|||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Group settings" }).click();
|
await page.getByRole("button", { name: "Group settings" }).click();
|
||||||
await expect(page).toHaveURL(/\/groups\/settings/);
|
await expect(page).toHaveURL(/\/groups\/[0-9]+\/settings/);
|
||||||
|
|
||||||
await expect(page.getByText("Join requests")).toBeVisible();
|
await expect(page.getByText("Join requests")).toBeVisible();
|
||||||
await expect(page.getByText("requester1@fiddy.dev")).toBeVisible();
|
await expect(page.getByText("requester1@fiddy.dev")).toBeVisible();
|
||||||
|
|||||||
@ -5,18 +5,18 @@ test("seeded entries render with tags and no-tag state", async ({ page }) => {
|
|||||||
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Entries" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Entries" })).toBeVisible();
|
||||||
await expect(page.getByText("$12.50").first()).toBeVisible();
|
await expect(page.getByText("$12.50")).toBeVisible();
|
||||||
await expect(page.locator("span:visible", { hasText: "#Food" }).first()).toBeVisible();
|
await expect(page.getByText("#Food")).toBeVisible();
|
||||||
await expect(page.locator("span:visible", { hasText: "#Travel" }).first()).toBeVisible();
|
await expect(page.getByText("#Travel")).toBeVisible();
|
||||||
await expect(page.locator("span:visible", { hasText: "No tags" }).first()).toBeVisible();
|
await expect(page.getByText("No tags")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("entry details modal opens", async ({ page }) => {
|
test("entry details modal opens", async ({ page }) => {
|
||||||
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
await page.getByText("$12.50").first().click();
|
await page.getByText("$12.50").click();
|
||||||
await expect(page.getByRole("heading", { name: "Entry details" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Entry details" })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Close" }).click();
|
await page.getByRole("button", { name: "Close" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Entry details" })).toBeHidden();
|
await expect(page.getByRole("heading", { name: "Entry details" })).toBeHidden();
|
||||||
@ -26,14 +26,13 @@ test("empty tag callout shows contact admin for members", async ({ page }) => {
|
|||||||
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
const dropdown = page.getByRole("button", { name: /▼$/ });
|
const dropdown = page.getByRole("button", { name: /Group:/ });
|
||||||
await dropdown.click();
|
await dropdown.click();
|
||||||
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
||||||
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
||||||
await waitSetActive;
|
await waitSetActive;
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Add entry" }).click();
|
await page.getByRole("button", { name: "Add entry" }).click();
|
||||||
await page.getByPlaceholder("Add tags... (who, where, why, etc.)").fill("missing");
|
|
||||||
const callout = page.getByRole("button", { name: "No Tags Assigned Yet - Contact Your Group Admin" });
|
const callout = page.getByRole("button", { name: "No Tags Assigned Yet - Contact Your Group Admin" });
|
||||||
await expect(callout).toBeVisible();
|
await expect(callout).toBeVisible();
|
||||||
await expect(callout).toBeDisabled();
|
await expect(callout).toBeDisabled();
|
||||||
@ -43,14 +42,13 @@ test("empty tag callout navigates to settings for admins", async ({ page }) => {
|
|||||||
await login(page, "member1@fiddy.dev", "FiddyDev123!");
|
await login(page, "member1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
const dropdown = page.getByRole("button", { name: /▼$/ });
|
const dropdown = page.getByRole("button", { name: /Group:/ });
|
||||||
await dropdown.click();
|
await dropdown.click();
|
||||||
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
||||||
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
||||||
await waitSetActive;
|
await waitSetActive;
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Add entry" }).click();
|
await page.getByRole("button", { name: "Add entry" }).click();
|
||||||
await page.getByPlaceholder("Add tags... (who, where, why, etc.)").fill("missing");
|
|
||||||
await page.getByRole("button", { name: "No Tags Assigned Yet - Click To Assign Tags" }).click();
|
await page.getByRole("button", { name: "No Tags Assigned Yet - Click To Assign Tags" }).click();
|
||||||
await expect(page).toHaveURL(/\/groups\/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";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext } from "react";
|
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);
|
const AuthContext = createContext<ReturnType<typeof useAuth> | null>(null);
|
||||||
|
|
||||||
@ -19,4 +19,3 @@ export function useAuthContext() {
|
|||||||
if (!ctx) throw new Error("AuthProvider is missing");
|
if (!ctx) throw new Error("AuthProvider is missing");
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext } from "react";
|
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);
|
const GroupsContext = createContext<ReturnType<typeof useGroups> | null>(null);
|
||||||
|
|
||||||
@ -19,4 +19,3 @@ export function useGroupsContext() {
|
|||||||
if (!ctx) throw new Error("GroupsProvider is missing");
|
if (!ctx) throw new Error("GroupsProvider is missing");
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,13 @@ type CreateEntryInput = {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
bucketId?: number | null;
|
bucketId?: number | null;
|
||||||
|
isRecurring?: boolean;
|
||||||
|
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
||||||
|
intervalCount?: number;
|
||||||
|
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
||||||
|
endCount?: number | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
nextRunAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UpdateEntryInput = CreateEntryInput & { id: number };
|
type UpdateEntryInput = CreateEntryInput & { id: number };
|
||||||
@ -56,7 +63,7 @@ export default function useEntries(activeGroupId?: number | null) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [activeGroupId]);
|
}, [activeGroupId]);
|
||||||
|
|
||||||
const createEntry = useCallback(async (input: CreateEntryInput): Promise<Entry | null> => {
|
const createEntry = useCallback(async (input: CreateEntryInput) => {
|
||||||
setError("");
|
setError("");
|
||||||
const result = await entriesCreate(input);
|
const result = await entriesCreate(input);
|
||||||
if (isError(result)) {
|
if (isError(result)) {
|
||||||
@ -68,7 +75,7 @@ export default function useEntries(activeGroupId?: number | null) {
|
|||||||
return created;
|
return created;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateEntry = useCallback(async (input: UpdateEntryInput): Promise<Entry | null> => {
|
const updateEntry = useCallback(async (input: UpdateEntryInput) => {
|
||||||
setError("");
|
setError("");
|
||||||
const result = await entriesUpdate(input);
|
const result = await entriesUpdate(input);
|
||||||
if (isError(result)) {
|
if (isError(result)) {
|
||||||
@ -82,7 +89,7 @@ export default function useEntries(activeGroupId?: number | null) {
|
|||||||
return updated;
|
return updated;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const deleteEntry = useCallback(async (id: number | string): Promise<Entry | null> => {
|
const deleteEntry = useCallback(async (id: number | string) => {
|
||||||
setError("");
|
setError("");
|
||||||
const numericId = Number(id);
|
const numericId = Number(id);
|
||||||
if (!Number.isFinite(numericId) || numericId <= 0) return null;
|
if (!Number.isFinite(numericId) || numericId <= 0) return null;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user