diff --git a/apps/server/AGENTS.md b/apps/server/AGENTS.md index 7515fa7..1e811ed 100644 --- a/apps/server/AGENTS.md +++ b/apps/server/AGENTS.md @@ -18,6 +18,7 @@ TanStack Start fullstack web app with ORPC (contract-first RPC) and shadcn/ui. - **Hotkeys**: @tanstack/react-hotkeys (`useHotkey` for type-safe keyboard shortcuts) - **Animation**: motion (page transitions, staggered entrance, layout animation) - **Command Palette**: cmdk (via shadcn Command component, triggered by ⌘K) +- **API Key**: @better-auth/api-key (API key auth for external integrations like N8N) - **Build**: Vite + Nitro ## Architecture Overview @@ -27,6 +28,8 @@ TanStack Start fullstack web app with ORPC (contract-first RPC) and shadcn/ui. ``` / → Dashboard overview (greeting, quick bookmarks, module summary) /bookmarks → Bookmarks page (view mode + edit mode toggle) +/finance → Finance page (transaction list with filters, account/category management) +/settings → Settings page (API key management) /setup → One-time owner setup (first visit only, redirects to /login after) /login → Login page (redirects to /setup if no owner exists) ``` @@ -56,7 +59,7 @@ src/ ├── client/ │ └── orpc.ts # ORPC client (isomorphic: SSR direct call / CSR fetch) ├── components/ -│ ├── AppSidebar.tsx # Unified sidebar (reads module registry, collapsible) +│ ├── AppSidebar.tsx # Unified sidebar (reads module registry, collapsible, includes Settings link in footer) │ ├── CommandPalette.tsx # ⌘K command palette (search bookmarks, engines, navigation) │ ├── Error.tsx # Error boundary fallback │ ├── NotFound.tsx # 404 fallback @@ -67,7 +70,19 @@ src/ │ └── utils.ts # cn() utility ├── modules/ │ ├── registry.ts # ModuleMetadata interface + modules[] -│ └── bookmarks/ # Bookmarks module (schema, contract, router, components/) +│ ├── bookmarks/ # Bookmarks module (schema, contract, router, components/) +│ └── finance/ # Finance module +│ ├── index.ts # Module metadata +│ ├── schema.ts # Drizzle tables (financeAccount, transactionCategory, transaction) +│ ├── contract.ts # ORPC contracts (account, category, transaction CRUD + summary) +│ ├── router.ts # ORPC handlers +│ └── components/ # React UI components +│ ├── AccountFormDialog.tsx +│ ├── AccountManager.tsx +│ ├── CategoryFormDialog.tsx +│ ├── CategoryManager.tsx +│ ├── TransactionFormDialog.tsx +│ └── TransactionList.tsx ├── cli/ │ ├── index.ts # citty CLI entrypoint (bun run cli ...) │ └── commands/auth.ts # auth reset-password command @@ -76,7 +91,9 @@ src/ │ ├── _protected.tsx # Auth guard + unified shell (SidebarProvider, AppSidebar, CommandPalette) │ ├── _protected/ │ │ ├── index.tsx # Dashboard overview (greeting, quick bookmarks, module cards) -│ │ └── bookmarks.tsx # Bookmarks page (view/edit toggle, Motion animations) +│ │ ├── bookmarks.tsx # Bookmarks page (view/edit toggle, Motion animations) +│ │ ├── finance.tsx # Finance page (transaction list, filters, account/category management) +│ │ └── settings.tsx # Settings page (API key management) │ ├── login.tsx, setup.tsx │ └── api/ # $.ts (OpenAPI), auth.$.ts, health.ts, rpc.$.ts ├── server/ @@ -340,6 +357,49 @@ Kairos is a self-hosted single-user app. There is NO public registration. The fi - **ORPC middleware**: `authMiddleware` calls `auth.api.getSession({ headers })` → injects `context.user` - **Password reset**: Via server CLI only (`bun run cli auth reset-password`) — no web-based password recovery +### API Key Authentication + +- **Plugin**: `@better-auth/api-key` with `enableSessionForAPIKeys: true` +- **Auth middleware**: Unchanged — API keys automatically simulate sessions via Better Auth plugin +- **Header**: `x-api-key: kairos_xxxxxxxx` (or `Authorization: Bearer kairos_xxxxxxxx`) +- **Management**: Via `/settings` page UI (create, list, delete) +- **Use case**: N8N workflow automation → parse emails → call ORPC API to create transactions +- **Default prefix**: `kairos_` + +## Finance Module + +### Data Model + +- **financeAccount**: Bank accounts, wallets, credit cards. Fields: name, type (checking/savings/credit/cash/investment/loan), currencyCode (ISO 4217, default CNY), initialBalance (integer, smallest currency unit), icon, isArchived, orderId +- **transactionCategory**: Flat expense/income categories. Fields: name, icon, type (expense/income), orderId +- **transaction**: Financial records. Fields: accountId, categoryId (nullable), type (expense/income), amount (integer, positive, smallest currency unit), description, note, date, source (manual/n8n/import), externalId (unique, for N8N dedup) + +### Amount Convention + +- Stored as **integer in smallest currency unit** (e.g., ¥120.30 = 12030) +- Always **positive** — type field determines income vs expense +- Display: `(amount / 100).toFixed(2)` with currency symbol + +### N8N Integration + +External services call ORPC API with API key: + +```bash +curl -X POST https://kairos.example/api/rpc/finance.transaction.create \ + -H "x-api-key: kairos_xxxxxxxx" \ + -H "Content-Type: application/json" \ + -d '{"accountId":"...","type":"expense","amount":3500,"description":"午餐","date":"2026-04-01T12:30:00Z","source":"n8n","externalId":"email-abc123"}' +``` + +- `externalId` enables idempotent deduplication — duplicate imports return existing transaction +- `source: "n8n"` marks automated imports + +### ORPC Endpoints + +- `finance.account.list/create/update/remove/reorder` +- `finance.category.list/create/update/remove/reorder` +- `finance.transaction.list/create/update/remove/summary` + ## Critical Rules **DO:** @@ -367,3 +427,7 @@ Kairos is a self-hosted single-user app. There is NO public registration. The fi - Use `@/*` aliases in Drizzle schema files (drizzle-kit can't resolve them) - Add registration/signup functionality (single-owner model, enforced by `databaseHooks`) - Create separate admin pages — integrate view/edit modes in each module page +- Use `authClient.apiKey` methods for API key management (not ORPC) +- Store amount as float/decimal — amounts MUST be integer (smallest currency unit) +- Duplicate `externalId` on transactions — must be unique (for N8N dedup) +- Try to retrieve API key after creation — keys are shown only once diff --git a/apps/server/drizzle/0001_special_titanium_man.sql b/apps/server/drizzle/0001_special_titanium_man.sql new file mode 100644 index 0000000..8d7829d --- /dev/null +++ b/apps/server/drizzle/0001_special_titanium_man.sql @@ -0,0 +1,81 @@ +CREATE TABLE "apikey" ( + "id" text PRIMARY KEY NOT NULL, + "config_id" text DEFAULT 'default' NOT NULL, + "name" text, + "start" text, + "reference_id" text NOT NULL, + "prefix" text, + "key" text NOT NULL, + "refill_interval" integer, + "refill_amount" integer, + "last_refill_at" timestamp, + "enabled" boolean DEFAULT true, + "rate_limit_enabled" boolean DEFAULT true, + "rate_limit_time_window" integer DEFAULT 86400000, + "rate_limit_max" integer DEFAULT 10, + "request_count" integer DEFAULT 0, + "remaining" integer, + "last_request" timestamp, + "expires_at" timestamp, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + "permissions" text, + "metadata" text +); +--> statement-breakpoint +CREATE TABLE "finance_account" ( + "id" uuid PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "name" text NOT NULL, + "type" text DEFAULT 'checking' NOT NULL, + "currency_code" text DEFAULT 'CNY' NOT NULL, + "initial_balance" integer DEFAULT 0 NOT NULL, + "icon" text, + "is_archived" boolean DEFAULT false NOT NULL, + "order_id" integer DEFAULT 0 NOT NULL, + "user_id" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "transaction" ( + "id" uuid PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "account_id" uuid NOT NULL, + "category_id" uuid, + "type" text DEFAULT 'expense' NOT NULL, + "amount" integer NOT NULL, + "description" text NOT NULL, + "note" text, + "date" timestamp with time zone DEFAULT now() NOT NULL, + "source" text DEFAULT 'manual' NOT NULL, + "external_id" text, + "user_id" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "transaction_category" ( + "id" uuid PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "name" text NOT NULL, + "icon" text, + "type" text DEFAULT 'expense' NOT NULL, + "order_id" integer DEFAULT 0 NOT NULL, + "user_id" text NOT NULL +); +--> statement-breakpoint +ALTER TABLE "finance_account" ADD CONSTRAINT "finance_account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "transaction" ADD CONSTRAINT "transaction_account_id_finance_account_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."finance_account"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "transaction" ADD CONSTRAINT "transaction_category_id_transaction_category_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."transaction_category"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "transaction" ADD CONSTRAINT "transaction_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "transaction_category" ADD CONSTRAINT "transaction_category_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "apikey_configId_idx" ON "apikey" USING btree ("config_id");--> statement-breakpoint +CREATE INDEX "apikey_referenceId_idx" ON "apikey" USING btree ("reference_id");--> statement-breakpoint +CREATE INDEX "apikey_key_idx" ON "apikey" USING btree ("key");--> statement-breakpoint +CREATE INDEX "finance_account_user_id_idx" ON "finance_account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "transaction_user_id_idx" ON "transaction" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "transaction_account_id_idx" ON "transaction" USING btree ("account_id");--> statement-breakpoint +CREATE INDEX "transaction_category_id_idx" ON "transaction" USING btree ("category_id");--> statement-breakpoint +CREATE INDEX "transaction_date_idx" ON "transaction" USING btree ("date");--> statement-breakpoint +CREATE UNIQUE INDEX "transaction_external_id_idx" ON "transaction" USING btree ("external_id");--> statement-breakpoint +CREATE INDEX "transaction_category_user_id_idx" ON "transaction_category" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/server/drizzle/meta/0001_snapshot.json b/apps/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..6e78072 --- /dev/null +++ b/apps/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1134 @@ +{ + "id": "7fd71f10-dabd-41d1-a379-05827419d0c7", + "prevId": "e52e0416-6f56-4223-9016-44087dd17d11", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikey_configId_idx": { + "name": "apikey_configId_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_referenceId_idx": { + "name": "apikey_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bookmark": { + "name": "bookmark", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "bookmark_category_id_category_id_fk": { + "name": "bookmark_category_id_category_id_fk", + "tableFrom": "bookmark", + "tableTo": "category", + "columnsFrom": ["category_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmark_user_id_user_id_fk": { + "name": "bookmark_user_id_user_id_fk", + "tableFrom": "bookmark", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category": { + "name": "category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "category_user_id_user_id_fk": { + "name": "category_user_id_user_id_fk", + "tableFrom": "category", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.finance_account": { + "name": "finance_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'checking'" + }, + "currency_code": { + "name": "currency_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'CNY'" + }, + "initial_balance": { + "name": "initial_balance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "finance_account_user_id_idx": { + "name": "finance_account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "finance_account_user_id_user_id_fk": { + "name": "finance_account_user_id_user_id_fk", + "tableFrom": "finance_account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transaction": { + "name": "transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'expense'" + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "transaction_user_id_idx": { + "name": "transaction_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transaction_account_id_idx": { + "name": "transaction_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transaction_category_id_idx": { + "name": "transaction_category_id_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transaction_date_idx": { + "name": "transaction_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transaction_external_id_idx": { + "name": "transaction_external_id_idx", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transaction_account_id_finance_account_id_fk": { + "name": "transaction_account_id_finance_account_id_fk", + "tableFrom": "transaction", + "tableTo": "finance_account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transaction_category_id_transaction_category_id_fk": { + "name": "transaction_category_id_transaction_category_id_fk", + "tableFrom": "transaction", + "tableTo": "transaction_category", + "columnsFrom": ["category_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "transaction_user_id_user_id_fk": { + "name": "transaction_user_id_user_id_fk", + "tableFrom": "transaction", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transaction_category": { + "name": "transaction_category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'expense'" + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "transaction_category_user_id_idx": { + "name": "transaction_category_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transaction_category_user_id_user_id_fk": { + "name": "transaction_category_user_id_user_id_fk", + "tableFrom": "transaction_category", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 9fc1e9e..b5d08b1 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1774958187626, "tag": "0000_tricky_stick", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1774980527146, + "tag": "0001_special_titanium_man", + "breakpoints": true } ] } diff --git a/apps/server/package.json b/apps/server/package.json index 31c832e..c5b769a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@base-ui/react": "catalog:", + "@better-auth/api-key": "^1.5.6", "@dnd-kit/dom": "catalog:", "@dnd-kit/helpers": "catalog:", "@dnd-kit/react": "catalog:", @@ -46,6 +47,7 @@ "class-variance-authority": "catalog:", "clsx": "catalog:", "cmdk": "^1.1.1", + "date-fns": "^4.1.0", "drizzle-orm": "catalog:", "drizzle-zod": "catalog:", "lucide-react": "catalog:", @@ -53,6 +55,7 @@ "next-themes": "catalog:", "postgres": "catalog:", "react": "catalog:", + "react-day-picker": "^9.14.0", "react-dom": "catalog:", "shadcn": "^4.1.1", "sonner": "catalog:", diff --git a/apps/server/src/components/AppSidebar.tsx b/apps/server/src/components/AppSidebar.tsx index 6043c7e..b0258fc 100644 --- a/apps/server/src/components/AppSidebar.tsx +++ b/apps/server/src/components/AppSidebar.tsx @@ -1,7 +1,7 @@ import { Link, useRouter, useRouterState } from '@tanstack/react-router' import type { LucideIcon } from 'lucide-react' import * as LucideIcons from 'lucide-react' -import { Circle, Home, LogOut, Search } from 'lucide-react' +import { Circle, Home, LogOut, Search, Settings } from 'lucide-react' import { Sidebar, SidebarContent, @@ -99,6 +99,16 @@ export const AppSidebar = ({ onOpenCommandPalette }: { onOpenCommandPalette: () + + } + isActive={currentPath === '/settings'} + tooltip="设置" + > + + 设置 + + diff --git a/apps/server/src/components/ui/calendar.tsx b/apps/server/src/components/ui/calendar.tsx new file mode 100644 index 0000000..1bd5b93 --- /dev/null +++ b/apps/server/src/components/ui/calendar.tsx @@ -0,0 +1,170 @@ +'use client' + +import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react' +import * as React from 'react' +import { type DayButton, DayPicker, getDefaultClassNames, type Locale } from 'react-day-picker' +import { Button, buttonVariants } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = 'label', + buttonVariant = 'ghost', + locale, + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps['variant'] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className, + )} + captionLayout={captionLayout} + locale={locale} + formatters={{ + formatMonthDropdown: (date) => date.toLocaleString(locale?.code, { month: 'short' }), + ...formatters, + }} + classNames={{ + root: cn('w-fit', defaultClassNames.root), + months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months), + month: cn('flex w-full flex-col gap-4', defaultClassNames.month), + nav: cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', defaultClassNames.nav), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50', + defaultClassNames.button_previous, + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50', + defaultClassNames.button_next, + ), + month_caption: cn( + 'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)', + defaultClassNames.month_caption, + ), + dropdowns: cn( + 'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium', + defaultClassNames.dropdowns, + ), + dropdown_root: cn('relative rounded-(--cell-radius)', defaultClassNames.dropdown_root), + dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown), + caption_label: cn( + 'font-medium select-none', + captionLayout === 'label' + ? 'text-sm' + : 'flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground', + defaultClassNames.caption_label, + ), + table: 'w-full border-collapse', + weekdays: cn('flex', defaultClassNames.weekdays), + weekday: cn( + 'flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none', + defaultClassNames.weekday, + ), + week: cn('mt-2 flex w-full', defaultClassNames.week), + week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header), + week_number: cn('text-[0.8rem] text-muted-foreground select-none', defaultClassNames.week_number), + day: cn( + 'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)', + props.showWeekNumber + ? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)' + : '[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)', + defaultClassNames.day, + ), + range_start: cn( + 'relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted', + defaultClassNames.range_start, + ), + range_middle: cn('rounded-none', defaultClassNames.range_middle), + range_end: cn( + 'relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted', + defaultClassNames.range_end, + ), + today: cn( + 'rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none', + defaultClassNames.today, + ), + outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside), + disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled), + hidden: cn('invisible', defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return
+ }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === 'left') { + return + } + + if (orientation === 'right') { + return + } + + return + }, + DayButton: ({ ...props }) => , + WeekNumber: ({ children, ...props }) => { + return ( + +
{children}
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + + + + + + ) +} diff --git a/apps/server/src/modules/finance/components/AccountManager.tsx b/apps/server/src/modules/finance/components/AccountManager.tsx new file mode 100644 index 0000000..9cee435 --- /dev/null +++ b/apps/server/src/modules/finance/components/AccountManager.tsx @@ -0,0 +1,177 @@ +import { move } from '@dnd-kit/helpers' +import { DragDropProvider } from '@dnd-kit/react' +import { useSortable } from '@dnd-kit/react/sortable' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { GripVertical, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { orpc } from '@/client/orpc' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import { AccountFormDialog } from './AccountFormDialog' + +interface Account { + id: string + name: string + type: string + currencyCode: string + initialBalance: number + icon: string | null + orderId: number +} + +interface AccountManagerProps { + accounts: Account[] +} + +const ACCOUNT_TYPE_LABELS: Record = { + checking: '储蓄卡', + savings: '存款', + credit: '信用卡', + cash: '现金', + investment: '投资', + loan: '贷款', +} + +const SortableAccountItem = ({ account, index }: { account: Account; index: number }) => { + const { ref, handleRef, isDragging } = useSortable({ + id: account.id, + index, + group: 'finance-accounts', + }) + + const removeAccount = useMutation(orpc.finance.account.remove.mutationOptions()) + + const handleDelete = async () => { + try { + await removeAccount.mutateAsync({ id: account.id }) + toast.success('账户已删除') + } catch { + toast.error('操作失败') + } + } + + return ( +
+ + +
+
+ {account.icon && {account.icon}} + {account.name} +
+ {ACCOUNT_TYPE_LABELS[account.type]} +
+ + + }> + + + + + + 编辑 + + } + /> + + + + 删除 + + + +
+ ) +} + +export const AccountManager = ({ accounts }: AccountManagerProps) => { + const [items, setItems] = useState(accounts) + const queryClient = useQueryClient() + const reorderAccounts = useMutation(orpc.finance.account.reorder.mutationOptions()) + + useEffect(() => { + setItems(accounts) + }, [accounts]) + + const handleDragEnd: NonNullable['onDragEnd']> = (event) => { + if (event.canceled) return + + const reordered = move(items, event) + const previousItems = items + setItems(reordered) + + reorderAccounts.mutate( + reordered.map((item, index) => ({ id: item.id, orderId: index })), + { + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: orpc.finance.account.list.queryOptions().queryKey, + }) + toast.success('账户顺序已更新') + }, + onError: () => { + setItems(previousItems) + toast.error('操作失败') + }, + }, + ) + } + + return ( + + + 账户管理 + + + + + {items.map((account, index) => ( + + ))} + + + {items.length === 0 && ( +
+ 暂无账户 +
+ )} +
+ + + + + 添加账户 + + } + /> + +
+ ) +} diff --git a/apps/server/src/modules/finance/components/CategoryFormDialog.tsx b/apps/server/src/modules/finance/components/CategoryFormDialog.tsx new file mode 100644 index 0000000..7ecbc9a --- /dev/null +++ b/apps/server/src/modules/finance/components/CategoryFormDialog.tsx @@ -0,0 +1,151 @@ +import { useMutation } from '@tanstack/react-query' +import type { ReactElement } from 'react' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { orpc } from '@/client/orpc' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { IconPickerDialog } from '@/modules/bookmarks/components/IconPickerDialog' + +interface CategoryFormDialogProps { + category?: { + id: string + name: string + type: string + icon: string | null + } + orderId?: number + trigger: ReactElement +} + +export const CategoryFormDialog = ({ category, orderId = 0, trigger }: CategoryFormDialogProps) => { + const [open, setOpen] = useState(false) + const [form, setForm] = useState({ + name: '', + type: 'expense' as 'expense' | 'income', + icon: null as string | null, + }) + + const isEdit = Boolean(category) + const createCategory = useMutation(orpc.finance.category.create.mutationOptions()) + const updateCategory = useMutation(orpc.finance.category.update.mutationOptions()) + + useEffect(() => { + if (!open) { + setForm({ name: '', type: 'expense', icon: null }) + return + } + + if (category) { + setForm({ + name: category.name, + type: category.type as 'expense' | 'income', + icon: category.icon, + }) + } + }, [category, open]) + + const isPending = createCategory.isPending || updateCategory.isPending + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + + const name = form.name.trim() + if (!name) return + + try { + if (category) { + await updateCategory.mutateAsync({ + id: category.id, + data: { + name, + type: form.type, + icon: form.icon, + }, + }) + toast.success('分类已更新') + } else { + await createCategory.mutateAsync({ + name, + type: form.type, + icon: form.icon, + orderId, + }) + toast.success('分类已创建') + } + + setOpen(false) + } catch { + toast.error('操作失败') + } + } + + return ( + + + +
+ + {isEdit ? '编辑分类' : '添加分类'} + {isEdit ? '更新分类信息' : '添加一个新的交易分类'} + + +
+ + setForm((prev) => ({ ...prev, name: e.target.value }))} + placeholder="例如:餐饮美食" + required + /> +
+ +
+ 类型 + +
+ +
+

图标

+ setForm((prev) => ({ ...prev, icon }))} /> +
+ + + + + +
+
+
+ ) +} diff --git a/apps/server/src/modules/finance/components/CategoryManager.tsx b/apps/server/src/modules/finance/components/CategoryManager.tsx new file mode 100644 index 0000000..4757393 --- /dev/null +++ b/apps/server/src/modules/finance/components/CategoryManager.tsx @@ -0,0 +1,168 @@ +import { move } from '@dnd-kit/helpers' +import { DragDropProvider } from '@dnd-kit/react' +import { useSortable } from '@dnd-kit/react/sortable' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { GripVertical, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { orpc } from '@/client/orpc' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import { CategoryFormDialog } from './CategoryFormDialog' + +interface Category { + id: string + name: string + type: string + icon: string | null + orderId: number +} + +interface CategoryManagerProps { + categories: Category[] +} + +const SortableCategoryItem = ({ category, index }: { category: Category; index: number }) => { + const { ref, handleRef, isDragging } = useSortable({ + id: category.id, + index, + group: 'finance-categories', + }) + + const removeCategory = useMutation(orpc.finance.category.remove.mutationOptions()) + + const handleDelete = async () => { + try { + await removeCategory.mutateAsync({ id: category.id }) + toast.success('分类已删除') + } catch { + toast.error('操作失败') + } + } + + return ( +
+ + +
+
+ {category.icon && {category.icon}} + {category.name} +
+ + {category.type === 'income' ? '收入' : '支出'} + +
+ + + }> + + + + + + 编辑 + + } + /> + + + + 删除 + + + +
+ ) +} + +export const CategoryManager = ({ categories }: CategoryManagerProps) => { + const [items, setItems] = useState(categories) + const queryClient = useQueryClient() + const reorderCategories = useMutation(orpc.finance.category.reorder.mutationOptions()) + + useEffect(() => { + setItems(categories) + }, [categories]) + + const handleDragEnd: NonNullable['onDragEnd']> = (event) => { + if (event.canceled) return + + const reordered = move(items, event) + const previousItems = items + setItems(reordered) + + reorderCategories.mutate( + reordered.map((item, index) => ({ id: item.id, orderId: index })), + { + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: orpc.finance.category.list.queryOptions().queryKey, + }) + toast.success('分类顺序已更新') + }, + onError: () => { + setItems(previousItems) + toast.error('操作失败') + }, + }, + ) + } + + return ( + + + 分类管理 + + + + + {items.map((category, index) => ( + + ))} + + + {items.length === 0 && ( +
+ 暂无分类 +
+ )} +
+ + + + + 添加分类 + + } + /> + +
+ ) +} diff --git a/apps/server/src/modules/finance/components/TransactionFormDialog.tsx b/apps/server/src/modules/finance/components/TransactionFormDialog.tsx new file mode 100644 index 0000000..04cd4fb --- /dev/null +++ b/apps/server/src/modules/finance/components/TransactionFormDialog.tsx @@ -0,0 +1,296 @@ +import { useMutation, useSuspenseQuery } from '@tanstack/react-query' +import { format } from 'date-fns' +import { CalendarIcon } from 'lucide-react' +import type { ReactElement } from 'react' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { orpc } from '@/client/orpc' +import { Button } from '@/components/ui/button' +import { Calendar } from '@/components/ui/calendar' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { cn } from '@/lib/utils' + +interface TransactionFormDialogProps { + transaction?: { + id: string + accountId: string + categoryId: string | null + type: string + amount: number + description: string + note: string | null + date: Date + } + trigger: ReactElement +} + +export const TransactionFormDialog = ({ transaction, trigger }: TransactionFormDialogProps) => { + const [open, setOpen] = useState(false) + const { data: accounts } = useSuspenseQuery(orpc.finance.account.list.queryOptions()) + const { data: categories } = useSuspenseQuery(orpc.finance.category.list.queryOptions()) + + const [form, setForm] = useState<{ + type: 'expense' | 'income' + amount: string + description: string + accountId: string + categoryId: string + date: Date + note: string + }>({ + type: 'expense', + amount: '', + description: '', + accountId: accounts[0]?.id ?? '', + categoryId: 'none', + date: new Date(), + note: '', + }) + + const isEdit = Boolean(transaction) + const createTransaction = useMutation(orpc.finance.transaction.create.mutationOptions()) + const updateTransaction = useMutation(orpc.finance.transaction.update.mutationOptions()) + + useEffect(() => { + if (!open) { + setForm({ + type: 'expense', + amount: '', + description: '', + accountId: accounts[0]?.id ?? '', + categoryId: 'none', + date: new Date(), + note: '', + }) + return + } + + if (transaction) { + setForm({ + type: transaction.type as 'expense' | 'income', + amount: (transaction.amount / 100).toFixed(2), + description: transaction.description, + accountId: transaction.accountId, + categoryId: transaction.categoryId ?? 'none', + date: new Date(transaction.date), + note: transaction.note ?? '', + }) + } + }, [transaction, open, accounts]) + + const isPending = createTransaction.isPending || updateTransaction.isPending + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + + const description = form.description.trim() + if (!description || !form.accountId || !form.amount) return + + const amountCents = Math.round(Number.parseFloat(form.amount) * 100) + if (amountCents <= 0) { + toast.error('金额必须大于 0') + return + } + + try { + const data = { + type: form.type, + amount: amountCents, + description, + accountId: form.accountId, + categoryId: form.categoryId === 'none' ? undefined : form.categoryId, + date: form.date, + note: form.note.trim() || undefined, + } + + if (transaction) { + await updateTransaction.mutateAsync({ + id: transaction.id, + data, + }) + toast.success('交易已更新') + } else { + await createTransaction.mutateAsync(data) + toast.success('交易已记录') + } + + setOpen(false) + } catch { + toast.error('操作失败') + } + } + + const filteredCategories = categories.filter((c) => c.type === form.type) + + return ( + + + +
+ + {isEdit ? '编辑交易' : '记录交易'} + {isEdit ? '更新交易信息' : '添加一笔新的收入或支出'} + + +
+
+ 类型 + +
+ +
+ + setForm((prev) => ({ ...prev, amount: e.target.value }))} + placeholder="0.00" + required + /> +
+
+ +
+ + setForm((prev) => ({ ...prev, description: e.target.value }))} + placeholder="例如:午餐、工资" + required + /> +
+ +
+
+ 账户 + +
+ +
+ 分类 + +
+
+ +
+
+ 日期 + + + + {form.date ? format(form.date, 'yyyy-MM-dd') : 选择日期} + + } + /> + + date && setForm((prev) => ({ ...prev, date }))} + initialFocus + /> + + +
+ +
+ + setForm((prev) => ({ ...prev, note: e.target.value }))} + placeholder="添加备注..." + /> +
+
+ + + + + +
+
+
+ ) +} diff --git a/apps/server/src/modules/finance/components/TransactionList.tsx b/apps/server/src/modules/finance/components/TransactionList.tsx new file mode 100644 index 0000000..0f0e283 --- /dev/null +++ b/apps/server/src/modules/finance/components/TransactionList.tsx @@ -0,0 +1,173 @@ +import { useMutation } from '@tanstack/react-query' +import { format } from 'date-fns' +import { MoreHorizontal, Pencil, Trash2 } from 'lucide-react' +import { toast } from 'sonner' +import { orpc } from '@/client/orpc' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import { TransactionFormDialog } from './TransactionFormDialog' + +interface Transaction { + id: string + accountId: string + categoryId: string | null + type: string + amount: number + description: string + note: string | null + date: Date + source: string + account: { id: string; name: string; type: string; currencyCode: string; icon: string | null } + category: { id: string; name: string; icon: string | null; type: string } | null + createdAt: Date + updatedAt: Date +} + +interface TransactionListProps { + transactions: Transaction[] +} + +export const TransactionList = ({ transactions }: TransactionListProps) => { + const removeTransaction = useMutation(orpc.finance.transaction.remove.mutationOptions()) + + const handleDelete = async (id: string) => { + if (!confirm('确定要删除这笔交易吗?')) return + + try { + await removeTransaction.mutateAsync({ id }) + toast.success('交易已删除') + } catch { + toast.error('操作失败') + } + } + + if (transactions.length === 0) { + return ( +
+
+ 💸 +
+

暂无交易记录

+

点击右上角「记录交易」开始记账

+
+ ) + } + + const grouped = transactions.reduce( + (acc, tx) => { + const date = format(new Date(tx.date), 'yyyy-MM-dd') + if (!acc[date]) acc[date] = [] + acc[date].push(tx) + return acc + }, + {} as Record, + ) + + const sortedDates = Object.keys(grouped).sort((a, b) => new Date(b).getTime() - new Date(a).getTime()) + + return ( +
+ {sortedDates.map((date) => { + const dayTransactions = grouped[date] || [] + const dayTotal = dayTransactions.reduce((sum, tx) => sum + (tx.type === 'income' ? tx.amount : -tx.amount), 0) + + return ( +
+
+

{date}

+ 0 ? 'text-emerald-500' : dayTotal < 0 ? 'text-rose-500' : 'text-muted-foreground', + )} + > + {dayTotal > 0 ? '+' : ''} + {(dayTotal / 100).toFixed(2)} + +
+ +
+ {dayTransactions.map((tx, index) => ( +
+
+
+ {tx.category?.icon ? ( + {tx.category.icon} + ) : ( + 💰 + )} +
+
+
+ {tx.description} + {tx.category && ( + + {tx.category.name} + + )} +
+
+ + {tx.account.icon && {tx.account.icon}} + {tx.account.name} + + {tx.note && ( + <> + + {tx.note} + + )} +
+
+
+ +
+ + {tx.type === 'income' ? '+' : '-'}¥{(tx.amount / 100).toFixed(2)} + + + + }> + + + + + + 编辑 + + } + /> + + handleDelete(tx.id)}> + + 删除 + + + +
+
+ ))} +
+
+ ) + })} +
+ ) +} diff --git a/apps/server/src/modules/finance/contract.ts b/apps/server/src/modules/finance/contract.ts new file mode 100644 index 0000000..ecccdc7 --- /dev/null +++ b/apps/server/src/modules/finance/contract.ts @@ -0,0 +1,94 @@ +import { oc } from '@orpc/contract' +import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-zod' +import { z } from 'zod' +import * as schema from '@/modules/finance/schema' +import { generatedFieldKeys } from '@/server/db/fields' + +const financeAccountSelect = createSelectSchema(schema.financeAccount) +const financeAccountInsert = createInsertSchema(schema.financeAccount).omit(generatedFieldKeys).omit({ userId: true }) +const financeAccountUpdate = createUpdateSchema(schema.financeAccount).omit(generatedFieldKeys).omit({ userId: true }) + +const transactionCategorySelect = createSelectSchema(schema.transactionCategory) +const transactionCategoryInsert = createInsertSchema(schema.transactionCategory) + .omit(generatedFieldKeys) + .omit({ userId: true }) +const transactionCategoryUpdate = createUpdateSchema(schema.transactionCategory) + .omit(generatedFieldKeys) + .omit({ userId: true }) + +const transactionSelect = createSelectSchema(schema.transaction) +const transactionInsert = createInsertSchema(schema.transaction).omit(generatedFieldKeys).omit({ userId: true }) +const transactionUpdate = createUpdateSchema(schema.transaction).omit(generatedFieldKeys).omit({ userId: true }) + +export const account = { + list: oc.input(z.void()).output(z.array(financeAccountSelect)), + + create: oc.input(financeAccountInsert).output(financeAccountSelect), + + update: oc.input(z.object({ id: z.uuid(), data: financeAccountUpdate })).output(financeAccountSelect), + + remove: oc.input(z.object({ id: z.uuid() })).output(z.void()), + + reorder: oc.input(z.array(z.object({ id: z.uuid(), orderId: z.number().int() }))).output(z.void()), +} + +export const category = { + list: oc.input(z.void()).output(z.array(transactionCategorySelect)), + + create: oc.input(transactionCategoryInsert).output(transactionCategorySelect), + + update: oc.input(z.object({ id: z.uuid(), data: transactionCategoryUpdate })).output(transactionCategorySelect), + + remove: oc.input(z.object({ id: z.uuid() })).output(z.void()), + + reorder: oc.input(z.array(z.object({ id: z.uuid(), orderId: z.number().int() }))).output(z.void()), +} + +export const transaction = { + list: oc + .input( + z.object({ + accountId: z.uuid().optional(), + categoryId: z.uuid().optional(), + type: z.enum(['expense', 'income']).optional(), + startDate: z.string().optional(), + endDate: z.string().optional(), + limit: z.number().int().min(1).max(100).default(50), + offset: z.number().int().min(0).default(0), + }), + ) + .output( + z.object({ + items: z.array( + transactionSelect.extend({ + account: financeAccountSelect, + category: transactionCategorySelect.nullable(), + }), + ), + total: z.number().int(), + }), + ), + + create: oc.input(transactionInsert).output(transactionSelect), + + update: oc.input(z.object({ id: z.uuid(), data: transactionUpdate })).output(transactionSelect), + + remove: oc.input(z.object({ id: z.uuid() })).output(z.void()), + + summary: oc + .input( + z.object({ + startDate: z.string().optional(), + endDate: z.string().optional(), + accountId: z.uuid().optional(), + }), + ) + .output( + z.object({ + totalIncome: z.number().int(), + totalExpense: z.number().int(), + balance: z.number().int(), + transactionCount: z.number().int(), + }), + ), +} diff --git a/apps/server/src/modules/finance/index.ts b/apps/server/src/modules/finance/index.ts new file mode 100644 index 0000000..a0102a5 --- /dev/null +++ b/apps/server/src/modules/finance/index.ts @@ -0,0 +1,10 @@ +import type { ModuleMetadata } from '@/modules/registry' + +export const financeModule: ModuleMetadata = { + id: 'finance', + name: '记账', + description: '收支记录、账户管理与财务分析', + icon: 'Wallet', + route: '/finance', + enabled: true, +} diff --git a/apps/server/src/modules/finance/router.ts b/apps/server/src/modules/finance/router.ts new file mode 100644 index 0000000..cbfd59d --- /dev/null +++ b/apps/server/src/modules/finance/router.ts @@ -0,0 +1,232 @@ +import { ORPCError } from '@orpc/server' +import { and, eq, gte, lte, sql } from 'drizzle-orm' +import * as schema from '@/modules/finance/schema' +import { authMiddleware, dbMiddleware } from '@/server/api/middlewares' +import { os } from '@/server/api/server' + +export const account = { + list: os.finance.account.list + .use(dbMiddleware) + .use(authMiddleware) + .handler(async ({ context }) => { + return await context.db.query.financeAccount.findMany({ + where: (t, { eq }) => eq(t.userId, context.user.id), + orderBy: (t, { asc }) => asc(t.orderId), + }) + }), + + create: os.finance.account.create + .use(dbMiddleware) + .use(authMiddleware) + .handler(async ({ context, input }) => { + const [created] = await context.db + .insert(schema.financeAccount) + .values({ ...input, userId: context.user.id }) + .returning() + if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create account' }) + return created + }), + + update: os.finance.account.update + .use(dbMiddleware) + .use(authMiddleware) + .handler(async ({ context, input }) => { + const [updated] = await context.db + .update(schema.financeAccount) + .set(input.data) + .where(and(eq(schema.financeAccount.id, input.id), eq(schema.financeAccount.userId, context.user.id))) + .returning() + if (!updated) throw new ORPCError('NOT_FOUND') + return updated + }), + + remove: os.finance.account.remove + .use(dbMiddleware) + .use(authMiddleware) + .handler(async ({ context, input }) => { + const [deleted] = await context.db + .delete(schema.financeAccount) + .where(and(eq(schema.financeAccount.id, input.id), eq(schema.financeAccount.userId, context.user.id))) + .returning({ id: schema.financeAccount.id }) + if (!deleted) throw new ORPCError('NOT_FOUND') + }), + + reorder: os.finance.account.reorder + .use(dbMiddleware) + .use(authMiddleware) + .handler(async ({ context, input }) => { + await context.db.transaction(async (tx) => { + for (const item of input) { + await tx + .update(schema.financeAccount) + .set({ orderId: item.orderId }) + .where(and(eq(schema.financeAccount.id, item.id), eq(schema.financeAccount.userId, context.user.id))) + } + }) + }), +} + +export const category = { + list: os.finance.category.list + .use(dbMiddleware) + .use(authMiddleware) + .handler(async ({ context }) => { + return await context.db.query.transactionCategory.findMany({ + where: (t, { eq }) => eq(t.userId, context.user.id), + orderBy: (t, { asc }) => asc(t.orderId), + }) + }), + + create: os.finance.category.create + .use(dbMiddleware) + .use(authMiddleware) + .handler(async ({ context, input }) => { + const [created] = await context.db + .insert(schema.transactionCategory) + .values({ ...input, userId: context.user.id }) + .returning() + if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create category' }) + return created + }), + + update: os.finance.category.update + .use(dbMiddleware) + .use(authMiddleware) + .handler(async ({ context, input }) => { + const [updated] = await context.db + .update(schema.transactionCategory) + .set(input.data) + .where(and(eq(schema.transactionCategory.id, input.id), eq(schema.transactionCategory.userId, context.user.id))) + .returning() + if (!updated) throw new ORPCError('NOT_FOUND') + return updated + }), + + remove: os.finance.category.remove + .use(dbMiddleware) + .use(authMiddleware) + .handler(async ({ context, input }) => { + const [deleted] = await context.db + .delete(schema.transactionCategory) + .where(and(eq(schema.transactionCategory.id, input.id), eq(schema.transactionCategory.userId, context.user.id))) + .returning({ id: schema.transactionCategory.id }) + if (!deleted) throw new ORPCError('NOT_FOUND') + }), + + reorder: os.finance.category.reorder + .use(dbMiddleware) + .use(authMiddleware) + .handler(async ({ context, input }) => { + await context.db.transaction(async (tx) => { + for (const item of input) { + await tx + .update(schema.transactionCategory) + .set({ orderId: item.orderId }) + .where( + and(eq(schema.transactionCategory.id, item.id), eq(schema.transactionCategory.userId, context.user.id)), + ) + } + }) + }), +} + +export const transaction = { + list: os.finance.transaction.list + .use(dbMiddleware) + .use(authMiddleware) + .handler(async ({ context, input }) => { + const conditions = [eq(schema.transaction.userId, context.user.id)] + if (input.accountId) conditions.push(eq(schema.transaction.accountId, input.accountId)) + if (input.categoryId) conditions.push(eq(schema.transaction.categoryId, input.categoryId)) + if (input.type) conditions.push(eq(schema.transaction.type, input.type)) + if (input.startDate) conditions.push(gte(schema.transaction.date, new Date(input.startDate))) + if (input.endDate) conditions.push(lte(schema.transaction.date, new Date(input.endDate))) + + const where = and(...conditions) + + const items = await context.db.query.transaction.findMany({ + where: () => where, + with: { account: true, category: true }, + orderBy: (t, { desc }) => desc(t.date), + limit: input.limit, + offset: input.offset, + }) + + const [countResult] = await context.db + .select({ count: sql`COUNT(*)::int` }) + .from(schema.transaction) + .where(where) + + return { items, total: countResult?.count ?? 0 } + }), + + create: os.finance.transaction.create + .use(dbMiddleware) + .use(authMiddleware) + .handler(async ({ context, input }) => { + if (input.externalId) { + const externalId = input.externalId + const existing = await context.db.query.transaction.findFirst({ + where: (t, { eq, and }) => and(eq(t.externalId, externalId), eq(t.userId, context.user.id)), + }) + if (existing) return existing + } + + const [created] = await context.db + .insert(schema.transaction) + .values({ ...input, userId: context.user.id }) + .returning() + if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create transaction' }) + return created + }), + + update: os.finance.transaction.update + .use(dbMiddleware) + .use(authMiddleware) + .handler(async ({ context, input }) => { + const [updated] = await context.db + .update(schema.transaction) + .set(input.data) + .where(and(eq(schema.transaction.id, input.id), eq(schema.transaction.userId, context.user.id))) + .returning() + if (!updated) throw new ORPCError('NOT_FOUND') + return updated + }), + + remove: os.finance.transaction.remove + .use(dbMiddleware) + .use(authMiddleware) + .handler(async ({ context, input }) => { + const [deleted] = await context.db + .delete(schema.transaction) + .where(and(eq(schema.transaction.id, input.id), eq(schema.transaction.userId, context.user.id))) + .returning({ id: schema.transaction.id }) + if (!deleted) throw new ORPCError('NOT_FOUND') + }), + + summary: os.finance.transaction.summary + .use(dbMiddleware) + .use(authMiddleware) + .handler(async ({ context, input }) => { + const conditions = [eq(schema.transaction.userId, context.user.id)] + if (input.accountId) conditions.push(eq(schema.transaction.accountId, input.accountId)) + if (input.startDate) conditions.push(gte(schema.transaction.date, new Date(input.startDate))) + if (input.endDate) conditions.push(lte(schema.transaction.date, new Date(input.endDate))) + + const [result] = await context.db + .select({ + totalIncome: sql`COALESCE(SUM(CASE WHEN ${schema.transaction.type} = 'income' THEN ${schema.transaction.amount} ELSE 0 END), 0)::int`, + totalExpense: sql`COALESCE(SUM(CASE WHEN ${schema.transaction.type} = 'expense' THEN ${schema.transaction.amount} ELSE 0 END), 0)::int`, + transactionCount: sql`COUNT(*)::int`, + }) + .from(schema.transaction) + .where(and(...conditions)) + + return { + totalIncome: result?.totalIncome ?? 0, + totalExpense: result?.totalExpense ?? 0, + balance: (result?.totalIncome ?? 0) - (result?.totalExpense ?? 0), + transactionCount: result?.transactionCount ?? 0, + } + }), +} diff --git a/apps/server/src/modules/finance/schema.ts b/apps/server/src/modules/finance/schema.ts new file mode 100644 index 0000000..db2507e --- /dev/null +++ b/apps/server/src/modules/finance/schema.ts @@ -0,0 +1,64 @@ +import { boolean, index, integer, pgTable, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core' +import { user } from '../../server/auth/schema' +import { generatedFields } from '../../server/db/fields' + +export const financeAccount = pgTable( + 'finance_account', + { + ...generatedFields, + name: text('name').notNull(), + type: text('type').notNull().default('checking'), + currencyCode: text('currency_code').notNull().default('CNY'), + initialBalance: integer('initial_balance').notNull().default(0), + icon: text('icon'), + isArchived: boolean('is_archived').notNull().default(false), + orderId: integer('order_id').notNull().default(0), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + }, + (table) => [index('finance_account_user_id_idx').on(table.userId)], +) + +export const transactionCategory = pgTable( + 'transaction_category', + { + ...generatedFields, + name: text('name').notNull(), + icon: text('icon'), + type: text('type').notNull().default('expense'), + orderId: integer('order_id').notNull().default(0), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + }, + (table) => [index('transaction_category_user_id_idx').on(table.userId)], +) + +export const transaction = pgTable( + 'transaction', + { + ...generatedFields, + accountId: uuid('account_id') + .notNull() + .references(() => financeAccount.id, { onDelete: 'cascade' }), + categoryId: uuid('category_id').references(() => transactionCategory.id, { onDelete: 'set null' }), + type: text('type').notNull().default('expense'), + amount: integer('amount').notNull(), + description: text('description').notNull(), + note: text('note'), + date: timestamp('date', { withTimezone: true }).notNull().defaultNow(), + source: text('source').notNull().default('manual'), + externalId: text('external_id'), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + }, + (table) => [ + index('transaction_user_id_idx').on(table.userId), + index('transaction_account_id_idx').on(table.accountId), + index('transaction_category_id_idx').on(table.categoryId), + index('transaction_date_idx').on(table.date), + uniqueIndex('transaction_external_id_idx').on(table.externalId), + ], +) diff --git a/apps/server/src/modules/registry.ts b/apps/server/src/modules/registry.ts index 1409b74..28732d0 100644 --- a/apps/server/src/modules/registry.ts +++ b/apps/server/src/modules/registry.ts @@ -1,4 +1,5 @@ import { bookmarksModule } from './bookmarks' +import { financeModule } from './finance' export interface ModuleMetadata { id: string @@ -9,4 +10,4 @@ export interface ModuleMetadata { enabled: boolean } -export const modules: ModuleMetadata[] = [bookmarksModule] +export const modules: ModuleMetadata[] = [bookmarksModule, financeModule] diff --git a/apps/server/src/routeTree.gen.ts b/apps/server/src/routeTree.gen.ts index c1a7d5d..a0225ee 100644 --- a/apps/server/src/routeTree.gen.ts +++ b/apps/server/src/routeTree.gen.ts @@ -15,6 +15,8 @@ import { Route as ProtectedRouteImport } from './routes/_protected' import { Route as ProtectedIndexRouteImport } from './routes/_protected/index' import { Route as ApiHealthRouteImport } from './routes/api/health' import { Route as ApiSplatRouteImport } from './routes/api/$' +import { Route as ProtectedSettingsRouteImport } from './routes/_protected/settings' +import { Route as ProtectedFinanceRouteImport } from './routes/_protected/finance' import { Route as ProtectedBookmarksRouteImport } from './routes/_protected/bookmarks' import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$' @@ -48,6 +50,16 @@ const ApiSplatRoute = ApiSplatRouteImport.update({ path: '/api/$', getParentRoute: () => rootRouteImport, } as any) +const ProtectedSettingsRoute = ProtectedSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => ProtectedRoute, +} as any) +const ProtectedFinanceRoute = ProtectedFinanceRouteImport.update({ + id: '/finance', + path: '/finance', + getParentRoute: () => ProtectedRoute, +} as any) const ProtectedBookmarksRoute = ProtectedBookmarksRouteImport.update({ id: '/bookmarks', path: '/bookmarks', @@ -69,6 +81,8 @@ export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/setup': typeof SetupRoute '/bookmarks': typeof ProtectedBookmarksRoute + '/finance': typeof ProtectedFinanceRoute + '/settings': typeof ProtectedSettingsRoute '/api/$': typeof ApiSplatRoute '/api/health': typeof ApiHealthRoute '/api/auth/$': typeof ApiAuthSplatRoute @@ -78,6 +92,8 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/setup': typeof SetupRoute '/bookmarks': typeof ProtectedBookmarksRoute + '/finance': typeof ProtectedFinanceRoute + '/settings': typeof ProtectedSettingsRoute '/api/$': typeof ApiSplatRoute '/api/health': typeof ApiHealthRoute '/': typeof ProtectedIndexRoute @@ -90,6 +106,8 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/setup': typeof SetupRoute '/_protected/bookmarks': typeof ProtectedBookmarksRoute + '/_protected/finance': typeof ProtectedFinanceRoute + '/_protected/settings': typeof ProtectedSettingsRoute '/api/$': typeof ApiSplatRoute '/api/health': typeof ApiHealthRoute '/_protected/': typeof ProtectedIndexRoute @@ -103,6 +121,8 @@ export interface FileRouteTypes { | '/login' | '/setup' | '/bookmarks' + | '/finance' + | '/settings' | '/api/$' | '/api/health' | '/api/auth/$' @@ -112,6 +132,8 @@ export interface FileRouteTypes { | '/login' | '/setup' | '/bookmarks' + | '/finance' + | '/settings' | '/api/$' | '/api/health' | '/' @@ -123,6 +145,8 @@ export interface FileRouteTypes { | '/login' | '/setup' | '/_protected/bookmarks' + | '/_protected/finance' + | '/_protected/settings' | '/api/$' | '/api/health' | '/_protected/' @@ -184,6 +208,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiSplatRouteImport parentRoute: typeof rootRouteImport } + '/_protected/settings': { + id: '/_protected/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof ProtectedSettingsRouteImport + parentRoute: typeof ProtectedRoute + } + '/_protected/finance': { + id: '/_protected/finance' + path: '/finance' + fullPath: '/finance' + preLoaderRoute: typeof ProtectedFinanceRouteImport + parentRoute: typeof ProtectedRoute + } '/_protected/bookmarks': { id: '/_protected/bookmarks' path: '/bookmarks' @@ -210,11 +248,15 @@ declare module '@tanstack/react-router' { interface ProtectedRouteChildren { ProtectedBookmarksRoute: typeof ProtectedBookmarksRoute + ProtectedFinanceRoute: typeof ProtectedFinanceRoute + ProtectedSettingsRoute: typeof ProtectedSettingsRoute ProtectedIndexRoute: typeof ProtectedIndexRoute } const ProtectedRouteChildren: ProtectedRouteChildren = { ProtectedBookmarksRoute: ProtectedBookmarksRoute, + ProtectedFinanceRoute: ProtectedFinanceRoute, + ProtectedSettingsRoute: ProtectedSettingsRoute, ProtectedIndexRoute: ProtectedIndexRoute, } diff --git a/apps/server/src/routes/_protected/finance.tsx b/apps/server/src/routes/_protected/finance.tsx new file mode 100644 index 0000000..1d338a6 --- /dev/null +++ b/apps/server/src/routes/_protected/finance.tsx @@ -0,0 +1,240 @@ +import type { QueryClient } from '@tanstack/react-query' +import { useSuspenseQuery } from '@tanstack/react-query' +import { createFileRoute } from '@tanstack/react-router' +import { Pencil, Plus, X } from 'lucide-react' +import { AnimatePresence } from 'motion/react' +import * as motion from 'motion/react-client' +import { useState } from 'react' +import { orpc } from '@/client/orpc' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { AccountManager } from '@/modules/finance/components/AccountManager' +import { CategoryManager } from '@/modules/finance/components/CategoryManager' +import { TransactionFormDialog } from '@/modules/finance/components/TransactionFormDialog' +import { TransactionList } from '@/modules/finance/components/TransactionList' + +export const Route = createFileRoute('/_protected/finance' as never)({ + loader: async ({ context }: { context: { queryClient: QueryClient } }) => { + await Promise.all([ + context.queryClient.fetchQuery(orpc.finance.account.list.queryOptions()), + context.queryClient.fetchQuery(orpc.finance.category.list.queryOptions()), + context.queryClient.fetchQuery(orpc.finance.transaction.list.queryOptions({ input: { limit: 50, offset: 0 } })), + context.queryClient.fetchQuery(orpc.finance.transaction.summary.queryOptions({ input: {} })), + ]) + }, + component: FinancePage, +}) + +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.05, delayChildren: 0.08 }, + }, +} + +const itemVariants = { + hidden: { opacity: 0, y: 10 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] as const } }, +} + +function FinancePage() { + const [editing, setEditing] = useState(false) + const [filterAccountId, setFilterAccountId] = useState('all') + const [filterCategoryId, setFilterCategoryId] = useState('all') + const [filterType, setFilterType] = useState<'all' | 'expense' | 'income'>('all') + + const { data: accounts } = useSuspenseQuery(orpc.finance.account.list.queryOptions()) + const { data: categories } = useSuspenseQuery(orpc.finance.category.list.queryOptions()) + + const listParams = { + limit: 50, + offset: 0, + ...(filterAccountId !== 'all' && { accountId: filterAccountId }), + ...(filterCategoryId !== 'all' && { categoryId: filterCategoryId }), + ...(filterType !== 'all' && { type: filterType }), + } + + const { data: result } = useSuspenseQuery(orpc.finance.transaction.list.queryOptions({ input: listParams })) + const { data: summary } = useSuspenseQuery( + orpc.finance.transaction.summary.queryOptions({ + input: { + ...(filterAccountId !== 'all' && { accountId: filterAccountId }), + }, + }), + ) + + return ( +
+
+
+

{editing ? '财务管理' : '记账'}

+

+ {editing ? '管理你的财务账户和交易分类' : '追踪你的收入、支出和资产状况'} +

+
+
+ {!editing && ( + + + 记录交易 + + } + /> + )} + +
+
+ + + {editing ? ( + +
+ +
+
+ +
+
+ ) : ( + + {accounts.length === 0 ? ( + +
+ 🏦 +
+

还没有任何账户

+

点击右上角「编辑」按钮添加你的第一个财务账户

+ +
+ ) : ( +
+ + + + 总收入 + + +
+ ¥{(summary.totalIncome / 100).toFixed(2)} +
+
+
+ + + 总支出 + + +
¥{(summary.totalExpense / 100).toFixed(2)}
+
+
+ + + 结余 + + +
¥{(summary.balance / 100).toFixed(2)}
+
+
+ + + 交易笔数 + + +
{summary.transactionCount}
+
+
+
+ + + + + + + + + + + + +
+ )} +
+ )} +
+
+ ) +} diff --git a/apps/server/src/routes/_protected/index.tsx b/apps/server/src/routes/_protected/index.tsx index 6564421..3c744b3 100644 --- a/apps/server/src/routes/_protected/index.tsx +++ b/apps/server/src/routes/_protected/index.tsx @@ -2,7 +2,7 @@ import type { QueryClient } from '@tanstack/react-query' import { useSuspenseQuery } from '@tanstack/react-query' import { createFileRoute, Link } from '@tanstack/react-router' import * as icons from 'lucide-react' -import { ArrowRight, Compass, Plus } from 'lucide-react' +import { ArrowRight, Compass, TrendingDown, TrendingUp, Wallet } from 'lucide-react' import * as motion from 'motion/react-client' import { orpc } from '@/client/orpc' @@ -10,7 +10,11 @@ const allIcons = icons as unknown as Record { - await context.queryClient.fetchQuery(orpc.bookmarks.category.list.queryOptions()) + await Promise.all([ + context.queryClient.fetchQuery(orpc.bookmarks.category.list.queryOptions()), + context.queryClient.fetchQuery(orpc.finance.transaction.summary.queryOptions({ input: {} })), + context.queryClient.fetchQuery(orpc.finance.account.list.queryOptions()), + ]) }, component: DashboardPage, }) @@ -46,6 +50,8 @@ const itemVariants = { function DashboardPage() { const { data: categories } = useSuspenseQuery(orpc.bookmarks.category.list.queryOptions()) + const { data: summary } = useSuspenseQuery(orpc.finance.transaction.summary.queryOptions({ input: {} })) + const { data: accounts } = useSuspenseQuery(orpc.finance.account.list.queryOptions()) const now = new Date() const totalBookmarks = categories.reduce( @@ -119,17 +125,43 @@ function DashboardPage() {
- +
-

添加书签

-

快速添加常用链接

+

记账

+

+ {accounts.length} 个账户 · {summary.transactionCount} 笔交易 +

+ + + {summary.transactionCount > 0 && ( +
+
+
+ + + 收入 + + + ¥{(summary.totalIncome / 100).toFixed(2)} + +
+
+ + + 支出 + + ¥{(summary.totalExpense / 100).toFixed(2)} +
+
+
+ )}
diff --git a/apps/server/src/routes/_protected/settings.tsx b/apps/server/src/routes/_protected/settings.tsx new file mode 100644 index 0000000..6f5da09 --- /dev/null +++ b/apps/server/src/routes/_protected/settings.tsx @@ -0,0 +1,204 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { createFileRoute } from '@tanstack/react-router' +import { Check, Copy, Key, Plus, Trash2 } from 'lucide-react' +import * as motion from 'motion/react-client' +import { useState } from 'react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { authClient } from '@/server/auth/client' + +export const Route = createFileRoute('/_protected/settings' as never)({ + component: SettingsPage, +}) + +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.05, delayChildren: 0.08 }, + }, +} + +const itemVariants = { + hidden: { opacity: 0, y: 10 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] as const } }, +} + +function SettingsPage() { + const queryClient = useQueryClient() + const [createOpen, setCreateOpen] = useState(false) + const [keyName, setKeyName] = useState('') + const [newKey, setNewKey] = useState(null) + const [copied, setCopied] = useState(false) + + const { data: apiKeys, isLoading } = useQuery({ + queryKey: ['api-keys'], + queryFn: async () => { + const result = await authClient.apiKey.list({ query: { limit: 100, sortBy: 'createdAt', sortDirection: 'desc' } }) + return result.data + }, + }) + + const createMutation = useMutation({ + mutationFn: async (name: string) => { + const result = await authClient.apiKey.create({ name }) + return result.data + }, + onSuccess: (data) => { + if (data?.key) { + setNewKey(data.key) + } + setKeyName('') + setCreateOpen(false) + queryClient.invalidateQueries({ queryKey: ['api-keys'] }) + toast.success('API 密钥已创建') + }, + onError: () => { + toast.error('创建失败') + }, + }) + + const deleteMutation = useMutation({ + mutationFn: async (keyId: string) => { + await authClient.apiKey.delete({ keyId }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['api-keys'] }) + toast.success('API 密钥已删除') + }, + onError: () => { + toast.error('删除失败') + }, + }) + + const handleCopyKey = async () => { + if (!newKey) return + await navigator.clipboard.writeText(newKey) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const handleCreateSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!keyName.trim()) return + createMutation.mutate(keyName.trim()) + } + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr) + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` + } + + return ( + + +

设置

+

管理 API 密钥和系统配置

+
+ + +
+
+

API 密钥

+

用于 N8N 等外部服务调用 Kairos API

+
+ + }> + + 创建密钥 + + +
+ + 创建 API 密钥 + +
+ setKeyName(e.target.value)} + autoFocus + /> +
+ + + +
+
+
+
+ + {isLoading ? ( +
+ {[1, 2].map((i) => ( +
+ ))} +
+ ) : apiKeys && apiKeys.length > 0 ? ( +
+ {apiKeys.map((key: { id: string; name: string | null; start: string | null; createdAt: string }) => ( +
+
+ +
+
+

{key.name ?? '未命名密钥'}

+

{key.start ?? '***'}••••••••

+
+ {formatDate(key.createdAt)} + +
+ ))} +
+ ) : ( +
+
+ +
+

还没有 API 密钥

+

创建一个密钥来接入 N8N 等外部服务

+ +
+ )} + + + !open && setNewKey(null)}> + + + 密钥已创建 + +
+

请立即复制此密钥,它只会显示一次:

+
+ {newKey} + +
+
+ + + +
+
+ + ) +} diff --git a/apps/server/src/server/api/contracts/index.ts b/apps/server/src/server/api/contracts/index.ts index 07b9020..f92f9a4 100644 --- a/apps/server/src/server/api/contracts/index.ts +++ b/apps/server/src/server/api/contracts/index.ts @@ -1,7 +1,9 @@ import * as bookmarks from '@/modules/bookmarks/contract' +import * as finance from '@/modules/finance/contract' export const contract = { bookmarks, + finance, } export type Contract = typeof contract diff --git a/apps/server/src/server/api/routers/index.ts b/apps/server/src/server/api/routers/index.ts index d2d5191..01348de 100644 --- a/apps/server/src/server/api/routers/index.ts +++ b/apps/server/src/server/api/routers/index.ts @@ -1,6 +1,8 @@ import * as bookmarks from '@/modules/bookmarks/router' +import * as finance from '@/modules/finance/router' import { os } from '@/server/api/server' export const router = os.router({ bookmarks, + finance, }) diff --git a/apps/server/src/server/auth/client.ts b/apps/server/src/server/auth/client.ts index fc6c795..744d1df 100644 --- a/apps/server/src/server/auth/client.ts +++ b/apps/server/src/server/auth/client.ts @@ -1,3 +1,6 @@ +import { apiKeyClient } from '@better-auth/api-key/client' import { createAuthClient } from 'better-auth/react' -export const authClient = createAuthClient() +export const authClient = createAuthClient({ + plugins: [apiKeyClient()], +}) diff --git a/apps/server/src/server/auth/index.ts b/apps/server/src/server/auth/index.ts index 50c5bc0..52c5eb4 100644 --- a/apps/server/src/server/auth/index.ts +++ b/apps/server/src/server/auth/index.ts @@ -1,3 +1,4 @@ +import { apiKey } from '@better-auth/api-key' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { APIError } from 'better-auth/api' @@ -34,5 +35,11 @@ export const auth = betterAuth({ }, }, }, - plugins: [tanstackStartCookies()], + plugins: [ + tanstackStartCookies(), + apiKey({ + defaultPrefix: 'kairos_', + enableSessionForAPIKeys: true, + }), + ], }) diff --git a/apps/server/src/server/auth/schema.ts b/apps/server/src/server/auth/schema.ts index 2d7a881..1c21b14 100644 --- a/apps/server/src/server/auth/schema.ts +++ b/apps/server/src/server/auth/schema.ts @@ -1,5 +1,5 @@ import { relations } from 'drizzle-orm' -import { boolean, index, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { boolean, index, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' export const user = pgTable('user', { id: text('id').primaryKey(), @@ -73,6 +73,39 @@ export const verification = pgTable( (table) => [index('verification_identifier_idx').on(table.identifier)], ) +export const apikey = pgTable( + 'apikey', + { + id: text('id').primaryKey(), + configId: text('config_id').default('default').notNull(), + name: text('name'), + start: text('start'), + referenceId: text('reference_id').notNull(), + prefix: text('prefix'), + key: text('key').notNull(), + refillInterval: integer('refill_interval'), + refillAmount: integer('refill_amount'), + lastRefillAt: timestamp('last_refill_at'), + enabled: boolean('enabled').default(true), + rateLimitEnabled: boolean('rate_limit_enabled').default(true), + rateLimitTimeWindow: integer('rate_limit_time_window').default(86400000), + rateLimitMax: integer('rate_limit_max').default(10), + requestCount: integer('request_count').default(0), + remaining: integer('remaining'), + lastRequest: timestamp('last_request'), + expiresAt: timestamp('expires_at'), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + permissions: text('permissions'), + metadata: text('metadata'), + }, + (table) => [ + index('apikey_configId_idx').on(table.configId), + index('apikey_referenceId_idx').on(table.referenceId), + index('apikey_key_idx').on(table.key), + ], +) + export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), diff --git a/apps/server/src/server/db/relations.ts b/apps/server/src/server/db/relations.ts index 4817d01..46f49fc 100644 --- a/apps/server/src/server/db/relations.ts +++ b/apps/server/src/server/db/relations.ts @@ -1,10 +1,14 @@ import { relations } from 'drizzle-orm' import { bookmark, category } from '../../modules/bookmarks/schema' +import { financeAccount, transaction, transactionCategory } from '../../modules/finance/schema' import { user } from '../auth/schema' export const userRelations = relations(user, ({ many }) => ({ categories: many(category), bookmarks: many(bookmark), + financeAccounts: many(financeAccount), + transactionCategories: many(transactionCategory), + transactions: many(transaction), })) export const categoryRelations = relations(category, ({ one, many }) => ({ @@ -25,3 +29,34 @@ export const bookmarkRelations = relations(bookmark, ({ one }) => ({ references: [category.id], }), })) + +export const financeAccountRelations = relations(financeAccount, ({ one, many }) => ({ + user: one(user, { + fields: [financeAccount.userId], + references: [user.id], + }), + transactions: many(transaction), +})) + +export const transactionCategoryRelations = relations(transactionCategory, ({ one, many }) => ({ + user: one(user, { + fields: [transactionCategory.userId], + references: [user.id], + }), + transactions: many(transaction), +})) + +export const transactionRelations = relations(transaction, ({ one }) => ({ + user: one(user, { + fields: [transaction.userId], + references: [user.id], + }), + account: one(financeAccount, { + fields: [transaction.accountId], + references: [financeAccount.id], + }), + category: one(transactionCategory, { + fields: [transaction.categoryId], + references: [transactionCategory.id], + }), +})) diff --git a/apps/server/src/server/db/schema/index.ts b/apps/server/src/server/db/schema/index.ts index ad79f59..d23d9ba 100644 --- a/apps/server/src/server/db/schema/index.ts +++ b/apps/server/src/server/db/schema/index.ts @@ -1,2 +1,3 @@ export * from '../../../modules/bookmarks/schema' -export { account, session, user, verification } from '../../auth/schema' +export * from '../../../modules/finance/schema' +export { account, apikey, session, user, verification } from '../../auth/schema' diff --git a/bun.lock b/bun.lock index f02acf4..e54e4f3 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "version": "1.0.0", "dependencies": { "@base-ui/react": "catalog:", + "@better-auth/api-key": "^1.5.6", "@dnd-kit/dom": "catalog:", "@dnd-kit/helpers": "catalog:", "@dnd-kit/react": "catalog:", @@ -36,6 +37,7 @@ "class-variance-authority": "catalog:", "clsx": "catalog:", "cmdk": "^1.1.1", + "date-fns": "^4.1.0", "drizzle-orm": "catalog:", "drizzle-zod": "catalog:", "lucide-react": "catalog:", @@ -43,6 +45,7 @@ "next-themes": "catalog:", "postgres": "catalog:", "react": "catalog:", + "react-day-picker": "^9.14.0", "react-dom": "catalog:", "shadcn": "^4.1.1", "sonner": "catalog:", @@ -206,6 +209,8 @@ "@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="], + "@better-auth/api-key": ["@better-auth/api-key@1.5.6", "", { "dependencies": { "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "1.5.6", "@better-auth/utils": "0.3.1", "better-auth": "1.5.6" } }, "sha512-jr3m4/caFxn9BuY9pGDJ4B1HP1Qoqmyd7heBHm4KUFel+a9Whe/euROgZ/L+o7mbmUdZtreneaU15dpn0tJZ5g=="], + "@better-auth/core": ["@better-auth/core@1.5.6", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-Ez9DZdIMFyxHremmoLz1emFPGNQomDC1jqqBPnZ6Ci+6TiGN3R9w/Y03cJn6I8r1ycKgOzeVMZtJ/erOZ27Gsw=="], "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.6", "", { "peerDependencies": { "@better-auth/core": "1.5.6", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-VfFFmaoFw3ug12SiSuIwzrMoHyIVmkMGWm9gZ4sXdYYVX4HboCL4m3fjzOhppcmK5OGatRuU+N1UX6wxCITcXw=="], @@ -242,6 +247,8 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.10", "", { "os": "win32", "cpu": "x64" }, "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg=="], + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + "@dnd-kit/abstract": ["@dnd-kit/abstract@0.3.2", "", { "dependencies": { "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-uvPVK+SZYD6Viddn9M0K0JQdXknuVSxA/EbMlFRanve3P/XTc18oLa5zGftKSGjfQGmuzkZ34E26DSbly1zi3Q=="], "@dnd-kit/collision": ["@dnd-kit/collision@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-pNmNSLCI8S9fNQ7QJ3fBCDjiT0sqBhUFcKgmyYaGvGCAU+kq0AP8OWlh0JSisc9k5mFyxmRpmFQcnJpILz/RPA=="], @@ -520,6 +527,8 @@ "@t3-oss/env-core": ["@t3-oss/env-core@0.13.11", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-sM7GYY+KL7H/Hl0BE0inWfk3nRHZOLhmVn7sHGxaZt9FAR6KqREXAE+6TqKfiavfXmpRxO/OZ2QgKRd+oiBYRQ=="], + "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], @@ -780,6 +789,10 @@ "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + + "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], + "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], "db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="], @@ -1240,6 +1253,8 @@ "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],