From 03b569d7088ad10aa7fbd230f4595e86030a3a65 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 26 Feb 2026 14:32:56 +0100 Subject: [PATCH] refactor: consolidate extension system to general-only with manifest-driven architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove all sector-specific extensions (construction, ecommerce, export, hotel, restaurant, tech) — only general-purpose extensions remain - Move NE-bilaga and SRU export from extensions to core reports (lib/reports/) - Move moms-box-mapping from extensions/export/shared to lib/vat/ - Replace per-extension API routes with catch-all dispatcher (app/api/extensions/ext/[...path]/route.ts) - Add manifest.json for each extension with metadata, env vars, and deps - Add api-routes.ts pattern for extension-defined API endpoints - Add code generation scripts (generate-extension-registry, create-extension) - Add extensions.config.json for opt-in extension loading - Add extensions.schema.json for config validation - Add email service interface with noop default (lib/email/service.ts) - Add CI workflow (core-build.yml) to verify core builds with zero extensions - Add migration 045: expand account_type CHECK for untaxed_reserves - Update CLAUDE.md with comprehensive extension system documentation - Update all report engines and bookkeeping services for new imports - Clean up extensions.schema.json to only list existing extensions Co-Authored-By: Claude Opus 4.6 --- .github/workflows/core-build.yml | 30 + .gitignore | 5 + CLAUDE.md | 507 ++++++-- app/(dashboard)/transactions/page.tsx | 3 +- .../admin/seed-template-embeddings/route.ts | 18 +- .../fiscal-periods/[id]/close/route.ts | 2 +- .../fiscal-periods/[id]/lock/route.ts | 2 +- .../fiscal-periods/[id]/year-end/route.ts | 6 +- .../[id]/correct/__tests__/route.test.ts | 2 +- .../journal-entries/[id]/correct/route.ts | 2 +- .../[id]/reverse/__tests__/route.test.ts | 2 +- .../journal-entries/[id]/reverse/route.ts | 2 +- .../journal-entries/__tests__/route.test.ts | 2 +- app/api/bookkeeping/journal-entries/route.ts | 2 +- .../mapping-rules/evaluate/route.ts | 2 +- app/api/documents/[id]/link/route.ts | 1 + app/api/documents/[id]/verify/route.ts | 2 +- app/api/documents/[id]/versions/route.ts | 2 +- app/api/documents/route.ts | 2 +- .../ai-categorization/settings/route.ts | 63 - .../ai-categorization/suggestions/route.ts | 95 -- app/api/extensions/ai-chat/route.ts | 169 --- .../extensions/ai-chat/sessions/[id]/route.ts | 146 --- app/api/extensions/ai-chat/sessions/route.ts | 84 -- app/api/extensions/ai-chat/stream/route.ts | 195 ---- .../currency-receivables/report/route.ts | 156 --- .../export/eu-sales-list/download/route.ts | 166 --- .../export/eu-sales-list/report/route.ts | 212 ---- .../export/intrastat/download/route.ts | 165 --- .../export/intrastat/report/route.ts | 214 ---- .../export/vat-monitor/report/route.ts | 188 --- app/api/extensions/ext/[...path]/route.ts | 76 +- .../inbox/[id]/confirm-receipt/route.ts | 148 --- .../[id]/confirm/__tests__/route.test.ts | 234 ---- .../invoice-inbox/inbox/[id]/confirm/route.ts | 230 ---- .../invoice-inbox/inbox/[id]/process/route.ts | 163 --- .../invoice-inbox/inbox/[id]/route.ts | 111 -- .../inbox/__tests__/route.test.ts | 124 -- .../extensions/invoice-inbox/inbox/route.ts | 273 ----- .../invoice-inbox/settings/route.ts | 28 - app/api/extensions/ne-bilaga/route.ts | 9 - .../push-notifications/settings/route.ts | 61 - .../push-notifications/subscribe/route.ts | 135 --- .../receipt-ocr/[id]/confirm/route.ts | 135 --- .../receipt-ocr/[id]/match/route.ts | 234 ---- app/api/extensions/receipt-ocr/[id]/route.ts | 176 --- app/api/extensions/receipt-ocr/queue/route.ts | 106 -- app/api/extensions/receipt-ocr/route.ts | 55 - .../extensions/receipt-ocr/settings/route.ts | 61 - .../extensions/receipt-ocr/upload/route.ts | 222 ---- .../extensions/sru-export/coverage/route.ts | 8 - app/api/extensions/sru-export/route.ts | 9 - app/api/import/sie/execute/route.ts | 1 + app/api/import/sie/mappings/route.ts | 2 +- app/api/import/sie/parse/route.ts | 2 +- .../[id]/mark-paid/__tests__/route.test.ts | 2 + app/api/invoices/[id]/mark-paid/route.ts | 2 + app/api/invoices/[id]/mark-sent/route.ts | 1 + .../[id]/send/__tests__/route.test.ts | 15 +- app/api/invoices/[id]/send/route.ts | 14 +- app/api/invoices/reminders/cron/route.ts | 6 +- app/api/invoices/route.ts | 1 + app/api/reports/ar-ledger/route.ts | 4 +- app/api/reports/balance-sheet/route.ts | 2 +- app/api/reports/general-ledger/route.ts | 2 +- app/api/reports/income-statement/route.ts | 2 +- app/api/reports/journal-register/route.ts | 2 +- app/api/reports/monthly-breakdown/route.ts | 2 +- app/api/reports/ne-bilaga/route.ts | 2 +- app/api/reports/sie-export/route.ts | 2 +- app/api/reports/sru-export/coverage/route.ts | 2 +- app/api/reports/sru-export/route.ts | 2 +- app/api/reports/supplier-ledger/route.ts | 4 +- app/api/reports/trial-balance/route.ts | 2 +- app/api/reports/vat-declaration/route.ts | 1 + .../supplier-invoices/[id]/credit/route.ts | 1 + .../supplier-invoices/[id]/mark-paid/route.ts | 2 + app/api/supplier-invoices/route.ts | 1 + .../[id]/book/__tests__/route.test.ts | 2 +- app/api/transactions/[id]/book/route.ts | 2 +- .../[id]/categorize/__tests__/route.test.ts | 1 + app/api/transactions/[id]/categorize/route.ts | 2 + .../[id]/describe/__tests__/route.test.ts | 15 +- app/api/transactions/[id]/describe/route.ts | 22 +- .../match-invoice/__tests__/route.test.ts | 1 + .../transactions/[id]/match-invoice/route.ts | 2 + .../[id]/match-supplier-invoice/route.ts | 4 +- .../batch-describe/__tests__/route.test.ts | 1 + app/api/transactions/batch-describe/route.ts | 2 + .../batch-match-invoices/route.ts | 1 + .../transactions/suggest-categories/route.ts | 11 +- components/chat/useChatStream.ts | 4 +- components/dashboard/DashboardContent.tsx | 1 - .../construction/ProjectCostWorkspace.tsx | 864 -------------- .../construction/RotCalculatorWorkspace.tsx | 613 ---------- .../MultichannelRevenueWorkspace.tsx | 989 ---------------- .../ecommerce/ShopifyImportWorkspace.tsx | 875 -------------- .../export/CurrencyReceivablesWorkspace.tsx | 715 ------------ .../export/EuSalesListWorkspace.tsx | 777 ------------- .../extensions/export/IntrastatWorkspace.tsx | 747 ------------ .../extensions/export/VatMonitorWorkspace.tsx | 656 ----------- .../general/DocumentInboxWorkspace.tsx | 14 +- .../general/InvoiceInboxWorkspace.tsx | 14 +- .../document-inbox/ReceiptInboxDetail.tsx | 4 +- .../general/invoice-inbox/InboxUploadZone.tsx | 2 +- .../extensions/hotel/OccupancyWorkspace.tsx | 578 ---------- .../extensions/hotel/RevparWorkspace.tsx | 588 ---------- .../restaurant/EarningsPerLiterWorkspace.tsx | 817 ------------- .../restaurant/FoodCostWorkspace.tsx | 552 --------- .../restaurant/PosImportWorkspace.tsx | 696 ----------- .../restaurant/TipTrackingWorkspace.tsx | 844 -------------- .../tech/BillableHoursWorkspace.tsx | 965 ---------------- .../tech/ProjectBillingWorkspace.tsx | 1002 ---------------- components/import/BankFileConfirmStep.tsx | 23 +- .../transactions/SwipeCategorizationView.tsx | 51 +- .../transactions/TransactionInboxCard.tsx | 21 - dev_docs/PR1PR2.md | 232 ++++ dev_docs/TWO_PHASES.md | 476 ++++++++ extensions.config.json | 1 + extensions.md | 525 +++++++-- extensions.schema.json | 34 + extensions/construction/project-cost/index.ts | 2 - .../__tests__/project-cost-calculator.test.ts | 186 --- .../lib/project-cost-calculator.ts | 133 --- .../construction/rot-calculator/index.ts | 2 - .../lib/__tests__/rot-calculator.test.ts | 164 --- .../rot-calculator/lib/rot-calculator.ts | 118 -- .../ecommerce/multichannel-revenue/index.ts | 2 - .../__tests__/multichannel-calculator.test.ts | 211 ---- .../lib/multichannel-calculator.ts | 200 ---- extensions/ecommerce/shopify-import/index.ts | 2 - .../lib/__tests__/shopify-calculator.test.ts | 167 --- .../shopify-import/lib/shopify-calculator.ts | 199 ---- .../export/currency-receivables/index.ts | 18 - .../lib/__tests__/receivables-engine.test.ts | 465 -------- .../lib/receivables-engine.ts | 359 ------ extensions/export/eu-sales-list/index.ts | 18 - .../lib/__tests__/csv-generator.test.ts | 140 --- .../__tests__/eu-sales-list-engine.test.ts | 559 --------- .../lib/__tests__/skv-xml-generator.test.ts | 189 --- .../export/eu-sales-list/lib/csv-generator.ts | 65 -- .../eu-sales-list/lib/eu-sales-list-engine.ts | 453 -------- .../eu-sales-list/lib/skv-xml-generator.ts | 127 -- extensions/export/intrastat/index.ts | 17 - .../lib/__tests__/intrastat-engine.test.ts | 417 ------- .../lib/__tests__/scb-csv-generator.test.ts | 203 ---- .../export/intrastat/lib/intrastat-engine.ts | 466 -------- .../export/intrastat/lib/scb-csv-generator.ts | 74 -- extensions/export/shared/eu-countries.ts | 15 - extensions/export/vat-monitor/index.ts | 17 - .../lib/__tests__/vat-monitor-engine.test.ts | 477 -------- .../vat-monitor/lib/vat-monitor-engine.ts | 453 -------- .../general/ai-categorization/api-routes.ts | 161 +++ extensions/general/ai-categorization/index.ts | 27 +- .../lib/__tests__/template-embeddings.test.ts | 233 ++++ .../lib/template-embeddings.ts | 265 +++++ .../general/ai-categorization/manifest.json | 19 + extensions/general/ai-chat/api-routes.ts | 587 ++++++++++ extensions/general/ai-chat/index.ts | 3 + extensions/general/ai-chat/manifest.json | 25 + extensions/general/calendar/manifest.json | 19 + extensions/general/email/index.ts | 12 + .../general/email/lib/invoice-templates.ts | 220 ++++ .../general/email/lib/reminder-templates.ts | 280 +++++ .../general/email/lib/resend-service.ts | 79 ++ extensions/general/email/manifest.json | 19 + .../general/enable-banking/manifest.json | 20 + .../general/invoice-inbox/api-routes.ts | 1025 +++++++++++++++++ extensions/general/invoice-inbox/index.ts | 3 + .../general/invoice-inbox/manifest.json | 19 + .../NotificationSettings.tsx | 6 +- .../general/push-notifications/PushPrompt.tsx | 4 +- .../general/push-notifications/api-routes.ts | 215 ++++ .../general/push-notifications/index.ts | 3 + .../general/push-notifications/manifest.json | 19 + extensions/general/receipt-ocr/api-routes.ts | 864 ++++++++++++++ .../components/TransactionMatcher.tsx | 2 +- extensions/general/receipt-ocr/index.ts | 3 + extensions/general/receipt-ocr/manifest.json | 25 + .../receipt-ocr/pages/ReceiptsPage.tsx | 6 +- .../pages/scan/ScanReceiptPage.tsx | 6 +- .../user-description-match/manifest.json | 19 + extensions/hotel/occupancy/index.ts | 2 - .../__tests__/occupancy-calculator.test.ts | 178 --- .../occupancy/lib/occupancy-calculator.ts | 151 --- extensions/hotel/revpar/index.ts | 2 - .../lib/__tests__/revpar-calculator.test.ts | 227 ---- .../hotel/revpar/lib/revpar-calculator.ts | 166 --- extensions/ne-bilaga/NEDeclarationView.tsx | 297 ----- .../ne-bilaga/__tests__/ne-engine.test.ts | 122 -- extensions/ne-bilaga/index.ts | 23 - extensions/ne-bilaga/ne-engine.ts | 8 - extensions/ne-bilaga/types.ts | 11 - .../restaurant/earnings-per-liter/index.ts | 3 - .../lib/__tests__/earnings-calculator.test.ts | 82 -- .../lib/earnings-calculator.ts | 40 - extensions/restaurant/food-cost/index.ts | 4 - .../__tests__/food-cost-calculator.test.ts | 114 -- .../food-cost/lib/food-cost-calculator.ts | 51 - extensions/restaurant/pos-import/index.ts | 2 - .../lib/__tests__/pos-calculator.test.ts | 225 ---- .../pos-import/lib/pos-calculator.ts | 183 --- extensions/restaurant/tip-tracking/index.ts | 2 - .../lib/__tests__/tip-calculator.test.ts | 196 ---- .../tip-tracking/lib/tip-calculator.ts | 180 --- extensions/sru-export/SRUExportView.tsx | 244 ---- extensions/sru-export/index.ts | 23 - extensions/sru-export/lib/sru-generator.ts | 9 - extensions/sru-export/sru-engine.ts | 9 - extensions/sru-export/sru-generator.ts | 12 - extensions/sru-export/types.ts | 7 - extensions/tech/billable-hours/index.ts | 2 - .../billable-hours-calculator.test.ts | 193 ---- .../lib/billable-hours-calculator.ts | 190 --- extensions/tech/project-billing/index.ts | 2 - .../lib/__tests__/billing-calculator.test.ts | 206 ---- .../project-billing/lib/billing-calculator.ts | 107 -- .../__tests__/invoice-entries.test.ts | 26 +- .../__tests__/mapping-engine.test.ts | 14 +- .../__tests__/template-embeddings.test.ts | 259 ++--- lib/bookkeeping/engine.ts | 21 +- .../supplier-invoice-handler.test.ts | 1 + .../handlers/supplier-invoice-handler.ts | 1 + lib/bookkeeping/invoice-entries.ts | 21 +- lib/bookkeeping/mapping-engine.ts | 8 +- lib/bookkeeping/supplier-invoice-entries.ts | 21 +- lib/bookkeeping/template-embeddings.ts | 276 +---- lib/bookkeeping/transaction-entries.ts | 6 +- lib/core/audit/audit-service.ts | 8 +- .../__tests__/period-service.test.ts | 25 +- .../__tests__/storno-service.test.ts | 16 +- .../__tests__/year-end-service.test.ts | 24 +- lib/core/bookkeeping/period-service.ts | 10 +- lib/core/bookkeeping/storno-service.ts | 7 +- lib/core/bookkeeping/year-end-service.ts | 34 +- .../__tests__/document-service.test.ts | 45 +- lib/core/documents/document-service.ts | 11 +- .../tax/__tests__/tax-code-service.test.ts | 13 +- lib/core/tax/tax-code-service.ts | 14 +- lib/email/resend.ts | 123 +- lib/email/service.ts | 51 + lib/events/types.ts | 10 - lib/extensions/__tests__/sectors.test.ts | 65 +- .../_generated/enabled-extensions.ts | 4 + lib/extensions/_generated/extension-list.ts | 5 + .../_generated/sector-definitions.ts | 5 + lib/extensions/_generated/workspace-map.tsx | 7 + lib/extensions/loader.ts | 43 +- lib/extensions/sectors.ts | 386 +------ lib/extensions/types.ts | 7 +- lib/extensions/workspace-registry.tsx | 40 +- lib/import/sie-import.ts | 29 +- lib/invoices/invoice-matching.ts | 8 +- lib/invoices/reminder-processor.ts | 4 +- lib/reports/__tests__/ar-ledger.test.ts | 23 +- .../__tests__/ar-reconciliation.test.ts | 21 +- lib/reports/__tests__/balance-sheet.test.ts | 18 +- lib/reports/__tests__/general-ledger.test.ts | 23 +- .../__tests__/income-statement.test.ts | 18 +- .../__tests__/journal-register.test.ts | 21 +- .../__tests__/monthly-breakdown.test.ts | 12 +- lib/reports/__tests__/sie-export.test.ts | 33 +- lib/reports/__tests__/supplier-ledger.test.ts | 23 +- .../__tests__/supplier-reconciliation.test.ts | 21 +- lib/reports/__tests__/trial-balance.test.ts | 29 +- lib/reports/__tests__/vat-declaration.test.ts | 29 +- lib/reports/ar-ledger.ts | 4 +- lib/reports/ar-reconciliation.ts | 4 +- lib/reports/balance-sheet.ts | 4 +- lib/reports/general-ledger.ts | 4 +- lib/reports/income-statement.ts | 4 +- lib/reports/journal-register.ts | 4 +- lib/reports/monthly-breakdown.ts | 4 +- lib/reports/ne-bilaga/ne-engine.ts | 4 +- lib/reports/sie-export.ts | 4 +- lib/reports/sru-export/sru-engine.ts | 7 +- lib/reports/supplier-ledger.ts | 4 +- lib/reports/supplier-reconciliation.ts | 4 +- lib/reports/trial-balance.ts | 10 +- lib/reports/vat-declaration.ts | 4 +- lib/transactions/__tests__/ingest.test.ts | 4 +- lib/transactions/category-suggestions.ts | 9 +- lib/transactions/ingest.ts | 3 + .../shared => lib/vat}/moms-box-mapping.ts | 0 package.json | 3 + scripts/create-extension.ts | 346 ++++++ scripts/generate-extension-registry.ts | 295 +++++ ...5_expand_account_type_untaxed_reserves.sql | 11 + types/index.ts | 19 - 289 files changed, 7325 insertions(+), 27598 deletions(-) create mode 100644 .github/workflows/core-build.yml delete mode 100644 app/api/extensions/ai-categorization/settings/route.ts delete mode 100644 app/api/extensions/ai-categorization/suggestions/route.ts delete mode 100644 app/api/extensions/ai-chat/route.ts delete mode 100644 app/api/extensions/ai-chat/sessions/[id]/route.ts delete mode 100644 app/api/extensions/ai-chat/sessions/route.ts delete mode 100644 app/api/extensions/ai-chat/stream/route.ts delete mode 100644 app/api/extensions/export/currency-receivables/report/route.ts delete mode 100644 app/api/extensions/export/eu-sales-list/download/route.ts delete mode 100644 app/api/extensions/export/eu-sales-list/report/route.ts delete mode 100644 app/api/extensions/export/intrastat/download/route.ts delete mode 100644 app/api/extensions/export/intrastat/report/route.ts delete mode 100644 app/api/extensions/export/vat-monitor/report/route.ts delete mode 100644 app/api/extensions/invoice-inbox/inbox/[id]/confirm-receipt/route.ts delete mode 100644 app/api/extensions/invoice-inbox/inbox/[id]/confirm/__tests__/route.test.ts delete mode 100644 app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts delete mode 100644 app/api/extensions/invoice-inbox/inbox/[id]/process/route.ts delete mode 100644 app/api/extensions/invoice-inbox/inbox/[id]/route.ts delete mode 100644 app/api/extensions/invoice-inbox/inbox/__tests__/route.test.ts delete mode 100644 app/api/extensions/invoice-inbox/inbox/route.ts delete mode 100644 app/api/extensions/invoice-inbox/settings/route.ts delete mode 100644 app/api/extensions/ne-bilaga/route.ts delete mode 100644 app/api/extensions/push-notifications/settings/route.ts delete mode 100644 app/api/extensions/push-notifications/subscribe/route.ts delete mode 100644 app/api/extensions/receipt-ocr/[id]/confirm/route.ts delete mode 100644 app/api/extensions/receipt-ocr/[id]/match/route.ts delete mode 100644 app/api/extensions/receipt-ocr/[id]/route.ts delete mode 100644 app/api/extensions/receipt-ocr/queue/route.ts delete mode 100644 app/api/extensions/receipt-ocr/route.ts delete mode 100644 app/api/extensions/receipt-ocr/settings/route.ts delete mode 100644 app/api/extensions/receipt-ocr/upload/route.ts delete mode 100644 app/api/extensions/sru-export/coverage/route.ts delete mode 100644 app/api/extensions/sru-export/route.ts delete mode 100644 components/extensions/construction/ProjectCostWorkspace.tsx delete mode 100644 components/extensions/construction/RotCalculatorWorkspace.tsx delete mode 100644 components/extensions/ecommerce/MultichannelRevenueWorkspace.tsx delete mode 100644 components/extensions/ecommerce/ShopifyImportWorkspace.tsx delete mode 100644 components/extensions/export/CurrencyReceivablesWorkspace.tsx delete mode 100644 components/extensions/export/EuSalesListWorkspace.tsx delete mode 100644 components/extensions/export/IntrastatWorkspace.tsx delete mode 100644 components/extensions/export/VatMonitorWorkspace.tsx delete mode 100644 components/extensions/hotel/OccupancyWorkspace.tsx delete mode 100644 components/extensions/hotel/RevparWorkspace.tsx delete mode 100644 components/extensions/restaurant/EarningsPerLiterWorkspace.tsx delete mode 100644 components/extensions/restaurant/FoodCostWorkspace.tsx delete mode 100644 components/extensions/restaurant/PosImportWorkspace.tsx delete mode 100644 components/extensions/restaurant/TipTrackingWorkspace.tsx delete mode 100644 components/extensions/tech/BillableHoursWorkspace.tsx delete mode 100644 components/extensions/tech/ProjectBillingWorkspace.tsx create mode 100644 dev_docs/PR1PR2.md create mode 100644 dev_docs/TWO_PHASES.md create mode 100644 extensions.config.json create mode 100644 extensions.schema.json delete mode 100644 extensions/construction/project-cost/index.ts delete mode 100644 extensions/construction/project-cost/lib/__tests__/project-cost-calculator.test.ts delete mode 100644 extensions/construction/project-cost/lib/project-cost-calculator.ts delete mode 100644 extensions/construction/rot-calculator/index.ts delete mode 100644 extensions/construction/rot-calculator/lib/__tests__/rot-calculator.test.ts delete mode 100644 extensions/construction/rot-calculator/lib/rot-calculator.ts delete mode 100644 extensions/ecommerce/multichannel-revenue/index.ts delete mode 100644 extensions/ecommerce/multichannel-revenue/lib/__tests__/multichannel-calculator.test.ts delete mode 100644 extensions/ecommerce/multichannel-revenue/lib/multichannel-calculator.ts delete mode 100644 extensions/ecommerce/shopify-import/index.ts delete mode 100644 extensions/ecommerce/shopify-import/lib/__tests__/shopify-calculator.test.ts delete mode 100644 extensions/ecommerce/shopify-import/lib/shopify-calculator.ts delete mode 100644 extensions/export/currency-receivables/index.ts delete mode 100644 extensions/export/currency-receivables/lib/__tests__/receivables-engine.test.ts delete mode 100644 extensions/export/currency-receivables/lib/receivables-engine.ts delete mode 100644 extensions/export/eu-sales-list/index.ts delete mode 100644 extensions/export/eu-sales-list/lib/__tests__/csv-generator.test.ts delete mode 100644 extensions/export/eu-sales-list/lib/__tests__/eu-sales-list-engine.test.ts delete mode 100644 extensions/export/eu-sales-list/lib/__tests__/skv-xml-generator.test.ts delete mode 100644 extensions/export/eu-sales-list/lib/csv-generator.ts delete mode 100644 extensions/export/eu-sales-list/lib/eu-sales-list-engine.ts delete mode 100644 extensions/export/eu-sales-list/lib/skv-xml-generator.ts delete mode 100644 extensions/export/intrastat/index.ts delete mode 100644 extensions/export/intrastat/lib/__tests__/intrastat-engine.test.ts delete mode 100644 extensions/export/intrastat/lib/__tests__/scb-csv-generator.test.ts delete mode 100644 extensions/export/intrastat/lib/intrastat-engine.ts delete mode 100644 extensions/export/intrastat/lib/scb-csv-generator.ts delete mode 100644 extensions/export/shared/eu-countries.ts delete mode 100644 extensions/export/vat-monitor/index.ts delete mode 100644 extensions/export/vat-monitor/lib/__tests__/vat-monitor-engine.test.ts delete mode 100644 extensions/export/vat-monitor/lib/vat-monitor-engine.ts create mode 100644 extensions/general/ai-categorization/api-routes.ts create mode 100644 extensions/general/ai-categorization/lib/__tests__/template-embeddings.test.ts create mode 100644 extensions/general/ai-categorization/lib/template-embeddings.ts create mode 100644 extensions/general/ai-categorization/manifest.json create mode 100644 extensions/general/ai-chat/api-routes.ts create mode 100644 extensions/general/ai-chat/manifest.json create mode 100644 extensions/general/calendar/manifest.json create mode 100644 extensions/general/email/index.ts create mode 100644 extensions/general/email/lib/invoice-templates.ts create mode 100644 extensions/general/email/lib/reminder-templates.ts create mode 100644 extensions/general/email/lib/resend-service.ts create mode 100644 extensions/general/email/manifest.json create mode 100644 extensions/general/enable-banking/manifest.json create mode 100644 extensions/general/invoice-inbox/api-routes.ts create mode 100644 extensions/general/invoice-inbox/manifest.json create mode 100644 extensions/general/push-notifications/api-routes.ts create mode 100644 extensions/general/push-notifications/manifest.json create mode 100644 extensions/general/receipt-ocr/api-routes.ts create mode 100644 extensions/general/receipt-ocr/manifest.json create mode 100644 extensions/general/user-description-match/manifest.json delete mode 100644 extensions/hotel/occupancy/index.ts delete mode 100644 extensions/hotel/occupancy/lib/__tests__/occupancy-calculator.test.ts delete mode 100644 extensions/hotel/occupancy/lib/occupancy-calculator.ts delete mode 100644 extensions/hotel/revpar/index.ts delete mode 100644 extensions/hotel/revpar/lib/__tests__/revpar-calculator.test.ts delete mode 100644 extensions/hotel/revpar/lib/revpar-calculator.ts delete mode 100644 extensions/ne-bilaga/NEDeclarationView.tsx delete mode 100644 extensions/ne-bilaga/__tests__/ne-engine.test.ts delete mode 100644 extensions/ne-bilaga/index.ts delete mode 100644 extensions/ne-bilaga/ne-engine.ts delete mode 100644 extensions/ne-bilaga/types.ts delete mode 100644 extensions/restaurant/earnings-per-liter/index.ts delete mode 100644 extensions/restaurant/earnings-per-liter/lib/__tests__/earnings-calculator.test.ts delete mode 100644 extensions/restaurant/earnings-per-liter/lib/earnings-calculator.ts delete mode 100644 extensions/restaurant/food-cost/index.ts delete mode 100644 extensions/restaurant/food-cost/lib/__tests__/food-cost-calculator.test.ts delete mode 100644 extensions/restaurant/food-cost/lib/food-cost-calculator.ts delete mode 100644 extensions/restaurant/pos-import/index.ts delete mode 100644 extensions/restaurant/pos-import/lib/__tests__/pos-calculator.test.ts delete mode 100644 extensions/restaurant/pos-import/lib/pos-calculator.ts delete mode 100644 extensions/restaurant/tip-tracking/index.ts delete mode 100644 extensions/restaurant/tip-tracking/lib/__tests__/tip-calculator.test.ts delete mode 100644 extensions/restaurant/tip-tracking/lib/tip-calculator.ts delete mode 100644 extensions/sru-export/SRUExportView.tsx delete mode 100644 extensions/sru-export/index.ts delete mode 100644 extensions/sru-export/lib/sru-generator.ts delete mode 100644 extensions/sru-export/sru-engine.ts delete mode 100644 extensions/sru-export/sru-generator.ts delete mode 100644 extensions/sru-export/types.ts delete mode 100644 extensions/tech/billable-hours/index.ts delete mode 100644 extensions/tech/billable-hours/lib/__tests__/billable-hours-calculator.test.ts delete mode 100644 extensions/tech/billable-hours/lib/billable-hours-calculator.ts delete mode 100644 extensions/tech/project-billing/index.ts delete mode 100644 extensions/tech/project-billing/lib/__tests__/billing-calculator.test.ts delete mode 100644 extensions/tech/project-billing/lib/billing-calculator.ts create mode 100644 lib/email/service.ts create mode 100644 lib/extensions/_generated/enabled-extensions.ts create mode 100644 lib/extensions/_generated/extension-list.ts create mode 100644 lib/extensions/_generated/sector-definitions.ts create mode 100644 lib/extensions/_generated/workspace-map.tsx rename {extensions/export/shared => lib/vat}/moms-box-mapping.ts (100%) create mode 100644 scripts/create-extension.ts create mode 100644 scripts/generate-extension-registry.ts create mode 100644 supabase/migrations/20240101000045_expand_account_type_untaxed_reserves.sql diff --git a/.github/workflows/core-build.yml b/.github/workflows/core-build.yml new file mode 100644 index 00000000..2d52aa8e --- /dev/null +++ b/.github/workflows/core-build.yml @@ -0,0 +1,30 @@ +name: Core Build (no extensions) + +on: [pull_request] + +jobs: + core-only: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - name: Reset extensions config + run: echo '{"extensions":[]}' > extensions.config.json + - run: npm run setup:extensions + - run: npm run build + - run: npm test + - name: Check no core imports from extensions + run: | + VIOLATIONS=$(grep -r "from '@/extensions/" lib/ app/api/ components/ --include="*.ts" --include="*.tsx" \ + | grep -v "app/api/extensions/" \ + | grep -v "components/extensions/" \ + | grep -v "lib/extensions/_generated/" \ + | grep -v "lib/extensions/loader.ts" || true) + if [ -n "$VIOLATIONS" ]; then + echo "ERROR: Core code imports from @/extensions/:" + echo "$VIOLATIONS" + exit 1 + fi diff --git a/.gitignore b/.gitignore index b3f47885..c8247446 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,8 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Extension registry (auto-generated but defaults are committed) +# Run `npm run setup:extensions` to regenerate after changing extensions.config.json +# The empty defaults in lib/extensions/_generated/ are committed so core compiles +# out of the box without running the generator. diff --git a/CLAUDE.md b/CLAUDE.md index a87b7dbd..9ad17b9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,11 +17,12 @@ erp-base is a Swedish-focused accounting SaaS for sole traders (enskild firma) a ## Commands ```bash -npm run dev # Start development server -npm run build # Production build -npm run lint # ESLint -npm test # Run all Vitest tests -npx vitest run # Run tests in a specific directory +npm run dev # Start development server (runs setup:extensions first) +npm run build # Production build (runs setup:extensions first) +npm run lint # ESLint +npm test # Run all Vitest tests +npx vitest run # Run tests in a specific directory +npm run setup:extensions # Regenerate extension registry from extensions.config.json ``` --- @@ -50,7 +51,7 @@ components/ TaxTodoWidget, UpcomingDeadlinesWidget extensions/ Extension marketplace UI (ExtensionCard, SectorCard, ExtensionToggleButton, workspace components, - per-sector subdirectories, shared reusable components) + general/ subdirectory for general extension workspaces) import/ Bank file import workflow components (SIE + bank file steps) invoices/ InvoiceReviewContent onboarding/ NewUserChecklist, setup step components @@ -60,36 +61,18 @@ components/ transactions/ Transaction list, categorization, booking, swipe review, batch operations, invoice matching, VAT treatment -extensions/ Sector-based extension hierarchy +extensions/ Config-driven extension directory (general only) general/ General-purpose extensions (all businesses) ai-categorization/ AI-powered transaction categorization ai-chat/ Claude-based chat assistant (LangChain RAG) calendar/ Payment calendar views, deadline cards, payment summary - enable-banking/ PSD2 bank integration (opt-in, commented out in loader) + email/ Email service (Resend) — registers via EmailService interface + enable-banking/ PSD2 bank integration (opt-in) example-logger/ Minimal reference extension (not loaded) invoice-inbox/ Supplier invoice intake via email/upload with AI extraction push-notifications/ Web push notification system receipt-ocr/ Receipt image OCR processing (includes components/pages) user-description-match/ User description matching for transaction categorization - restaurant/ Restaurant & cafe sector - food-cost/ Food cost percentage calculator - earnings-per-liter/ Revenue per liter of alcohol - pos-import/ POS Z-report import - tip-tracking/ Tip tracking per shift/employee - construction/ Construction & trades sector - rot-calculator/ ROT tax deduction calculator - project-cost/ Project cost tracking - hotel/ Hotel & lodging sector - revpar/ Revenue Per Available Room - occupancy/ Occupancy rate tracking - tech/ IT & consulting sector - billable-hours/ Billable hours & utilization rate - project-billing/ Project billing analysis - ecommerce/ E-commerce sector - shopify-import/ Shopify order import - multichannel-revenue/ Multi-channel revenue analysis - ne-bilaga/ NE tax form attachment generation (top-level) - sru-export/ SRU file export (top-level) lib/ api/ Zod validation schemas and utilities for API routes @@ -118,24 +101,32 @@ lib/ calendar/ Calendar utilities, ICS feed generation currency/ Riksbanken exchange rates deadlines/ Tax deadline tracking, status engine - email/ Email service (Resend), invoice/reminder templates + email/ Email service interface and registry + service.ts EmailService interface, NoopEmailService default, + getEmailService()/registerEmailService() registry + resend.ts Legacy Resend helpers (invoice/reminder templates) errors/ Error message utilities (get-error-message.ts) events/ Event bus (bus.ts, types.ts) extensions/ Extension system - loader.ts FIRST_PARTY_EXTENSIONS array, static imports + loader.ts Loads extensions from generated FIRST_PARTY_EXTENSIONS registry.ts Runtime extension registry types.ts Extension, Sector, ExtensionDefinition, toggle types - sectors.ts Sector & extension metadata registry (pure data) + sectors.ts Sector shells + generated extension definitions hooks.ts React hooks for extension state context-factory.ts Extension context builder toggle-check.ts Extension enable/disable logic validation.ts Extension data validation - workspace-registry.tsx Extension workspace component registry + workspace-registry.tsx Workspace component lookup from generated map icon-resolver.tsx Dynamic icon lookup for extensions use-account-totals.ts Hook for account balance queries use-extension-data.ts Hook for extension-specific data invoice-inbox-utils.ts Utilities for supplier invoice inbox use-mock-data.ts Mock data utilities for development + _generated/ Code-generated files (DO NOT EDIT) + extension-list.ts FIRST_PARTY_EXTENSIONS array + sector-definitions.ts EXTENSION_DEFINITIONS per sector + workspace-map.tsx Lazy-loaded workspace component registry + enabled-extensions.ts Set of enabled extension IDs hooks/ React hooks (use-unsaved-changes.ts) import/ SIE parser, SIE import orchestrator, bank file parser bank-file/ Bank file parser with format modules @@ -143,17 +134,35 @@ lib/ ica-banken, lansforsakringar, lunar, skandia invoices/ VAT rules, invoice matching, PDF template, reminder processor reconciliation/ Bank reconciliation engine (4-pass matching algorithm) - reports/ Financial reports (trial-balance, income-statement, - balance-sheet, vat-declaration, sie-export, - supplier-ledger, supplier-reconciliation, - general-ledger, journal-register, - ar-ledger, ar-reconciliation, monthly-breakdown) + reports/ Financial reports + trial-balance.ts Trial balance report + income-statement.ts Income statement (resultatrakning) + balance-sheet.ts Balance sheet (balansrakning) + vat-declaration.ts VAT declaration (momsdeklaration) + sie-export.ts SIE file export + general-ledger.ts General ledger (huvudbok) + journal-register.ts Journal register (grundbok) + ar-ledger.ts Accounts receivable ledger + ar-reconciliation.ts AR reconciliation + supplier-ledger.ts Supplier/AP ledger + supplier-reconciliation.ts Supplier reconciliation + monthly-breakdown.ts Monthly breakdown report + ne-bilaga/ NE tax form attachment (core report, not extension) + ne-engine.ts NE declaration engine + types.ts NE-specific types + sru-export/ SRU file export (core report, not extension) + sru-engine.ts SRU generation engine + sru-generator.ts SRU file generator + sru-generic-generator.ts Generic SRU generator + types.ts SRU-specific types supabase/ Client setup (client.ts = browser, server.ts = server/admin, fetch-all.ts = pagination helper, middleware.ts) tax/ Tax calculations, deadlines, deadline generator, Swedish holidays, expense warnings transactions/ Transaction processing, category suggestions - vat/ VAT utilities (VIES client for EU VAT validation) + vat/ VAT utilities + vies-client.ts VIES EU VAT number validation + moms-box-mapping.ts BAS account to momsdeklaration box mapping init.ts Extension loader (idempotent, called by API routes) logger.ts Centralized logging utility utils.ts Shared utility functions @@ -161,13 +170,18 @@ lib/ types/index.ts Canonical type definitions (single source of truth) types/chat.ts Chat-specific type definitions tests/helpers.ts Mock factories and fixture builders -supabase/migrations/ SQL migration files -scripts/ Utility scripts (clear-user-data.sql, copy-extensions.mjs, - move-extensions.js, setup-phase8.js) +supabase/migrations/ SQL migration files (45 files) +scripts/ + generate-extension-registry.ts Code generator for extension system + create-extension.ts Helper for creating new extensions + clear-user-data.sql Utility SQL for data cleanup dev_docs/ Project documentation (BAS account guides, gap analysis, Enable Banking docs, Bokio reference screenshots) -extensions.md Extension system design document (architecture, data patterns, - sector model, workspace pattern, migration plan) +extensions.config.json Extension opt-in configuration (controls which extensions load) +extensions.schema.json JSON Schema for extensions.config.json +extensions.md Extension system design document +.github/workflows/ CI workflows + core-build.yml PR build — verifies core builds/tests with zero extensions ``` ### Key Relationships @@ -176,7 +190,9 @@ extensions.md Extension system design document (architecture, data p - **API routes** that emit events must call `ensureInitialized()` (from `lib/init.ts`) at module level to load extensions. - **Event bus** (`lib/events/bus.ts`) is a module-level singleton. Core services emit, extensions subscribe. - **Supabase clients**: browser (`lib/supabase/client.ts`), server with user cookies (`createClient()` from `lib/supabase/server.ts`), and service role (`createServiceClient()`). -- **Extension sector system**: Extensions are organized by business sector (`lib/extensions/sectors.ts`). Users can browse/toggle extensions via the marketplace UI (`app/(dashboard)/extensions/`). Sector-specific extension workspaces are rendered at `app/(dashboard)/e/[sector]/[slug]/`. +- **Extension system**: Extensions are opt-in via `extensions.config.json`. Code generation (`npm run setup:extensions`) produces static imports from manifests. Core builds and runs with zero extensions. +- **Email service**: Core defines `EmailService` interface in `lib/email/service.ts` with a `NoopEmailService` default. The `email` extension registers the real Resend implementation at load time. Core uses `getEmailService()` which degrades gracefully. +- **NE-bilaga and SRU export** are core reports (in `lib/reports/`), not extensions. They are always available. --- @@ -260,43 +276,145 @@ These rules exist for legal compliance and are enforced by database triggers. ** --- -## Extension Development +## Extension System -Extensions are first-party plugins organized by business sector in the `/extensions/` directory, loaded statically at startup. +Extensions are opt-in plugins controlled by `extensions.config.json`. The system uses a manifest-driven, code-generated architecture where core builds and runs with zero extensions. + +### How It Works + +1. Each extension has a `manifest.json` declaring its metadata, entry point, workspace component, dependencies, and required env vars. +2. `extensions.config.json` lists which extensions to load (by ID). +3. `npm run setup:extensions` reads the config and manifests, then generates four files in `lib/extensions/_generated/`: + - `extension-list.ts` — `FIRST_PARTY_EXTENSIONS` array (static imports) + - `sector-definitions.ts` — `EXTENSION_DEFINITIONS` map for marketplace UI + - `workspace-map.tsx` — Lazy-loaded workspace component registry + - `enabled-extensions.ts` — Set of enabled extension IDs +4. `predev` and `prebuild` hooks run this automatically. + +### Enabling Extensions + +Edit `extensions.config.json`: +```json +{ + "$schema": "./extensions.schema.json", + "extensions": ["receipt-ocr", "ai-categorization", "email"] +} +``` + +Then run `npm run setup:extensions` (or just `npm run dev`/`npm run build`). + +### Available Extensions + +All extensions live in `extensions/general/` with `manifest.json` files: + +| Extension | Category | Data Pattern | Env Vars Required | +|-----------|----------|--------------|-------------------| +| `receipt-ocr` | import | manual | `ANTHROPIC_API_KEY` | +| `ai-categorization` | operations | core | `OPENAI_API_KEY` | +| `ai-chat` | operations | manual | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` | +| `push-notifications` | operations | core | `NEXT_PUBLIC_VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` | +| `invoice-inbox` | import | manual | `ANTHROPIC_API_KEY` | +| `calendar` | operations | core | — | +| `enable-banking` | import | manual | `ENABLE_BANKING_APP_ID`, `ENABLE_BANKING_PRIVATE_KEY` | +| `email` | operations | core | `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | +| `user-description-match` | operations | core | — | ### Sector System -Extensions are grouped into sectors defined in `lib/extensions/sectors.ts`. Each sector targets a specific industry (restaurant, construction, hotel, tech, ecommerce) or serves all businesses (general). The sector registry provides metadata used by the extension marketplace UI. +Only the `general` sector exists. Extensions are grouped under it. Key types (from `lib/extensions/types.ts`): -- `SectorSlug` — `'general' | 'restaurant' | 'construction' | 'hotel' | 'tech' | 'ecommerce'` -- `ExtensionDefinition` — Marketplace metadata (slug, name, sector, category, icon, dataPattern, description) +- `SectorSlug` — `'general'` +- `ExtensionDefinition` — Marketplace metadata (slug, name, sector, category, icon, dataPattern, description, longDescription, readsCoreTables, hasOwnData) - `ExtensionCategory` — `'import' | 'operations' | 'reports' | 'accounting'` - `ExtensionDataPattern` — `'core' | 'manual' | 'both'` (how extension accesses data) - `ExtensionToggle` — Per-user enable/disable state for extensions ### Creating a New Extension -1. Create `extensions///index.ts` -2. Export an object implementing the `Extension` interface from `lib/extensions/types.ts` -3. Add a static import to the `FIRST_PARTY_EXTENSIONS` array in `lib/extensions/loader.ts` -4. Add metadata to the appropriate sector in `lib/extensions/sectors.ts` -5. Extensions **cannot** use dynamic imports (Next.js bundling constraint) +Use the scaffolding script: -### Currently Loaded Extensions (FIRST_PARTY_EXTENSIONS) +```bash +npx tsx scripts/create-extension.ts \ + --name my-extension \ + --sector general \ + --category operations \ + --description "Short description" +``` +This creates: +1. `extensions/general/my-extension/manifest.json` — Full manifest template +2. `extensions/general/my-extension/index.ts` — Extension object skeleton +3. `extensions/general/my-extension/api-routes.ts` — Empty API routes array +4. Updates `extensions.schema.json` with the new ID + +Then to activate: +1. Add `"my-extension"` to `extensions.config.json` +2. Run `npm run setup:extensions` to regenerate + +**Manual steps** (if not using the script): +1. Create `extensions/general//manifest.json` (see Manifest Format below) +2. Create `extensions/general//index.ts` exporting an `Extension` object +3. Optionally create `extensions/general//api-routes.ts` for API endpoints +4. Add the extension ID to `extensions.schema.json` and `extensions.config.json` +5. Run `npm run setup:extensions` + +**Constraints**: Extensions **cannot** use dynamic imports (Next.js bundling constraint). + +### Manifest Format + +Every extension declares a `manifest.json`. All fields are required unless noted: + +```json +{ + "id": "my-extension", + "sector": "general", + "exportName": "myExtensionExtension", + "entryPoint": "@/extensions/general/my-extension", + "workspace": "@/components/extensions/general/MyExtensionWorkspace", + "requiredEnvVars": ["MY_API_KEY"], + "optionalEnvVars": [], + "npmDependencies": ["some-sdk"], + "definition": { + "name": "My Extension", + "category": "operations", + "icon": "Wrench", + "dataPattern": "core", + "readsCoreTables": ["transactions"], + "hasOwnData": false, + "description": "Short one-line description (Swedish)", + "longDescription": "Multi-line description (Swedish)", + "quickAction": { + "label": "Do Thing", + "description": "Does the thing", + "icon": "Wrench", + "href": "/my-page" + }, + "subscriptionNotice": "Optional notice shown when enabling" + } +} ``` -receiptOcrExtension @/extensions/general/receipt-ocr -aiCategorizationExtension @/extensions/general/ai-categorization -pushNotificationsExtension @/extensions/general/push-notifications -sruExportExtension @/extensions/sru-export -neBilagaExtension @/extensions/ne-bilaga -aiChatExtension @/extensions/general/ai-chat -invoiceInboxExtension @/extensions/general/invoice-inbox -calendarExtension @/extensions/general/calendar -userDescriptionMatchExtension @/extensions/general/user-description-match -# enableBankingExtension @/extensions/general/enable-banking (commented out, opt-in) -``` + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Unique kebab-case ID | +| `sector` | string | Always `"general"` | +| `exportName` | string \| null | Named export from `entryPoint` (null = metadata-only, no runtime code) | +| `entryPoint` | string \| null | Import path to Extension definition (null if no runtime code) | +| `workspace` | string \| null | Import path to React workspace component (null if no UI) | +| `requiredEnvVars` | string[] | Env vars that must be set for the extension to function | +| `optionalEnvVars` | string[] | Env vars that enhance but are not required | +| `npmDependencies` | string[] | npm packages the extension needs | +| `definition.name` | string | Display name for marketplace UI | +| `definition.category` | string | `'import'` \| `'operations'` \| `'reports'` \| `'accounting'` | +| `definition.icon` | string | Lucide icon name (e.g., `"Camera"`, `"Brain"`) | +| `definition.dataPattern` | string | `'core'` (reads existing data), `'manual'` (user submits data), `'both'` | +| `definition.readsCoreTables` | string[] | Optional. Which tables it reads (for `core`/`both` pattern) | +| `definition.hasOwnData` | boolean | Optional. Whether users submit data (for `manual`/`both` pattern) | +| `definition.description` | string | One-line description (Swedish) | +| `definition.longDescription` | string | Multi-line description (Swedish) | +| `definition.quickAction` | object | Optional. Dashboard quick action button | +| `definition.subscriptionNotice` | string | Optional. Notice shown when user enables the extension | ### Extension Interface @@ -305,6 +423,7 @@ interface Extension { id: string // Unique identifier (e.g. 'receipt-ocr') name: string // Display name version: string // Semver + sector?: SectorSlug // Always 'general' // Surfaces (all optional) routes?: RouteDefinition[] @@ -316,6 +435,7 @@ interface Extension { settingsPanel?: SettingsPanelDefinition taxCodes?: TaxCodeDefinition[] dimensionTypes?: DimensionDefinition[] + services?: Record Promise> // Lifecycle hooks onInstall?(ctx: ExtensionContext): Promise @@ -323,10 +443,164 @@ interface Extension { } ``` -### Minimal Example +### Extension Context -See `extensions/general/example-logger/index.ts`: +Event handlers and API route handlers receive an `ExtensionContext` (`lib/extensions/context-factory.ts`): +```typescript +interface ExtensionContext { + userId: string // Current authenticated user + extensionId: string // Which extension is running + supabase: SupabaseClient // Pre-authenticated Supabase client + emit(event: CoreEvent): Promise // Emit events to event bus + settings: ExtensionSettings // Key-value store (JSONB in extension_data table) + storage: ExtensionStorage // Supabase Storage wrapper + log: ExtensionLogger // Scoped logging (prefixed "ext:extension-id") + services: ExtensionServices // Core services (ingestTransactions, etc.) +} +``` + +**ExtensionSettings** — Key-value JSONB persisted in `extension_data` table: +```typescript +await ctx.settings.get() // Get 'settings' key (default) +await ctx.settings.get('config') // Get specific key +await ctx.settings.set('settings', newValue) // Set value +``` + +**ExtensionStorage** — Supabase Storage wrapper: +```typescript +await ctx.storage.upload('receipts', path, data, { contentType: 'image/jpeg' }) +await ctx.storage.download('receipts', path) +ctx.storage.getPublicUrl('receipts', path) +``` + +**ExtensionLogger** — Namespaced logging: +```typescript +ctx.log.info('Processing receipt') // logs: "ext:receipt-ocr: Processing receipt" +ctx.log.warn('Low confidence') +ctx.log.error('OCR failed', error) +``` + +### Extension API Routes + +Extensions expose API endpoints via `apiRoutes`. These are dispatched through a single catch-all route at `app/api/extensions/ext/[...path]/route.ts`. + +**URL scheme**: `/api/extensions/ext/{extensionId}/{...routePath}` + +Examples: +- `POST /api/extensions/ext/receipt-ocr/upload` → matches `POST /upload` +- `GET /api/extensions/ext/receipt-ocr/abc123` → matches `GET /:id` +- `POST /api/extensions/ext/ai-categorization/suggestions` → matches `POST /suggestions` + +**Defining routes** (in `api-routes.ts`): +```typescript +import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types' +import { NextResponse } from 'next/server' + +export const myApiRoutes: ApiRouteDefinition[] = [ + { + method: 'GET', + path: '/', + handler: async (request: Request, ctx?: ExtensionContext) => { + const userId = ctx!.userId + const { data } = await ctx!.supabase + .from('my_table') + .select('*') + .eq('user_id', userId) + return NextResponse.json({ data }) + }, + }, + { + method: 'GET', + path: '/:id', + handler: async (request: Request, ctx?: ExtensionContext) => { + // Path params are extracted as _paramName search params + const id = new URL(request.url).searchParams.get('_id') + // ... + }, + }, + { + method: 'POST', + path: '/:id/confirm', + handler: async (request: Request, ctx?: ExtensionContext) => { + const id = new URL(request.url).searchParams.get('_id') + const body = await request.json() + // Emit events after success: + await ctx!.emit({ type: 'receipt.confirmed', payload: { ... } }) + return NextResponse.json({ data: result }) + }, + }, +] +``` + +**Dispatcher behavior**: +1. Authenticates user (401 if not logged in) +2. Looks up extension in registry (404 if not found) +3. Checks extension toggle for user (403 if disabled) +4. Matches HTTP method + path pattern (supports `:param` wildcards) +5. Builds `ExtensionContext` and calls the matched handler + +### Service Provider Pattern + +Extensions can register capabilities that core calls without direct imports. Two patterns: + +**1. Interface registration (email pattern)**: + +Core defines an interface with a no-op default (`lib/email/service.ts`): +```typescript +export interface EmailService { + sendEmail(options: SendEmailOptions): Promise + isConfigured(): boolean +} + +let emailService: EmailService = new NoopEmailService() +export function getEmailService(): EmailService { return emailService } +export function registerEmailService(svc: EmailService): void { emailService = svc } +``` + +Extension registers the real implementation at load time (`extensions/general/email/index.ts`): +```typescript +import { registerEmailService } from '@/lib/email/service' +import { ResendEmailService } from './lib/resend-service' + +registerEmailService(new ResendEmailService()) + +export const emailExtension: Extension = { + id: 'email', + name: 'E-post (Resend)', + version: '1.0.0', +} +``` + +Core callers use `getEmailService()` — gracefully degrades if extension not loaded. + +**2. Services record (ai-categorization pattern)**: + +Extension exposes named functions via `services`: +```typescript +export const aiCategorizationExtension: Extension = { + id: 'ai-categorization', + name: 'AI Kategorisering', + version: '1.0.0', + services: { + findSimilarTemplates: async (...args: unknown[]) => { + const { findSimilarTemplates } = await import('./lib/template-embeddings') + return findSimilarTemplates(args[0], args[1], args[2], args[3]) + }, + }, +} +``` + +Core looks up via registry (no direct import): +```typescript +const ext = extensionRegistry.get('ai-categorization') +const results = await ext?.services?.findSimilarTemplates(tx, entityType, limit) +if (results) { /* use results */ } // Gracefully degrades if not loaded +``` + +### Extension Examples + +**Minimal** — `extensions/general/example-logger/index.ts`: ```typescript import type { Extension } from '@/lib/extensions/types' import type { EventPayload } from '@/lib/events/types' @@ -346,6 +620,47 @@ export const myExtension: Extension = { } ``` +**Full-featured** — Receipt OCR extension (`extensions/general/receipt-ocr/index.ts`): +```typescript +export const receiptOcrExtension: Extension = { + id: 'receipt-ocr', + name: 'Receipt OCR', + version: '1.0.0', + sector: 'general', + apiRoutes: receiptOcrApiRoutes, // GET /, POST /upload, GET /:id, POST /:id/confirm, etc. + eventHandlers: [ + { eventType: 'document.uploaded', handler: handleDocumentUploaded }, + { eventType: 'transaction.synced', handler: handleTransactionSynced }, + ], + mappingRuleTypes: [ + { id: 'receipt-ocr-merchant', name: 'OCR Merchant Match', description: '...' }, + ], + settingsPanel: { + label: 'Receipt OCR', + path: '/settings/extensions/receipt-ocr', + }, + async onInstall(ctx) { + await ctx.settings.set('settings', DEFAULT_SETTINGS) + }, +} +``` + +### Key Extension Files + +| File | Purpose | +|------|---------| +| `extensions/general/*/manifest.json` | Metadata for each extension | +| `extensions/general/*/index.ts` | Extension object (services, handlers, etc.) | +| `extensions/general/*/api-routes.ts` | API route definitions | +| `extensions.config.json` | Which extensions are enabled | +| `extensions.schema.json` | JSON Schema for config validation | +| `scripts/generate-extension-registry.ts` | Generator script | +| `scripts/create-extension.ts` | Scaffolding helper | +| `app/api/extensions/ext/[...path]/route.ts` | Catch-all API route dispatcher | +| `lib/extensions/context-factory.ts` | Builds ExtensionContext for handlers | +| `lib/extensions/loader.ts` | Loads FIRST_PARTY_EXTENSIONS into registry | +| `lib/extensions/_generated/*` | Auto-generated files (DO NOT EDIT) | + ### Available Event Types All defined in `lib/events/types.ts`: @@ -358,25 +673,19 @@ All defined in `lib/events/types.ts`: | `document.uploaded` | `{ document, userId }` | | `invoice.created` | `{ invoice, userId }` | | `invoice.sent` | `{ invoice, userId }` | -| `invoice.paid` | `{ invoice, transaction, kursdifferens?, userId }` | -| `invoice.overdue` | `{ invoice, days, userId }` | | `credit_note.created` | `{ creditNote, userId }` | | `transaction.synced` | `{ transactions[], userId }` | | `transaction.categorized` | `{ transaction, account, taxCode, userId }` | | `transaction.reconciled` | `{ transaction, journalEntryId, method, userId }` | -| `bank.statement_received` | `{ statement, userId }` | -| `bank.payment_notification` | `{ notification, userId }` | | `period.locked` | `{ period, userId }` | | `period.year_closed` | `{ period, userId }` | | `customer.created` | `{ customer, userId }` | -| `customer.pseudonymized` | `{ customerId, userId }` | | `receipt.extracted` | `{ receipt, documentId, confidence, userId }` | | `receipt.matched` | `{ receipt, transaction, confidence, autoMatched, userId }` | | `receipt.confirmed` | `{ receipt, businessTotal, privateTotal, userId }` | | `supplier_invoice.received` | `{ inboxItem, userId }` | | `supplier_invoice.extracted` | `{ inboxItem, confidence, userId }` | | `supplier_invoice.confirmed` | `{ inboxItem, supplierInvoice, userId }` | -| `audit.security_event` | `{ event, userId }` | ### Event Bus Behavior @@ -484,11 +793,11 @@ mockResult({ data: makeTransaction(), error: null }) ### Location -`supabase/migrations/` — currently 41 files numbered `20240101000001` through `20240101000041`. +`supabase/migrations/` — currently 45 files numbered `20240101000001` through `20240101000045`. ### Naming Convention -`YYYYMMDD00NNNN_descriptive_name.sql` — next migration: `20240101000042_*.sql` +`YYYYMMDD00NNNN_descriptive_name.sql` — next migration: `20240101000046_*.sql` ### Migration Rules @@ -524,18 +833,13 @@ mockResult({ data: makeTransaction(), error: null }) ### Recent Migrations -- **Migration 030 (`bank_reconciliation`)** — Adds `reconciliation_method` column to `transactions` (CHECK constraint for method types), indexes for unmatched transaction lookup, and RPC `get_unlinked_1930_lines()` for finding unreconciled GL lines. -- **Migration 031 (`invoice_document_type`)** — Adds `document_type` column to `invoices` (CHECK: invoice/proforma/delivery_note, default 'invoice') and `converted_from_id` FK for tracking proforma-to-invoice conversions. -- **Migration 032 (`add_accounting_method`)** — Adds `accounting_method` column to `company_settings` (CHECK: accrual/cash, default 'accrual') to support kontantmetoden vs faktureringsmetoden. -- **Migration 033 (`ai_chat_schema`)** — AI chat conversation and message storage. -- **Migration 034 (`fix_extension_data_trigger`)** — Fixes extension data trigger. -- **Migration 035 (`fix_push_notifications`)** — Push notifications schema fix. -- **Migration 036 (`fix_enable_banking`)** — Enable Banking schema fix. -- **Migration 037 (`extension_toggles`)** — Extension toggle table for per-user enable/disable. -- **Migration 038 (`fix_match_documents_search_path`)** — Fixes search path for document matching function. - **Migration 039 (`invoice_inbox`)** — Invoice inbox table for supplier invoice intake via email/upload. - **Migration 040 (`booking_template_embeddings`)** — Booking templates with AI embeddings for suggestion matching. - **Migration 041 (`user_description_matching`)** — User description matching for transaction categorization. +- **Migration 042 (`full_bas_2026` + `prevent_overlapping_fiscal_periods`)** — Full BAS 2026 account catalog and fiscal period overlap prevention. +- **Migration 043 (`enforce_fiscal_period_month_boundaries`)** — Ensures fiscal periods start/end on month boundaries. +- **Migration 044 (`document_matching`)** — Document-to-transaction matching support. +- **Migration 045 (`expand_account_type_untaxed_reserves`)** — Adds `untaxed_reserves` to `chart_of_accounts.account_type` CHECK constraint for BAS 21xx accounts (obeskattade reserver). --- @@ -617,6 +921,17 @@ docs: update CLAUDE.md with extension guide --- +## CI + +GitHub Actions workflow (`.github/workflows/core-build.yml`) runs on pull requests: +- Resets `extensions.config.json` to empty (zero extensions) +- Runs `setup:extensions`, `build`, and `test` +- Verifies no core code (`lib/`, `app/api/`, `components/`) imports directly from `@/extensions/` (only generated files and the loader are allowed) + +This ensures the core application always builds and passes tests independently of any extensions. + +--- + ## Deployment Hosted on **Vercel** with cron jobs defined in `vercel.json`: @@ -636,19 +951,21 @@ Hosted on **Vercel** with cron jobs defined in `vercel.json`: NEXT_PUBLIC_SUPABASE_URL # Supabase project URL NEXT_PUBLIC_SUPABASE_ANON_KEY # Supabase anonymous key SUPABASE_SERVICE_ROLE_KEY # Supabase service role key -RESEND_API_KEY # Resend email service API key -RESEND_FROM_EMAIL # Sender email for transactional mail -RESEND_WEBHOOK_SECRET # Webhook auth for Resend -ENABLE_BANKING_APP_ID # Enable Banking app ID -ENABLE_BANKING_PRIVATE_KEY # Enable Banking private key (base64-encoded) -ENABLE_BANKING_SANDBOX # Enable Banking sandbox mode flag -ANTHROPIC_API_KEY # Claude API key (ai-chat) -OPENAI_API_KEY # OpenAI API key (embeddings) NEXT_PUBLIC_APP_URL # App base URL CRON_SECRET # Auth secret for Vercel cron jobs -NEXT_PUBLIC_VAPID_PUBLIC_KEY # Web push public key -VAPID_PRIVATE_KEY # Web push private key -VAPID_SUBJECT # VAPID subject (mailto: URI) for web push + +# Extension-dependent (only needed when extension is enabled) +RESEND_API_KEY # Resend email service API key (email extension) +RESEND_FROM_EMAIL # Sender email (email extension) +RESEND_WEBHOOK_SECRET # Webhook auth for Resend (email extension) +ENABLE_BANKING_APP_ID # Enable Banking app ID (enable-banking extension) +ENABLE_BANKING_PRIVATE_KEY # Enable Banking private key, base64 (enable-banking extension) +ENABLE_BANKING_SANDBOX # Enable Banking sandbox mode (enable-banking extension) +ANTHROPIC_API_KEY # Claude API key (ai-chat, ai-categorization, receipt-ocr) +OPENAI_API_KEY # OpenAI API key for embeddings (ai-categorization) +NEXT_PUBLIC_VAPID_PUBLIC_KEY # Web push public key (push-notifications extension) +VAPID_PRIVATE_KEY # Web push private key (push-notifications extension) +VAPID_SUBJECT # VAPID subject mailto: URI (push-notifications extension) ``` ## Other diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 0228f0d9..4615aa25 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -25,6 +25,7 @@ import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types' import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem } from '@/types' import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions' +import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' export default function TransactionsPage() { const [transactions, setTransactions] = useState([]) @@ -625,7 +626,7 @@ export default function TransactionsPage() { onMarkPrivate={handleMarkPrivate} onOpenMatchDialog={openMatchDialog} onOpenCategoryDialog={openCategoryDialog} - onOpenDescribe={openDescribeDialog} + onOpenDescribe={ENABLED_EXTENSION_IDS.has('ai-categorization') ? openDescribeDialog : undefined} onOpenQuickReview={handleOpenQuickReview} onToggleSelect={toggleBatchSelect} /> diff --git a/app/api/admin/seed-template-embeddings/route.ts b/app/api/admin/seed-template-embeddings/route.ts index 31ba15ef..e39b174f 100644 --- a/app/api/admin/seed-template-embeddings/route.ts +++ b/app/api/admin/seed-template-embeddings/route.ts @@ -1,5 +1,8 @@ import { NextResponse } from 'next/server' -import { seedAllTemplateEmbeddings, getSchemaVersion } from '@/lib/bookkeeping/template-embeddings' +import { extensionRegistry } from '@/lib/extensions/registry' +import { ensureInitialized } from '@/lib/init' + +ensureInitialized() export async function POST(request: Request) { const authHeader = request.headers.get('authorization') @@ -9,14 +12,23 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const aiExt = extensionRegistry.get('ai-categorization') + if (!aiExt?.services?.seedAllTemplateEmbeddings || !aiExt?.services?.getSchemaVersion) { + return NextResponse.json( + { error: 'ai-categorization extension not loaded' }, + { status: 503 } + ) + } + try { - const { seeded, errors } = await seedAllTemplateEmbeddings() + const { seeded, errors } = await aiExt.services.seedAllTemplateEmbeddings() + const schemaVersion = await aiExt.services.getSchemaVersion() return NextResponse.json({ success: errors.length === 0, seeded, errors, - schema_version: getSchemaVersion(), + schema_version: schemaVersion, }) } catch (error) { return NextResponse.json( diff --git a/app/api/bookkeeping/fiscal-periods/[id]/close/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/close/route.ts index ec492e43..2b39890e 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/close/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/close/route.ts @@ -15,7 +15,7 @@ export async function POST( } try { - const period = await closePeriod(user.id, id) + const period = await closePeriod(supabase, user.id, id) return NextResponse.json({ data: period }) } catch (err) { return NextResponse.json( diff --git a/app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts index 7b26453f..80a0eb71 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts @@ -15,7 +15,7 @@ export async function POST( } try { - const period = await lockPeriod(user.id, id) + const period = await lockPeriod(supabase, user.id, id) return NextResponse.json({ data: period }) } catch (err) { return NextResponse.json( diff --git a/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts index d1a79443..0f6e47c5 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts @@ -23,8 +23,8 @@ export async function GET( try { const [validation, preview] = await Promise.all([ - validateYearEndReadiness(user.id, id), - previewYearEndClosing(user.id, id), + validateYearEndReadiness(supabase, user.id, id), + previewYearEndClosing(supabase, user.id, id), ]) return NextResponse.json({ data: { validation, preview } }) @@ -52,7 +52,7 @@ export async function POST( } try { - const result = await executeYearEndClosing(user.id, id) + const result = await executeYearEndClosing(supabase, user.id, id) return NextResponse.json({ data: result }) } catch (err) { return NextResponse.json( diff --git a/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts index 8bf9e0b8..09dff667 100644 --- a/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts @@ -100,7 +100,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => { expect(status).toBe(200) expect(body.data.reversal).toEqual(reversal) expect(body.data.corrected).toEqual(corrected) - expect(mockCorrectEntry).toHaveBeenCalledWith('user-1', 'entry-1', lines) + expect(mockCorrectEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', 'entry-1', lines) }) it('returns 400 when correctEntry throws for unbalanced lines', async () => { diff --git a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts index 79a86d67..2e58eb13 100644 --- a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts @@ -24,7 +24,7 @@ export async function POST( const body = validation.data try { - const result = await correctEntry(user.id, id, body.lines) + const result = await correctEntry(supabase, user.id, id, body.lines) return NextResponse.json({ data: result }) } catch (err) { return NextResponse.json( diff --git a/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts index c6d812b0..33961f2e 100644 --- a/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts @@ -60,7 +60,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/reverse', () => { expect(status).toBe(200) expect(body.data).toEqual(reversalEntry) - expect(mockReverseEntry).toHaveBeenCalledWith('user-1', 'entry-1') + expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', 'entry-1') }) it('returns 400 when engine throws', async () => { diff --git a/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts b/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts index c14da52c..a262ff0f 100644 --- a/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts @@ -15,7 +15,7 @@ export async function POST( } try { - const reversalEntry = await reverseEntry(user.id, id) + const reversalEntry = await reverseEntry(supabase, user.id, id) return NextResponse.json({ data: reversalEntry }) } catch (err) { return NextResponse.json( diff --git a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts index adb771db..1a0a34c7 100644 --- a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts @@ -137,7 +137,7 @@ describe('POST /api/bookkeeping/journal-entries', () => { expect(status).toBe(200) expect(body.data).toEqual(entry) - expect(mockCreateJournalEntry).toHaveBeenCalledWith('user-1', input) + expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', input) }) it('returns 400 when engine throws', async () => { diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index 6e274243..a693d111 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -69,7 +69,7 @@ export async function POST(request: Request) { const body = validation.data try { - const entry = await createJournalEntry(user.id, body) + const entry = await createJournalEntry(supabase, user.id, body) return NextResponse.json({ data: entry }) } catch (err) { return NextResponse.json( diff --git a/app/api/bookkeeping/mapping-rules/evaluate/route.ts b/app/api/bookkeeping/mapping-rules/evaluate/route.ts index d7533f1f..78f9bb65 100644 --- a/app/api/bookkeeping/mapping-rules/evaluate/route.ts +++ b/app/api/bookkeeping/mapping-rules/evaluate/route.ts @@ -38,7 +38,7 @@ export async function POST(request: Request) { } try { - const result = await evaluateMappingRules(user.id, transaction) + const result = await evaluateMappingRules(supabase, user.id, transaction) return NextResponse.json({ data: result }) } catch (err) { return NextResponse.json( diff --git a/app/api/documents/[id]/link/route.ts b/app/api/documents/[id]/link/route.ts index 81b86997..3bd1ab59 100644 --- a/app/api/documents/[id]/link/route.ts +++ b/app/api/documents/[id]/link/route.ts @@ -38,6 +38,7 @@ export async function POST( } const document = await linkToJournalEntry( + supabase, user.id, id, body.journal_entry_id, diff --git a/app/api/documents/[id]/verify/route.ts b/app/api/documents/[id]/verify/route.ts index 27bcf755..33d499e2 100644 --- a/app/api/documents/[id]/verify/route.ts +++ b/app/api/documents/[id]/verify/route.ts @@ -24,7 +24,7 @@ export async function POST( const { id } = await params try { - const result = await verifyIntegrity(user.id, id) + const result = await verifyIntegrity(supabase, user.id, id) return NextResponse.json({ data: result }) } catch (error) { diff --git a/app/api/documents/[id]/versions/route.ts b/app/api/documents/[id]/versions/route.ts index d5672711..5ec6b8e5 100644 --- a/app/api/documents/[id]/versions/route.ts +++ b/app/api/documents/[id]/versions/route.ts @@ -36,7 +36,7 @@ export async function POST( const buffer = await file.arrayBuffer() - const newVersion = await createNewVersion(user.id, id, { + const newVersion = await createNewVersion(supabase, user.id, id, { name: file.name, buffer, type: file.type, diff --git a/app/api/documents/route.ts b/app/api/documents/route.ts index ce27bb9c..7ed9e751 100644 --- a/app/api/documents/route.ts +++ b/app/api/documents/route.ts @@ -38,7 +38,7 @@ export async function POST(request: Request) { const buffer = await file.arrayBuffer() - const document = await uploadDocument(user.id, { + const document = await uploadDocument(supabase, user.id, { name: file.name, buffer, type: file.type, diff --git a/app/api/extensions/ai-categorization/settings/route.ts b/app/api/extensions/ai-categorization/settings/route.ts deleted file mode 100644 index 38ec4e63..00000000 --- a/app/api/extensions/ai-categorization/settings/route.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { getSettings, saveSettings } from '@/extensions/general/ai-categorization' -import { ensureInitialized } from '@/lib/init' - -ensureInitialized() - -/** - * GET /api/extensions/ai-categorization/settings - * Get the current user's ai-categorization extension settings - */ -export async function GET() { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const settings = await getSettings(user.id) - return NextResponse.json({ data: settings }) -} - -/** - * PATCH /api/extensions/ai-categorization/settings - * Update the current user's ai-categorization extension settings - */ -export async function PATCH(request: Request) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - - // Validate setting keys - const allowedKeys = [ - 'autoSuggestEnabled', - 'confidenceThreshold', - 'providerModel', - ] - const filtered: Record = {} - for (const key of allowedKeys) { - if (key in body) { - filtered[key] = body[key] - } - } - - if (Object.keys(filtered).length === 0) { - return NextResponse.json({ error: 'No valid settings provided' }, { status: 400 }) - } - - const settings = await saveSettings(user.id, filtered) - return NextResponse.json({ data: settings }) -} diff --git a/app/api/extensions/ai-categorization/suggestions/route.ts b/app/api/extensions/ai-categorization/suggestions/route.ts deleted file mode 100644 index 1b7a624f..00000000 --- a/app/api/extensions/ai-categorization/suggestions/route.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { categorizeTransactions } from '@/extensions/general/ai-categorization' -import { ensureInitialized } from '@/lib/init' -import type { CategorizationSuggestion } from '@/extensions/general/ai-categorization/categorizer' - -ensureInitialized() - -/** - * GET /api/extensions/ai-categorization/suggestions?transaction_ids=id1,id2,... - * Fetch pre-computed AI suggestions for given transaction IDs - */ -export async function GET(request: Request) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const idsParam = searchParams.get('transaction_ids') - - if (!idsParam) { - return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 }) - } - - const transactionIds = idsParam.split(',').filter(Boolean).slice(0, 50) - - // Read stored suggestions from extension_data - const keys = transactionIds.map((id) => `suggestion:${id}`) - - const { data: records } = await supabase - .from('extension_data') - .select('key, value') - .eq('user_id', user.id) - .eq('extension_id', 'ai-categorization') - .in('key', keys) - - const suggestions: Record = {} - if (records) { - for (const record of records) { - const txId = record.key.replace('suggestion:', '') - suggestions[txId] = record.value as unknown as CategorizationSuggestion - } - } - - return NextResponse.json({ suggestions }) -} - -/** - * POST /api/extensions/ai-categorization/suggestions - * Trigger on-demand AI categorization for given transaction IDs - */ -export async function POST(request: Request) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - const { transaction_ids } = body - - if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) { - return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 }) - } - - const ids = transaction_ids.slice(0, 50) - - try { - const suggestions = await categorizeTransactions(user.id, ids) - - // Group by transaction ID - const grouped: Record = {} - for (const s of suggestions) { - grouped[s.transactionId] = s - } - - return NextResponse.json({ suggestions: grouped }) - } catch (error) { - console.error('[ai-categorization] On-demand categorization failed:', error) - return NextResponse.json( - { error: 'AI categorization failed' }, - { status: 500 } - ) - } -} diff --git a/app/api/extensions/ai-chat/route.ts b/app/api/extensions/ai-chat/route.ts deleted file mode 100644 index 67f26416..00000000 --- a/app/api/extensions/ai-chat/route.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { generateChatResponse } from '@/extensions/general/ai-chat/chatbot/chain' -import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config' -import type { ChatMessage, ChatRequest } from '@/types/chat' - -// Simple in-memory rate limiting (per user) -const rateLimitMap = new Map() - -function checkRateLimit(userId: string): boolean { - const now = Date.now() - const limit = rateLimitMap.get(userId) - - if (!limit || now > limit.resetTime) { - rateLimitMap.set(userId, { - count: 1, - resetTime: now + 60000, // 1 minute window - }) - return true - } - - if (limit.count >= CHATBOT_CONFIG.rateLimitPerMinute) { - return false - } - - limit.count++ - return true -} - -/** - * POST /api/chat - * Send a message and get a response - */ -export async function POST(request: Request) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Rate limiting - if (!checkRateLimit(user.id)) { - return NextResponse.json( - { error: 'Rate limit exceeded. Please wait a moment.' }, - { status: 429 } - ) - } - - try { - const body: ChatRequest = await request.json() - const { message, session_id } = body - - if (!message || typeof message !== 'string' || message.trim().length === 0) { - return NextResponse.json( - { error: 'Message is required' }, - { status: 400 } - ) - } - - let sessionId = session_id - - // Create new session if not provided - if (!sessionId) { - const { data: newSession, error: sessionError } = await supabase - .from('chat_sessions') - .insert({ - user_id: user.id, - title: message.slice(0, 100), // Use first 100 chars of message as title - }) - .select() - .single() - - if (sessionError) { - console.error('Error creating session:', sessionError) - return NextResponse.json( - { error: 'Failed to create chat session' }, - { status: 500 } - ) - } - - sessionId = newSession.id - } else { - // Verify session belongs to user - const { data: existingSession } = await supabase - .from('chat_sessions') - .select('id') - .eq('id', sessionId) - .eq('user_id', user.id) - .single() - - if (!existingSession) { - return NextResponse.json( - { error: 'Session not found' }, - { status: 404 } - ) - } - } - - // Save user message - const { data: userMessage, error: userMsgError } = await supabase - .from('chat_messages') - .insert({ - session_id: sessionId, - user_id: user.id, - role: 'user', - content: message.trim(), - sources: [], - }) - .select() - .single() - - if (userMsgError) { - console.error('Error saving user message:', userMsgError) - return NextResponse.json( - { error: 'Failed to save message' }, - { status: 500 } - ) - } - - // Get conversation history - const { data: history } = await supabase - .from('chat_messages') - .select('role, content') - .eq('session_id', sessionId) - .order('created_at', { ascending: true }) - .limit(CHATBOT_CONFIG.maxHistoryMessages) - - const conversationHistory = (history || []) as ChatMessage[] - - // Generate AI response - const result = await generateChatResponse(message.trim(), conversationHistory) - - // Save assistant message - const { data: assistantMessage, error: assistantMsgError } = await supabase - .from('chat_messages') - .insert({ - session_id: sessionId, - user_id: user.id, - role: 'assistant', - content: result.content, - sources: result.sources, - }) - .select() - .single() - - if (assistantMsgError) { - console.error('Error saving assistant message:', assistantMsgError) - return NextResponse.json( - { error: 'Failed to save response' }, - { status: 500 } - ) - } - - return NextResponse.json({ - message: assistantMessage, - session_id: sessionId, - }) - } catch (err) { - console.error('Chat error:', err) - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to process chat' }, - { status: 500 } - ) - } -} diff --git a/app/api/extensions/ai-chat/sessions/[id]/route.ts b/app/api/extensions/ai-chat/sessions/[id]/route.ts deleted file mode 100644 index 1318625f..00000000 --- a/app/api/extensions/ai-chat/sessions/[id]/route.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' - -/** - * GET /api/chat/sessions/[id] - * Get a single chat session with its messages - */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - // Get session with messages - const { data: session, error: sessionError } = await supabase - .from('chat_sessions') - .select('*') - .eq('id', id) - .eq('user_id', user.id) - .single() - - if (sessionError || !session) { - return NextResponse.json({ error: 'Session not found' }, { status: 404 }) - } - - // Get messages - const { data: messages, error: messagesError } = await supabase - .from('chat_messages') - .select('*') - .eq('session_id', id) - .order('created_at', { ascending: true }) - - if (messagesError) { - console.error('Error fetching messages:', messagesError) - return NextResponse.json( - { error: 'Failed to fetch messages' }, - { status: 500 } - ) - } - - return NextResponse.json({ - session, - messages: messages || [], - }) -} - -/** - * PATCH /api/chat/sessions/[id] - * Update a chat session (e.g., rename) - */ -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - try { - const body = await request.json() - const { title } = body - - const { data, error } = await supabase - .from('chat_sessions') - .update({ title }) - .eq('id', id) - .eq('user_id', user.id) - .select() - .single() - - if (error) { - console.error('Error updating session:', error) - return NextResponse.json( - { error: 'Failed to update session' }, - { status: 500 } - ) - } - - if (!data) { - return NextResponse.json({ error: 'Session not found' }, { status: 404 }) - } - - return NextResponse.json({ data }) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to update session' }, - { status: 400 } - ) - } -} - -/** - * DELETE /api/chat/sessions/[id] - * Delete a chat session and its messages - */ -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - // Delete session (messages will cascade delete due to FK) - const { error } = await supabase - .from('chat_sessions') - .delete() - .eq('id', id) - .eq('user_id', user.id) - - if (error) { - console.error('Error deleting session:', error) - return NextResponse.json( - { error: 'Failed to delete session' }, - { status: 500 } - ) - } - - return NextResponse.json({ success: true }) -} diff --git a/app/api/extensions/ai-chat/sessions/route.ts b/app/api/extensions/ai-chat/sessions/route.ts deleted file mode 100644 index 64d30954..00000000 --- a/app/api/extensions/ai-chat/sessions/route.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' - -/** - * GET /api/chat/sessions - * List all chat sessions for the authenticated user - */ -export async function GET(request: Request) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const limit = parseInt(searchParams.get('limit') || '20') - const offset = parseInt(searchParams.get('offset') || '0') - - const { data, error, count } = await supabase - .from('chat_sessions') - .select('*', { count: 'exact' }) - .eq('user_id', user.id) - .order('created_at', { ascending: false }) - .range(offset, offset + limit - 1) - - if (error) { - console.error('Error fetching sessions:', error) - return NextResponse.json( - { error: 'Failed to fetch sessions' }, - { status: 500 } - ) - } - - return NextResponse.json({ data, count }) -} - -/** - * POST /api/chat/sessions - * Create a new chat session - */ -export async function POST(request: Request) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const body = await request.json() - const { title } = body - - const { data, error } = await supabase - .from('chat_sessions') - .insert({ - user_id: user.id, - title: title || null, - }) - .select() - .single() - - if (error) { - console.error('Error creating session:', error) - return NextResponse.json( - { error: 'Failed to create session' }, - { status: 500 } - ) - } - - return NextResponse.json({ data }) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to create session' }, - { status: 400 } - ) - } -} diff --git a/app/api/extensions/ai-chat/stream/route.ts b/app/api/extensions/ai-chat/stream/route.ts deleted file mode 100644 index bcf31bb3..00000000 --- a/app/api/extensions/ai-chat/stream/route.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { streamChatResponse } from '@/extensions/general/ai-chat/chatbot/chain' -import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config' -import type { ChatMessage, ChatRequest, SourceReference } from '@/types/chat' - -// Simple in-memory rate limiting (per user) -const rateLimitMap = new Map() - -function checkRateLimit(userId: string): boolean { - const now = Date.now() - const limit = rateLimitMap.get(userId) - - if (!limit || now > limit.resetTime) { - rateLimitMap.set(userId, { - count: 1, - resetTime: now + 60000, - }) - return true - } - - if (limit.count >= CHATBOT_CONFIG.rateLimitPerMinute) { - return false - } - - limit.count++ - return true -} - -/** - * POST /api/chat/stream - * Streaming chat response via Server-Sent Events - */ -export async function POST(request: Request) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return new Response( - JSON.stringify({ error: 'Unauthorized' }), - { status: 401, headers: { 'Content-Type': 'application/json' } } - ) - } - - if (!checkRateLimit(user.id)) { - return new Response( - JSON.stringify({ error: 'Rate limit exceeded' }), - { status: 429, headers: { 'Content-Type': 'application/json' } } - ) - } - - try { - const body: ChatRequest = await request.json() - const { message, session_id } = body - - if (!message || typeof message !== 'string' || message.trim().length === 0) { - return new Response( - JSON.stringify({ error: 'Message is required' }), - { status: 400, headers: { 'Content-Type': 'application/json' } } - ) - } - - let sessionId = session_id - - // Create new session if not provided - if (!sessionId) { - const { data: newSession, error: sessionError } = await supabase - .from('chat_sessions') - .insert({ - user_id: user.id, - title: message.slice(0, 100), - }) - .select() - .single() - - if (sessionError) { - return new Response( - JSON.stringify({ error: 'Failed to create session' }), - { status: 500, headers: { 'Content-Type': 'application/json' } } - ) - } - - sessionId = newSession.id - } else { - // Verify session belongs to user - const { data: existingSession } = await supabase - .from('chat_sessions') - .select('id') - .eq('id', sessionId) - .eq('user_id', user.id) - .single() - - if (!existingSession) { - return new Response( - JSON.stringify({ error: 'Session not found' }), - { status: 404, headers: { 'Content-Type': 'application/json' } } - ) - } - } - - // Save user message - await supabase - .from('chat_messages') - .insert({ - session_id: sessionId, - user_id: user.id, - role: 'user', - content: message.trim(), - sources: [], - }) - - // Get conversation history - const { data: history } = await supabase - .from('chat_messages') - .select('role, content') - .eq('session_id', sessionId) - .order('created_at', { ascending: true }) - .limit(CHATBOT_CONFIG.maxHistoryMessages) - - const conversationHistory = (history || []) as ChatMessage[] - - // Create streaming response - const encoder = new TextEncoder() - let fullContent = '' - let sources: SourceReference[] = [] - - const stream = new ReadableStream({ - async start(controller) { - try { - // Send session ID first - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: 'session', session_id: sessionId })}\n\n`) - ) - - // Stream the response - for await (const chunk of streamChatResponse(message.trim(), conversationHistory)) { - if (chunk.type === 'content') { - fullContent += chunk.data as string - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: 'content', content: chunk.data })}\n\n`) - ) - } else if (chunk.type === 'sources') { - sources = chunk.data as SourceReference[] - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: 'sources', sources: chunk.data })}\n\n`) - ) - } - } - - // Save the complete assistant message - const { data: savedMessage } = await supabase - .from('chat_messages') - .insert({ - session_id: sessionId, - user_id: user.id, - role: 'assistant', - content: fullContent, - sources, - }) - .select() - .single() - - // Send done signal with message ID - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: 'done', message_id: savedMessage?.id })}\n\n`) - ) - - controller.close() - } catch (error) { - console.error('Streaming error:', error) - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: 'error', error: 'Streaming failed' })}\n\n`) - ) - controller.close() - } - }, - }) - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }, - }) - } catch (err) { - console.error('Stream setup error:', err) - return new Response( - JSON.stringify({ error: 'Failed to setup stream' }), - { status: 500, headers: { 'Content-Type': 'application/json' } } - ) - } -} diff --git a/app/api/extensions/export/currency-receivables/report/route.ts b/app/api/extensions/export/currency-receivables/report/route.ts deleted file mode 100644 index fa77eed9..00000000 --- a/app/api/extensions/export/currency-receivables/report/route.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { fetchAllRows } from '@/lib/supabase/fetch-all' -import { - generateReceivablesReport, - type ReceivableInvoice, - type ReceivableCustomer, - type GLLine, - type ExchangeRateInfo, -} from '@/extensions/export/currency-receivables/lib/receivables-engine' -import { fetchMultipleRates } from '@/lib/currency/riksbanken' -import type { Currency } from '@/types' - -const SUPPORTED_CURRENCIES: Currency[] = ['EUR', 'USD', 'GBP', 'NOK', 'DKK'] -const FX_ACCOUNTS = ['3960', '7960'] - -/** - * GET /api/extensions/export/currency-receivables/report - * - * Generate a multi-currency receivables report showing FX exposure, - * unrealized gains/losses, and realized FX from GL. - * - * Query params: - * year (optional) — Year for realized FX trend (default: current year) - */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const yearStr = searchParams.get('year') - const year = yearStr ? parseInt(yearStr, 10) : new Date().getFullYear() - - if (isNaN(year) || year < 2000 || year > 2100) { - return NextResponse.json({ error: 'Invalid year' }, { status: 400 }) - } - - const referenceDate = new Date().toISOString().split('T')[0] - - try { - // Fetch open foreign-currency invoices - const invoices = await fetchAllRows(({ from, to }) => - supabase - .from('invoices') - .select('id, invoice_number, invoice_date, due_date, status, currency, total, total_sek, exchange_rate, customer_id') - .eq('user_id', user.id) - .in('status', ['sent', 'overdue']) - .neq('currency', 'SEK') - .range(from, to) - ) - - // Fetch customers - const customerIds = [...new Set(invoices.map(inv => inv.customer_id))] - let customers: ReceivableCustomer[] = [] - if (customerIds.length > 0) { - const { data: customerData } = await supabase - .from('customers') - .select('id, name, country') - .eq('user_id', user.id) - .in('id', customerIds) - - customers = (customerData || []) as ReceivableCustomer[] - } - - // Fetch current Riksbanken rates for all supported currencies - const rateMap = await fetchMultipleRates(SUPPORTED_CURRENCIES) - const currentRates: ExchangeRateInfo[] = [] - for (const [, rate] of rateMap) { - if (rate.currency !== 'SEK') { - currentRates.push({ - currency: rate.currency, - rate: rate.rate, - date: rate.date, - }) - } - } - - // Fetch realized FX GL lines for the year - const realizedFXLines = await fetchFXLines(supabase, user.id, year) - - const report = generateReceivablesReport({ - invoices, - customers, - currentRates, - realizedFXLines, - referenceDate, - year, - }) - - return NextResponse.json({ data: report }) - } catch (err) { - console.error('Error generating currency receivables report:', err) - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to generate report' }, - { status: 500 }, - ) - } -} - -/** - * Fetch GL lines on accounts 3960 (FX gains) and 7960 (FX losses) - * for posted journal entries in the given year. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function fetchFXLines(supabase: any, userId: string, year: number): Promise { - const startDate = `${year}-01-01` - const endDate = `${year}-12-31` - - // Get posted journal entry IDs in the year - const { data: entries, error: entriesError } = await supabase - .from('journal_entries') - .select('id, entry_date') - .eq('user_id', userId) - .eq('status', 'posted') - .gte('entry_date', startDate) - .lte('entry_date', endDate) - - if (entriesError || !entries || entries.length === 0) return [] - - const entryDateMap = new Map() - for (const e of entries as Array<{ id: string; entry_date: string }>) { - entryDateMap.set(e.id, e.entry_date) - } - - const entryIds = entries.map((e: { id: string }) => e.id) - - // Fetch lines in batches - const BATCH_SIZE = 200 - const allLines: GLLine[] = [] - - for (let i = 0; i < entryIds.length; i += BATCH_SIZE) { - const batch = entryIds.slice(i, i + BATCH_SIZE) - const { data: lines } = await supabase - .from('journal_entry_lines') - .select('journal_entry_id, account_number, debit_amount, credit_amount') - .in('journal_entry_id', batch) - .in('account_number', FX_ACCOUNTS) - - if (lines) { - for (const line of lines as Array<{ journal_entry_id: string; account_number: string; debit_amount: number; credit_amount: number }>) { - allLines.push({ - account_number: line.account_number, - debit: Number(line.debit_amount) || 0, - credit: Number(line.credit_amount) || 0, - entry_date: entryDateMap.get(line.journal_entry_id) || startDate, - }) - } - } - } - - return allLines -} diff --git a/app/api/extensions/export/eu-sales-list/download/route.ts b/app/api/extensions/export/eu-sales-list/download/route.ts deleted file mode 100644 index be11ba4d..00000000 --- a/app/api/extensions/export/eu-sales-list/download/route.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { fetchAllRows } from '@/lib/supabase/fetch-all' -import { - generateECSalesListReport, - getMonthPeriod, - getQuarterPeriod, - type ECSalesListInvoice, - type ECSalesListCustomer, - type GLAccountTotal, -} from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine' -import { generateCSV, generateCSVFilename } from '@/extensions/export/eu-sales-list/lib/csv-generator' -import { generateSKVXml, generateXMLFilename } from '@/extensions/export/eu-sales-list/lib/skv-xml-generator' - -/** - * GET /api/extensions/export/eu-sales-list/download - * - * Download an EC Sales List (periodisk sammanställning) as CSV or XML. - * - * Query params: - * year (required) — Fiscal year - * month (optional) — 1-12, for monthly filing - * quarter (optional) — 1-4, for quarterly filing - * format (required) — 'csv' or 'xml' - */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Parse query parameters - const { searchParams } = new URL(request.url) - const yearStr = searchParams.get('year') - const monthStr = searchParams.get('month') - const quarterStr = searchParams.get('quarter') - const format = searchParams.get('format') - - if (!yearStr) { - return NextResponse.json({ error: 'year is required' }, { status: 400 }) - } - - if (!format || !['csv', 'xml'].includes(format)) { - return NextResponse.json({ error: 'format is required and must be csv or xml' }, { status: 400 }) - } - - const year = parseInt(yearStr, 10) - if (isNaN(year) || year < 2000 || year > 2100) { - return NextResponse.json({ error: 'Invalid year' }, { status: 400 }) - } - - if (!monthStr && !quarterStr) { - return NextResponse.json({ error: 'Either month or quarter is required' }, { status: 400 }) - } - - if (monthStr && quarterStr) { - return NextResponse.json({ error: 'Provide either month or quarter, not both' }, { status: 400 }) - } - - let month: number | undefined - let quarter: number | undefined - - if (monthStr) { - month = parseInt(monthStr, 10) - if (isNaN(month) || month < 1 || month > 12) { - return NextResponse.json({ error: 'Invalid month' }, { status: 400 }) - } - } - - if (quarterStr) { - quarter = parseInt(quarterStr, 10) - if (isNaN(quarter) || quarter < 1 || quarter > 4) { - return NextResponse.json({ error: 'Invalid quarter' }, { status: 400 }) - } - } - - const period = month !== undefined - ? getMonthPeriod(year, month) - : getQuarterPeriod(year, quarter!) - - try { - // Fetch company settings - const { data: company, error: companyError } = await supabase - .from('company_settings') - .select('company_name, org_number, vat_number') - .eq('user_id', user.id) - .single() - - if (companyError || !company) { - return NextResponse.json({ error: 'Company settings not found' }, { status: 404 }) - } - - const reporterVatNumber = company.vat_number || `SE${(company.org_number || '').replace(/\D/g, '')}01` - - // Fetch invoices - const invoices = await fetchAllRows(({ from, to }) => - supabase - .from('invoices') - .select('id, invoice_number, invoice_date, status, currency, total, total_sek, subtotal, subtotal_sek, vat_treatment, moms_ruta, document_type, credited_invoice_id, customer_id') - .eq('user_id', user.id) - .gte('invoice_date', period.start) - .lte('invoice_date', period.end) - .in('status', ['sent', 'paid', 'overdue']) - .eq('vat_treatment', 'reverse_charge') - .range(from, to) - ) - - // Fetch customers - const customerIds = [...new Set(invoices.map(inv => inv.customer_id))] - let customers: ECSalesListCustomer[] = [] - if (customerIds.length > 0) { - const { data: customerData } = await supabase - .from('customers') - .select('id, name, country, customer_type, vat_number, vat_number_validated') - .eq('user_id', user.id) - .in('id', customerIds) - - customers = (customerData || []) as ECSalesListCustomer[] - } - - // Generate report - const report = generateECSalesListReport({ - invoices, - customers, - reporterVatNumber, - reporterName: company.company_name || '', - year, - month, - quarter, - }) - - // Generate file content - if (format === 'csv') { - const content = generateCSV(report) - const filename = generateCSVFilename(report) - - return new NextResponse(content, { - status: 200, - headers: { - 'Content-Type': 'text/csv; charset=utf-8', - 'Content-Disposition': `attachment; filename="${filename}"`, - }, - }) - } - - // XML format - const content = generateSKVXml(report) - const filename = generateXMLFilename(report) - - return new NextResponse(content, { - status: 200, - headers: { - 'Content-Type': 'application/xml; charset=utf-8', - 'Content-Disposition': `attachment; filename="${filename}"`, - }, - }) - } catch (err) { - console.error('Error generating EC Sales List download:', err) - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to generate download' }, - { status: 500 }, - ) - } -} diff --git a/app/api/extensions/export/eu-sales-list/report/route.ts b/app/api/extensions/export/eu-sales-list/report/route.ts deleted file mode 100644 index 09301eb5..00000000 --- a/app/api/extensions/export/eu-sales-list/report/route.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { fetchAllRows } from '@/lib/supabase/fetch-all' -import { - generateECSalesListReport, - getMonthPeriod, - getQuarterPeriod, - getFilingDeadline, - daysUntilDeadline, - type ECSalesListInvoice, - type ECSalesListCustomer, - type GLAccountTotal, -} from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine' - -/** - * GET /api/extensions/export/eu-sales-list/report - * - * Generate an EC Sales List (periodisk sammanställning) report. - * - * Query params: - * year (required) — Fiscal year, e.g. 2026 - * month (optional) — 1-12, for monthly filing (goods) - * quarter (optional) — 1-4, for quarterly filing (services) - * - * Either month or quarter must be provided, not both. - */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Parse query parameters - const { searchParams } = new URL(request.url) - const yearStr = searchParams.get('year') - const monthStr = searchParams.get('month') - const quarterStr = searchParams.get('quarter') - - if (!yearStr) { - return NextResponse.json({ error: 'year is required' }, { status: 400 }) - } - - const year = parseInt(yearStr, 10) - if (isNaN(year) || year < 2000 || year > 2100) { - return NextResponse.json({ error: 'Invalid year. Must be between 2000 and 2100' }, { status: 400 }) - } - - if (!monthStr && !quarterStr) { - return NextResponse.json({ error: 'Either month or quarter is required' }, { status: 400 }) - } - - if (monthStr && quarterStr) { - return NextResponse.json({ error: 'Provide either month or quarter, not both' }, { status: 400 }) - } - - let month: number | undefined - let quarter: number | undefined - - if (monthStr) { - month = parseInt(monthStr, 10) - if (isNaN(month) || month < 1 || month > 12) { - return NextResponse.json({ error: 'Invalid month. Must be 1-12' }, { status: 400 }) - } - } - - if (quarterStr) { - quarter = parseInt(quarterStr, 10) - if (isNaN(quarter) || quarter < 1 || quarter > 4) { - return NextResponse.json({ error: 'Invalid quarter. Must be 1-4' }, { status: 400 }) - } - } - - // Determine date range - const period = month !== undefined - ? getMonthPeriod(year, month) - : getQuarterPeriod(year, quarter!) - - try { - // Fetch company settings for reporter info - const { data: company, error: companyError } = await supabase - .from('company_settings') - .select('company_name, org_number, vat_number') - .eq('user_id', user.id) - .single() - - if (companyError || !company) { - return NextResponse.json({ error: 'Company settings not found' }, { status: 404 }) - } - - const reporterVatNumber = company.vat_number || `SE${(company.org_number || '').replace(/\D/g, '')}01` - - // Fetch invoices for the period - const invoices = await fetchAllRows(({ from, to }) => - supabase - .from('invoices') - .select('id, invoice_number, invoice_date, status, currency, total, total_sek, subtotal, subtotal_sek, vat_treatment, moms_ruta, document_type, credited_invoice_id, customer_id') - .eq('user_id', user.id) - .gte('invoice_date', period.start) - .lte('invoice_date', period.end) - .in('status', ['sent', 'paid', 'overdue']) - .eq('vat_treatment', 'reverse_charge') - .range(from, to) - ) - - // Collect unique customer IDs - const customerIds = [...new Set(invoices.map(inv => inv.customer_id))] - - // Fetch customers (only if we have invoices) - let customers: ECSalesListCustomer[] = [] - if (customerIds.length > 0) { - const { data: customerData, error: customerError } = await supabase - .from('customers') - .select('id, name, country, customer_type, vat_number, vat_number_validated') - .eq('user_id', user.id) - .in('id', customerIds) - - if (customerError) { - return NextResponse.json({ error: 'Failed to fetch customers' }, { status: 500 }) - } - - customers = (customerData || []) as ECSalesListCustomer[] - } - - // Fetch GL account totals for cross-check - // Query posted journal entries in the period, then sum credit amounts - // on the relevant revenue accounts (3108, 3308, 3109, 3521) - const glTotals = await fetchGLTotals(supabase, user.id, period.start, period.end) - - // Generate report - const report = generateECSalesListReport({ - invoices, - customers, - glTotals: glTotals.length > 0 ? glTotals : undefined, - reporterVatNumber, - reporterName: company.company_name || '', - year, - month, - quarter, - }) - - // Add deadline info - const deadline = getFilingDeadline(year, month, quarter) - const daysLeft = daysUntilDeadline(deadline) - - return NextResponse.json({ - data: { - ...report, - deadline, - daysUntilDeadline: daysLeft, - }, - }) - } catch (err) { - console.error('Error generating EC Sales List report:', err) - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to generate report' }, - { status: 500 }, - ) - } -} - -// ── GL cross-check helper ─────────────────────────────────── - -const CROSS_CHECK_ACCOUNTS = ['3108', '3109', '3308', '3521'] - -/** - * Fetch credit totals for cross-check accounts from posted journal entries. - * Mirrors the approach used by /api/bookkeeping/account-totals. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function fetchGLTotals(supabase: any, userId: string, startDate: string, endDate: string): Promise { - // Get posted journal entry IDs in the period - const { data: entries, error: entriesError } = await supabase - .from('journal_entries') - .select('id') - .eq('user_id', userId) - .eq('status', 'posted') - .gte('entry_date', startDate) - .lte('entry_date', endDate) - - if (entriesError || !entries || entries.length === 0) return [] - - const entryIds = entries.map((e: { id: string }) => e.id) - - // Fetch lines in batches (same pattern as account-totals route) - const BATCH_SIZE = 200 - const allLines: Array<{ account_number: string; credit_amount: number }> = [] - - for (let i = 0; i < entryIds.length; i += BATCH_SIZE) { - const batch = entryIds.slice(i, i + BATCH_SIZE) - const { data: lines } = await supabase - .from('journal_entry_lines') - .select('account_number, credit_amount') - .in('journal_entry_id', batch) - .in('account_number', CROSS_CHECK_ACCOUNTS) - - if (lines) allLines.push(...lines) - } - - // Aggregate credits per account - const totals = new Map() - for (const line of allLines) { - const credit = Number(line.credit_amount) || 0 - totals.set(line.account_number, (totals.get(line.account_number) ?? 0) + credit) - } - - return Array.from(totals.entries()).map(([account_number, credit]) => ({ - account_number, - credit: Math.round(credit * 100) / 100, - })) -} diff --git a/app/api/extensions/export/intrastat/download/route.ts b/app/api/extensions/export/intrastat/download/route.ts deleted file mode 100644 index 06173319..00000000 --- a/app/api/extensions/export/intrastat/download/route.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { fetchAllRows } from '@/lib/supabase/fetch-all' -import { - generateIntrastatReport, - type IntrastatInvoice, - type IntrastatCustomer, - type IntrastatInvoiceItem, - type ProductMetadata, -} from '@/extensions/export/intrastat/lib/intrastat-engine' -import { generateSCBCsv, generateSCBFilename } from '@/extensions/export/intrastat/lib/scb-csv-generator' -import { getMonthPeriod } from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine' - -/** - * GET /api/extensions/export/intrastat/download - * - * Download an Intrastat declaration as SCB-compatible CSV. - * - * Query params: - * year (required) — Fiscal year - * month (required) — 1-12 - */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const yearStr = searchParams.get('year') - const monthStr = searchParams.get('month') - - if (!yearStr || !monthStr) { - return NextResponse.json({ error: 'year and month are required' }, { status: 400 }) - } - - const year = parseInt(yearStr, 10) - const month = parseInt(monthStr, 10) - - if (isNaN(year) || year < 2000 || year > 2100) { - return NextResponse.json({ error: 'Invalid year' }, { status: 400 }) - } - if (isNaN(month) || month < 1 || month > 12) { - return NextResponse.json({ error: 'Invalid month' }, { status: 400 }) - } - - const period = getMonthPeriod(year, month) - - try { - const { data: company } = await supabase - .from('company_settings') - .select('company_name, org_number, vat_number') - .eq('user_id', user.id) - .single() - - if (!company) { - return NextResponse.json({ error: 'Company settings not found' }, { status: 404 }) - } - - const reporterVatNumber = company.vat_number || `SE${(company.org_number || '').replace(/\D/g, '')}01` - - // Fetch invoices - const invoices = await fetchAllRows(({ from, to }) => - supabase - .from('invoices') - .select('id, invoice_number, invoice_date, status, vat_treatment, moms_ruta, currency, total_sek, subtotal_sek, subtotal, document_type, credited_invoice_id, customer_id') - .eq('user_id', user.id) - .gte('invoice_date', period.start) - .lte('invoice_date', period.end) - .in('status', ['sent', 'paid', 'overdue']) - .eq('vat_treatment', 'reverse_charge') - .range(from, to) - ) - - // Fetch invoice items - const invoiceIds = invoices.map(inv => inv.id) - let invoiceItems: IntrastatInvoiceItem[] = [] - if (invoiceIds.length > 0) { - const BATCH_SIZE = 200 - for (let i = 0; i < invoiceIds.length; i += BATCH_SIZE) { - const batch = invoiceIds.slice(i, i + BATCH_SIZE) - const { data: items } = await supabase - .from('invoice_items') - .select('id, invoice_id, description, quantity, unit_price, total, total_sek') - .in('invoice_id', batch) - - if (items) invoiceItems.push(...(items as IntrastatInvoiceItem[])) - } - } - - // Fetch customers - const customerIds = [...new Set(invoices.map(inv => inv.customer_id))] - let customers: IntrastatCustomer[] = [] - if (customerIds.length > 0) { - const { data: customerData } = await supabase - .from('customers') - .select('id, name, country, vat_number') - .eq('user_id', user.id) - .in('id', customerIds) - - customers = (customerData || []) as IntrastatCustomer[] - } - - // Fetch product metadata - const { data: productData } = await supabase - .from('extension_data') - .select('key, value') - .eq('user_id', user.id) - .eq('extension_id', 'export/intrastat') - .ilike('key', 'product:%') - - const products: ProductMetadata[] = (productData || []).map((d: { key: string; value: Record }) => ({ - productId: d.key.replace('product:', ''), - cnCode: (d.value.cn_code as string) || null, - description: (d.value.description as string) || '', - netWeightKg: d.value.net_weight_kg !== undefined ? Number(d.value.net_weight_kg) : null, - countryOfOrigin: (d.value.country_of_origin as string) || 'SE', - supplementaryUnit: d.value.supplementary_unit !== undefined ? Number(d.value.supplementary_unit) : null, - supplementaryUnitType: (d.value.supplementary_unit_type as string) || null, - })) - - // Fetch settings - const { data: settingsData } = await supabase - .from('extension_data') - .select('value') - .eq('user_id', user.id) - .eq('extension_id', 'export/intrastat') - .eq('key', 'settings') - .maybeSingle() - - const settings = settingsData?.value as Record | undefined - - const report = generateIntrastatReport({ - invoices, - invoiceItems, - customers, - products, - reporterVatNumber, - reporterName: company.company_name || '', - year, - month, - defaultTransactionNature: (settings?.default_transaction_nature as string) || '11', - defaultDeliveryTerms: (settings?.default_delivery_terms as string) || 'FCA', - }) - - const content = generateSCBCsv(report) - const filename = generateSCBFilename(report) - - return new NextResponse(content, { - status: 200, - headers: { - 'Content-Type': 'text/csv; charset=utf-8', - 'Content-Disposition': `attachment; filename="${filename}"`, - }, - }) - } catch (err) { - console.error('Error generating Intrastat download:', err) - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to generate download' }, - { status: 500 }, - ) - } -} diff --git a/app/api/extensions/export/intrastat/report/route.ts b/app/api/extensions/export/intrastat/report/route.ts deleted file mode 100644 index 46393b25..00000000 --- a/app/api/extensions/export/intrastat/report/route.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { fetchAllRows } from '@/lib/supabase/fetch-all' -import { - generateIntrastatReport, - type IntrastatInvoice, - type IntrastatCustomer, - type IntrastatInvoiceItem, - type ProductMetadata, -} from '@/extensions/export/intrastat/lib/intrastat-engine' -import { getMonthPeriod } from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine' - -/** - * GET /api/extensions/export/intrastat/report - * - * Generate an Intrastat declaration report for the specified month. - * - * Query params: - * year (required) — Fiscal year - * month (required) — 1-12 (Intrastat is always monthly) - */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const yearStr = searchParams.get('year') - const monthStr = searchParams.get('month') - - if (!yearStr || !monthStr) { - return NextResponse.json({ error: 'year and month are required' }, { status: 400 }) - } - - const year = parseInt(yearStr, 10) - const month = parseInt(monthStr, 10) - - if (isNaN(year) || year < 2000 || year > 2100) { - return NextResponse.json({ error: 'Invalid year' }, { status: 400 }) - } - if (isNaN(month) || month < 1 || month > 12) { - return NextResponse.json({ error: 'Invalid month' }, { status: 400 }) - } - - const period = getMonthPeriod(year, month) - - try { - // Fetch company settings - const { data: company } = await supabase - .from('company_settings') - .select('company_name, org_number, vat_number') - .eq('user_id', user.id) - .single() - - if (!company) { - return NextResponse.json({ error: 'Company settings not found' }, { status: 404 }) - } - - const reporterVatNumber = company.vat_number || `SE${(company.org_number || '').replace(/\D/g, '')}01` - - // Fetch reverse-charge invoices for the period - const invoices = await fetchAllRows(({ from, to }) => - supabase - .from('invoices') - .select('id, invoice_number, invoice_date, status, vat_treatment, moms_ruta, currency, total_sek, subtotal_sek, subtotal, document_type, credited_invoice_id, customer_id') - .eq('user_id', user.id) - .gte('invoice_date', period.start) - .lte('invoice_date', period.end) - .in('status', ['sent', 'paid', 'overdue']) - .eq('vat_treatment', 'reverse_charge') - .range(from, to) - ) - - // Fetch invoice items for those invoices - const invoiceIds = invoices.map(inv => inv.id) - let invoiceItems: IntrastatInvoiceItem[] = [] - if (invoiceIds.length > 0) { - const BATCH_SIZE = 200 - for (let i = 0; i < invoiceIds.length; i += BATCH_SIZE) { - const batch = invoiceIds.slice(i, i + BATCH_SIZE) - const { data: items } = await supabase - .from('invoice_items') - .select('id, invoice_id, description, quantity, unit_price, total, total_sek') - .in('invoice_id', batch) - - if (items) invoiceItems.push(...(items as IntrastatInvoiceItem[])) - } - } - - // Fetch customers - const customerIds = [...new Set(invoices.map(inv => inv.customer_id))] - let customers: IntrastatCustomer[] = [] - if (customerIds.length > 0) { - const { data: customerData } = await supabase - .from('customers') - .select('id, name, country, vat_number') - .eq('user_id', user.id) - .in('id', customerIds) - - customers = (customerData || []) as IntrastatCustomer[] - } - - // Fetch product metadata from extension_data - const { data: productData } = await supabase - .from('extension_data') - .select('key, value') - .eq('user_id', user.id) - .eq('extension_id', 'export/intrastat') - .ilike('key', 'product:%') - - const products: ProductMetadata[] = (productData || []).map((d: { key: string; value: Record }) => ({ - productId: d.key.replace('product:', ''), - cnCode: (d.value.cn_code as string) || null, - description: (d.value.description as string) || '', - netWeightKg: d.value.net_weight_kg !== undefined ? Number(d.value.net_weight_kg) : null, - countryOfOrigin: (d.value.country_of_origin as string) || 'SE', - supplementaryUnit: d.value.supplementary_unit !== undefined ? Number(d.value.supplementary_unit) : null, - supplementaryUnitType: (d.value.supplementary_unit_type as string) || null, - })) - - // Fetch extension settings - const { data: settingsData } = await supabase - .from('extension_data') - .select('value') - .eq('user_id', user.id) - .eq('extension_id', 'export/intrastat') - .eq('key', 'settings') - .maybeSingle() - - const settings = settingsData?.value as Record | undefined - const defaultTransactionNature = (settings?.default_transaction_nature as string) || '11' - const defaultDeliveryTerms = (settings?.default_delivery_terms as string) || 'FCA' - - // Calculate prior cumulative value (rolling 12 months excluding current) - const priorCumulativeValue = await calculatePriorCumulative(supabase, user.id, year, month) - - const report = generateIntrastatReport({ - invoices, - invoiceItems, - customers, - products, - reporterVatNumber, - reporterName: company.company_name || '', - year, - month, - defaultTransactionNature, - defaultDeliveryTerms, - priorCumulativeValue, - }) - - return NextResponse.json({ data: report }) - } catch (err) { - console.error('Error generating Intrastat report:', err) - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to generate report' }, - { status: 500 }, - ) - } -} - -/** - * Calculate the cumulative dispatch value for the 11 months prior to the - * current period (rolling 12-month window for threshold monitoring). - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function calculatePriorCumulative(supabase: any, userId: string, year: number, month: number): Promise { - // Calculate 11-month lookback window - let startMonth = month - 11 - let startYear = year - while (startMonth < 1) { - startMonth += 12 - startYear-- - } - const startDate = `${startYear}-${String(startMonth).padStart(2, '0')}-01` - - // End date is the day before the current period - let prevMonth = month - 1 - let prevYear = year - if (prevMonth < 1) { - prevMonth = 12 - prevYear-- - } - const lastDay = new Date(prevYear, prevMonth, 0).getDate() - const endDate = `${prevYear}-${String(prevMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` - - if (startDate > endDate) return 0 - - // Sum total_sek for reverse_charge invoices to EU in the lookback window - const { data, error } = await supabase - .from('invoices') - .select('total_sek, subtotal_sek, subtotal') - .eq('user_id', userId) - .gte('invoice_date', startDate) - .lte('invoice_date', endDate) - .in('status', ['sent', 'paid', 'overdue']) - .eq('vat_treatment', 'reverse_charge') - .eq('moms_ruta', '35') - - if (error || !data) return 0 - - let total = 0 - for (const inv of data) { - if (inv.subtotal_sek !== null) { - total += Number(inv.subtotal_sek) || 0 - } else { - total += Number(inv.subtotal) || 0 - } - } - - return Math.round(total * 100) / 100 -} diff --git a/app/api/extensions/export/vat-monitor/report/route.ts b/app/api/extensions/export/vat-monitor/report/route.ts deleted file mode 100644 index 3898b217..00000000 --- a/app/api/extensions/export/vat-monitor/report/route.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { fetchAllRows } from '@/lib/supabase/fetch-all' -import { - generateVatMonitorReport, - VAT_MONITOR_ACCOUNTS, - type GLLine, - type VatMonitorInvoice, - type VatMonitorCustomer, -} from '@/extensions/export/vat-monitor/lib/vat-monitor-engine' -import { - getMonthPeriod, - getQuarterPeriod, -} from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine' - -/** - * GET /api/extensions/export/vat-monitor/report - * - * Generate a VAT Monitor report for the specified period. - * - * Query params: - * year (required) — Fiscal year - * month (optional) — 1-12 - * quarter (optional) — 1-4 - * compare (optional) — 'previous' to include period comparison - */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const yearStr = searchParams.get('year') - const monthStr = searchParams.get('month') - const quarterStr = searchParams.get('quarter') - const compare = searchParams.get('compare') - - if (!yearStr) { - return NextResponse.json({ error: 'year is required' }, { status: 400 }) - } - - const year = parseInt(yearStr, 10) - if (isNaN(year) || year < 2000 || year > 2100) { - return NextResponse.json({ error: 'Invalid year' }, { status: 400 }) - } - - if (!monthStr && !quarterStr) { - return NextResponse.json({ error: 'Either month or quarter is required' }, { status: 400 }) - } - - if (monthStr && quarterStr) { - return NextResponse.json({ error: 'Provide either month or quarter, not both' }, { status: 400 }) - } - - let month: number | undefined - let quarter: number | undefined - - if (monthStr) { - month = parseInt(monthStr, 10) - if (isNaN(month) || month < 1 || month > 12) { - return NextResponse.json({ error: 'Invalid month' }, { status: 400 }) - } - } - - if (quarterStr) { - quarter = parseInt(quarterStr, 10) - if (isNaN(quarter) || quarter < 1 || quarter > 4) { - return NextResponse.json({ error: 'Invalid quarter' }, { status: 400 }) - } - } - - const period = month !== undefined - ? getMonthPeriod(year, month) - : getQuarterPeriod(year, quarter!) - - try { - // Fetch GL lines for current period - const glLines = await fetchGLLines(supabase, user.id, period.start, period.end) - - // Fetch invoices for validation - const invoices = await fetchAllRows(({ from, to }) => - supabase - .from('invoices') - .select('id, invoice_number, vat_treatment, moms_ruta, customer_id') - .eq('user_id', user.id) - .gte('invoice_date', period.start) - .lte('invoice_date', period.end) - .in('status', ['sent', 'paid', 'overdue']) - .range(from, to) - ) - - // Fetch customers for those invoices - const customerIds = [...new Set(invoices.map(inv => inv.customer_id))] - let customers: VatMonitorCustomer[] = [] - if (customerIds.length > 0) { - const { data: customerData } = await supabase - .from('customers') - .select('id, name, country, vat_number, vat_number_validated') - .eq('user_id', user.id) - .in('id', customerIds) - - customers = (customerData || []) as VatMonitorCustomer[] - } - - // Fetch previous period GL lines for comparison - let previousGlLines: GLLine[] | undefined - if (compare === 'previous') { - const prevPeriod = getPreviousPeriod(year, month, quarter) - previousGlLines = await fetchGLLines(supabase, user.id, prevPeriod.start, prevPeriod.end) - } - - const report = generateVatMonitorReport({ - glLines, - invoices, - customers, - year, - month, - quarter, - previousGlLines, - }) - - return NextResponse.json({ data: report }) - } catch (err) { - console.error('Error generating VAT Monitor report:', err) - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to generate report' }, - { status: 500 }, - ) - } -} - -// ── Helpers ───────────────────────────────────────────────── - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function fetchGLLines(supabase: any, userId: string, startDate: string, endDate: string): Promise { - // Fetch posted journal entry IDs for the period - const { data: entries, error: entriesError } = await supabase - .from('journal_entries') - .select('id') - .eq('user_id', userId) - .eq('status', 'posted') - .gte('entry_date', startDate) - .lte('entry_date', endDate) - - if (entriesError || !entries || entries.length === 0) return [] - - const entryIds = entries.map((e: { id: string }) => e.id) - - // Fetch lines in batches - const BATCH_SIZE = 200 - const allLines: GLLine[] = [] - - for (let i = 0; i < entryIds.length; i += BATCH_SIZE) { - const batch = entryIds.slice(i, i + BATCH_SIZE) - const { data: lines } = await supabase - .from('journal_entry_lines') - .select('account_number, debit_amount, credit_amount') - .in('journal_entry_id', batch) - .in('account_number', VAT_MONITOR_ACCOUNTS) - - if (lines) allLines.push(...lines) - } - - return allLines -} - -function getPreviousPeriod(year: number, month?: number, quarter?: number): { start: string; end: string } { - if (month !== undefined) { - let prevMonth = month - 1 - let prevYear = year - if (prevMonth < 1) { - prevMonth = 12 - prevYear-- - } - return getMonthPeriod(prevYear, prevMonth) - } - - let prevQuarter = quarter! - 1 - let prevYear = year - if (prevQuarter < 1) { - prevQuarter = 4 - prevYear-- - } - return getQuarterPeriod(prevYear, prevQuarter) -} diff --git a/app/api/extensions/ext/[...path]/route.ts b/app/api/extensions/ext/[...path]/route.ts index 0646efab..5d543c72 100644 --- a/app/api/extensions/ext/[...path]/route.ts +++ b/app/api/extensions/ext/[...path]/route.ts @@ -4,18 +4,47 @@ import { ensureInitialized } from '@/lib/init' import { extensionRegistry } from '@/lib/extensions/registry' import { createExtensionContext } from '@/lib/extensions/context-factory' import { isExtensionEnabled } from '@/lib/extensions/toggle-check' +import type { ApiRouteDefinition } from '@/lib/extensions/types' ensureInitialized() +/** + * Match a request path against a route pattern. + * Supports :param wildcards (e.g., /:id/confirm). + * Returns extracted params on match, null on mismatch. + */ +function matchPath( + pattern: string, + requestPath: string +): Record | null { + const patternParts = pattern.split('/').filter(Boolean) + const requestParts = requestPath.split('/').filter(Boolean) + + if (patternParts.length !== requestParts.length) return null + + const params: Record = {} + + for (let i = 0; i < patternParts.length; i++) { + if (patternParts[i].startsWith(':')) { + params[patternParts[i].slice(1)] = requestParts[i] + } else if (patternParts[i] !== requestParts[i]) { + return null + } + } + + return params +} + /** * Catch-all route for extension-declared API routes. * * URL scheme: /api/extensions/ext/{extensionId}/{...routePath} - * Example: /api/extensions/ext/enable-banking/banks → GET /banks + * Example: /api/extensions/ext/receipt-ocr/abc123/confirm → POST /:id/confirm * * - Looks up the extension in the registry * - Checks the extension toggle (disabled → 403) - * - Matches method + path to registered apiRoutes + * - Matches method + path pattern to registered apiRoutes + * - Extracts path params and appends them as URL search params * - Builds an ExtensionContext and passes it to the handler */ async function handleRequest( @@ -46,24 +75,51 @@ async function handleRequest( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // Toggle check — disabled extensions return 403 - const enabled = await isExtensionEnabled(user.id, 'general', extensionId) + // Toggle check — use extension's declared sector, fallback to 'general' + const sector = extension.sector || 'general' + const enabled = await isExtensionEnabled(user.id, sector, extensionId) if (!enabled) { return NextResponse.json({ error: 'Extension is disabled' }, { status: 403 }) } - // Find matching route - const route = extension.apiRoutes.find( - (r) => r.method === method && r.path === routePath - ) + // Find matching route (supports :param patterns) + let matchedRoute: ApiRouteDefinition | null = null + let extractedParams: Record = {} - if (!route) { + for (const route of extension.apiRoutes) { + if (route.method !== method) continue + + const params = matchPath(route.path, routePath) + if (params !== null) { + matchedRoute = route + extractedParams = params + break + } + } + + if (!matchedRoute) { return NextResponse.json({ error: 'Route not found' }, { status: 404 }) } + // If path params were extracted, create a new Request with them as search params + let handlerRequest = request + if (Object.keys(extractedParams).length > 0) { + const url = new URL(request.url) + for (const [key, value] of Object.entries(extractedParams)) { + url.searchParams.set(`_${key}`, value) + } + handlerRequest = new Request(url.toString(), { + method: request.method, + headers: request.headers, + body: request.body, + // @ts-expect-error -- duplex needed for streaming body + duplex: 'half', + }) + } + // Build context and dispatch const ctx = createExtensionContext(supabase, user.id, extensionId) - return route.handler(request, ctx) + return matchedRoute.handler(handlerRequest, ctx) } export const GET = handleRequest diff --git a/app/api/extensions/invoice-inbox/inbox/[id]/confirm-receipt/route.ts b/app/api/extensions/invoice-inbox/inbox/[id]/confirm-receipt/route.ts deleted file mode 100644 index 076bfe0b..00000000 --- a/app/api/extensions/invoice-inbox/inbox/[id]/confirm-receipt/route.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { ensureInitialized } from '@/lib/init' -import { eventBus } from '@/lib/events/bus' - -ensureInitialized() - -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - // Fetch inbox item - const { data: inboxItem, error: findError } = await supabase - .from('invoice_inbox_items') - .select('*') - .eq('id', id) - .eq('user_id', user.id) - .single() - - if (findError || !inboxItem) { - return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 }) - } - - if (inboxItem.document_type !== 'receipt') { - return NextResponse.json({ error: 'Inbox item is not a receipt' }, { status: 400 }) - } - - if (!inboxItem.linked_receipt_id) { - return NextResponse.json({ error: 'No linked receipt found' }, { status: 400 }) - } - - const body = await request.json() - const { - line_items, - matched_transaction_id, - representation_persons, - representation_purpose, - representation_business_connection, - } = body - - // Update receipt line items (business/private classification) - if (Array.isArray(line_items)) { - for (const item of line_items) { - if (!item.id) continue - await supabase - .from('receipt_line_items') - .update({ - is_business: item.is_business, - ...(item.category ? { category: item.category } : {}), - ...(item.bas_account ? { bas_account: item.bas_account } : {}), - }) - .eq('id', item.id) - .eq('receipt_id', inboxItem.linked_receipt_id) - } - } - - // Calculate business/private totals - const { data: updatedLineItems } = await supabase - .from('receipt_line_items') - .select('*') - .eq('receipt_id', inboxItem.linked_receipt_id) - - let businessTotal = 0 - let privateTotal = 0 - if (updatedLineItems) { - for (const li of updatedLineItems) { - if (li.is_business === true) { - businessTotal += li.line_total - } else if (li.is_business === false) { - privateTotal += li.line_total - } - } - } - businessTotal = Math.round(businessTotal * 100) / 100 - privateTotal = Math.round(privateTotal * 100) / 100 - - // Update receipt with match and representation data - const receiptUpdate: Record = { - status: 'confirmed', - } - - if (matched_transaction_id) { - receiptUpdate.matched_transaction_id = matched_transaction_id - } - if (representation_persons != null) { - receiptUpdate.representation_persons = representation_persons - } - if (representation_purpose) { - receiptUpdate.representation_purpose = representation_purpose - } - if (representation_business_connection) { - receiptUpdate.representation_business_connection = representation_business_connection - } - - await supabase - .from('receipts') - .update(receiptUpdate) - .eq('id', inboxItem.linked_receipt_id) - - // Link transaction to receipt if provided - if (matched_transaction_id) { - await supabase - .from('transactions') - .update({ receipt_id: inboxItem.linked_receipt_id }) - .eq('id', matched_transaction_id) - .eq('user_id', user.id) - } - - // Update inbox item status - await supabase - .from('invoice_inbox_items') - .update({ status: 'confirmed' }) - .eq('id', id) - - // Emit event (non-blocking) - try { - const { data: receipt } = await supabase - .from('receipts') - .select('*') - .eq('id', inboxItem.linked_receipt_id) - .single() - - if (receipt) { - await eventBus.emit({ - type: 'receipt.confirmed', - payload: { - receipt, - businessTotal, - privateTotal, - userId: user.id, - }, - }) - } - } catch { - // Non-blocking - } - - return NextResponse.json({ data: { confirmed: true, businessTotal, privateTotal } }) -} diff --git a/app/api/extensions/invoice-inbox/inbox/[id]/confirm/__tests__/route.test.ts b/app/api/extensions/invoice-inbox/inbox/[id]/confirm/__tests__/route.test.ts deleted file mode 100644 index 9f1b634b..00000000 --- a/app/api/extensions/invoice-inbox/inbox/[id]/confirm/__tests__/route.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { - createQueuedMockSupabase, - createMockRequest, - createMockRouteParams, - parseJsonResponse, - makeInvoiceInboxItem, - makeSupplier, -} from '@/tests/helpers' - -// Mock dependencies -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), -})) - -vi.mock('@/lib/init', () => ({ - ensureInitialized: vi.fn(), -})) - -vi.mock('@/lib/events/bus', () => ({ - eventBus: { emit: vi.fn().mockResolvedValue(undefined), clear: vi.fn() }, -})) - -import { createClient } from '@/lib/supabase/server' -import { POST } from '../route' - -const mockCreateClient = vi.mocked(createClient) - -describe('Invoice Inbox Confirm Route', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('returns 401 when not authenticated', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: null }, - error: { message: 'Not authenticated' }, - }) - mockCreateClient.mockResolvedValue(supabase as never) - - const request = createMockRequest('/api/extensions/invoice-inbox/inbox/item-1/confirm', { - method: 'POST', - body: {}, - }) - const response = await POST(request, createMockRouteParams({ id: 'item-1' })) - const { status } = await parseJsonResponse(response) - - expect(status).toBe(401) - }) - - it('returns 404 when item not found', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - enqueueMany([ - { data: null, error: { message: 'Not found' } }, // inbox item fetch - ]) - mockCreateClient.mockResolvedValue(supabase as never) - - const request = createMockRequest('/api/extensions/invoice-inbox/inbox/item-1/confirm', { - method: 'POST', - body: {}, - }) - const response = await POST(request, createMockRouteParams({ id: 'item-1' })) - const { status } = await parseJsonResponse(response) - - expect(status).toBe(404) - }) - - it('returns 400 when already confirmed', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - enqueueMany([ - { data: makeInvoiceInboxItem({ status: 'confirmed' }), error: null }, - ]) - mockCreateClient.mockResolvedValue(supabase as never) - - const request = createMockRequest('/api/extensions/invoice-inbox/inbox/item-1/confirm', { - method: 'POST', - body: {}, - }) - const response = await POST(request, createMockRouteParams({ id: 'item-1' })) - const { status } = await parseJsonResponse(response) - - expect(status).toBe(400) - }) - - it('returns 400 when no extracted data', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - enqueueMany([ - { data: makeInvoiceInboxItem({ status: 'ready', extracted_data: null }), error: null }, - ]) - mockCreateClient.mockResolvedValue(supabase as never) - - const request = createMockRequest('/api/extensions/invoice-inbox/inbox/item-1/confirm', { - method: 'POST', - body: {}, - }) - const response = await POST(request, createMockRouteParams({ id: 'item-1' })) - const { status } = await parseJsonResponse(response) - - expect(status).toBe(400) - }) - - it('creates new supplier when no match and confirms successfully', async () => { - const extractedData = { - supplier: { - name: 'New Supplier AB', - orgNumber: '556123-4567', - vatNumber: null, - address: null, - bankgiro: '123-4567', - plusgiro: null, - }, - invoice: { - invoiceNumber: 'F-001', - invoiceDate: '2024-06-15', - dueDate: '2024-07-15', - paymentReference: '1234567890', - currency: 'SEK', - }, - lineItems: [ - { description: 'Kontorsmaterial', quantity: 10, unitPrice: 50, lineTotal: 500, vatRate: 25, accountSuggestion: '6100' }, - ], - totals: { subtotal: 500, vatAmount: 125, total: 625 }, - vatBreakdown: [{ rate: 25, base: 500, amount: 125 }], - confidence: 0.92, - } - - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - - enqueueMany([ - // 1. Fetch inbox item - { data: makeInvoiceInboxItem({ id: 'item-1', status: 'ready', extracted_data: extractedData as unknown as Record }), error: null }, - // 2. Create new supplier - { data: makeSupplier({ id: 'new-supplier-1', name: 'New Supplier AB' }), error: null }, - // 3. Verify supplier - { data: makeSupplier({ id: 'new-supplier-1', name: 'New Supplier AB' }), error: null }, - // 4. Get arrival number - { data: 42, error: null }, - // 5. Insert supplier invoice - { data: { id: 'si-1', total: 625 }, error: null }, - // 6. Insert items - { data: null, error: null }, - // 7. Update inbox item as confirmed - { data: null, error: null }, - ]) - mockCreateClient.mockResolvedValue(supabase as never) - - const request = createMockRequest('/api/extensions/invoice-inbox/inbox/item-1/confirm', { - method: 'POST', - body: {}, - }) - const response = await POST(request, createMockRouteParams({ id: 'item-1' })) - const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) - - expect(status).toBe(200) - expect(body.data).toBeDefined() - }) - - it('uses existing supplier when matched', async () => { - const extractedData = { - supplier: { - name: 'Existing Supplier', - orgNumber: null, - vatNumber: null, - address: null, - bankgiro: null, - plusgiro: null, - }, - invoice: { - invoiceNumber: 'F-002', - invoiceDate: '2024-06-15', - dueDate: '2024-07-15', - paymentReference: null, - currency: 'SEK', - }, - lineItems: [], - totals: { subtotal: 1000, vatAmount: 250, total: 1250 }, - vatBreakdown: [], - confidence: 0.85, - } - - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - - enqueueMany([ - // 1. Fetch inbox item (has matched_supplier_id) - { data: makeInvoiceInboxItem({ - id: 'item-2', - status: 'ready', - extracted_data: extractedData as unknown as Record, - matched_supplier_id: 'existing-supplier-1', - }), error: null }, - // 2. Verify supplier - { data: makeSupplier({ id: 'existing-supplier-1', default_expense_account: '5410' }), error: null }, - // 3. Get arrival number - { data: 43, error: null }, - // 4. Insert supplier invoice - { data: { id: 'si-2', total: 1250 }, error: null }, - // 5. Insert items - { data: null, error: null }, - // 6. Update inbox item as confirmed - { data: null, error: null }, - ]) - mockCreateClient.mockResolvedValue(supabase as never) - - const request = createMockRequest('/api/extensions/invoice-inbox/inbox/item-2/confirm', { - method: 'POST', - body: {}, - }) - const response = await POST(request, createMockRouteParams({ id: 'item-2' })) - const { status } = await parseJsonResponse(response) - - expect(status).toBe(200) - }) -}) diff --git a/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts b/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts deleted file mode 100644 index dc8c68e9..00000000 --- a/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { ensureInitialized } from '@/lib/init' -import { eventBus } from '@/lib/events/bus' -import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types' -import type { SupplierInvoice } from '@/types' - -ensureInitialized() - -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - // Fetch inbox item - const { data: inboxItem, error: findError } = await supabase - .from('invoice_inbox_items') - .select('*') - .eq('id', id) - .eq('user_id', user.id) - .single() - - if (findError || !inboxItem) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - if (inboxItem.status === 'confirmed') { - return NextResponse.json({ error: 'Already confirmed' }, { status: 400 }) - } - - if (!inboxItem.extracted_data) { - return NextResponse.json({ error: 'No extracted data available' }, { status: 400 }) - } - - const extraction = inboxItem.extracted_data as unknown as InvoiceExtractionResult - const body = await request.json().catch(() => ({})) - - try { - // Resolve supplier: use matched, use body override, or create new - let supplierId = body.supplier_id || inboxItem.matched_supplier_id - - if (!supplierId) { - // Create new supplier from extracted data - const supplierName = extraction.supplier?.name - if (!supplierName) { - return NextResponse.json({ error: 'Supplier name is required' }, { status: 400 }) - } - - const { data: newSupplier, error: supplierError } = await supabase - .from('suppliers') - .insert({ - user_id: user.id, - name: supplierName, - supplier_type: 'swedish_business', - org_number: extraction.supplier?.orgNumber || null, - vat_number: extraction.supplier?.vatNumber || null, - bankgiro: extraction.supplier?.bankgiro || null, - plusgiro: extraction.supplier?.plusgiro || null, - default_expense_account: '6200', - default_payment_terms: 30, - default_currency: extraction.invoice?.currency || 'SEK', - }) - .select() - .single() - - if (supplierError || !newSupplier) { - return NextResponse.json({ error: 'Failed to create supplier' }, { status: 500 }) - } - - supplierId = newSupplier.id - } - - // Verify supplier exists and belongs to user - const { data: supplier, error: supplierCheckError } = await supabase - .from('suppliers') - .select('*') - .eq('id', supplierId) - .eq('user_id', user.id) - .single() - - if (supplierCheckError || !supplier) { - return NextResponse.json({ error: 'Supplier not found' }, { status: 404 }) - } - - // Get next arrival number - const { data: arrivalNum, error: arrivalError } = await supabase - .rpc('get_next_arrival_number', { p_user_id: user.id }) - - if (arrivalError) { - return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 }) - } - - // Build line items from extraction - const items = (extraction.lineItems || []).map((item, index) => { - const vatRate = item.vatRate != null ? item.vatRate / 100 : 0.25 - const lineTotal = Math.round(item.lineTotal * 100) / 100 - const vatAmount = Math.round(lineTotal * vatRate * 100) / 100 - return { - sort_order: index, - description: item.description, - quantity: item.quantity || 1, - unit: 'st', - unit_price: item.unitPrice != null ? item.unitPrice : lineTotal, - line_total: lineTotal, - account_number: item.accountSuggestion || supplier.default_expense_account || '6200', - vat_code: null, - vat_rate: vatRate, - vat_amount: vatAmount, - } - }) - - // If no line items, create a single item from totals - if (items.length === 0 && extraction.totals?.total) { - const total = extraction.totals.total - const vatAmount = extraction.totals.vatAmount || 0 - const subtotal = extraction.totals.subtotal || total - vatAmount - const vatRate = subtotal > 0 ? Math.round((vatAmount / subtotal) * 100) / 100 : 0.25 - items.push({ - sort_order: 0, - description: 'Fakturabelopp', - quantity: 1, - unit: 'st', - unit_price: subtotal, - line_total: subtotal, - account_number: supplier.default_expense_account || '6200', - vat_code: null, - vat_rate: vatRate, - vat_amount: Math.round(vatAmount * 100) / 100, - }) - } - - const subtotal = items.reduce((sum, i) => sum + i.line_total, 0) - const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0) - const total = Math.round((subtotal + vatAmount) * 100) / 100 - - // Determine VAT treatment - const primaryVatRate = items[0]?.vat_rate || 0.25 - let vatTreatment = 'standard_25' - if (primaryVatRate === 0.12) vatTreatment = 'reduced_12' - else if (primaryVatRate === 0.06) vatTreatment = 'reduced_6' - else if (primaryVatRate === 0) vatTreatment = 'exempt' - - // Insert supplier invoice - const { data: invoice, error: invoiceError } = await supabase - .from('supplier_invoices') - .insert({ - user_id: user.id, - supplier_id: supplierId, - arrival_number: arrivalNum, - supplier_invoice_number: extraction.invoice?.invoiceNumber || `INBOX-${Date.now()}`, - invoice_date: extraction.invoice?.invoiceDate || new Date().toISOString().split('T')[0], - due_date: extraction.invoice?.dueDate || new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0], - status: 'registered', - currency: extraction.invoice?.currency || 'SEK', - vat_treatment: vatTreatment, - payment_reference: extraction.invoice?.paymentReference || null, - subtotal: Math.round(subtotal * 100) / 100, - vat_amount: Math.round(vatAmount * 100) / 100, - total: Math.round(total * 100) / 100, - remaining_amount: Math.round(total * 100) / 100, - document_id: inboxItem.document_id || null, - notes: body.notes || null, - }) - .select() - .single() - - if (invoiceError || !invoice) { - return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 }) - } - - // Insert line items - const itemInserts = items.map((item) => ({ - supplier_invoice_id: invoice.id, - ...item, - })) - - const { error: itemsError } = await supabase - .from('supplier_invoice_items') - .insert(itemInserts) - - if (itemsError) { - await supabase.from('supplier_invoices').delete().eq('id', invoice.id) - return NextResponse.json({ error: itemsError.message }, { status: 500 }) - } - - // Update inbox item as confirmed - await supabase - .from('invoice_inbox_items') - .update({ - status: 'confirmed', - matched_supplier_id: supplierId, - created_supplier_invoice_id: invoice.id, - }) - .eq('id', inboxItem.id) - - // Emit confirmed event - try { - await eventBus.emit({ - type: 'supplier_invoice.confirmed', - payload: { - inboxItem: { ...inboxItem, status: 'confirmed' }, - supplierInvoice: invoice as SupplierInvoice, - userId: user.id, - }, - }) - } catch { - // Non-blocking - } - - // Journal entry creation is handled asynchronously by the core - // supplier_invoice.confirmed event handler (see lib/bookkeeping/handlers/) - return NextResponse.json({ - data: { - ...invoice, - items: itemInserts, - }, - }) - } catch (error) { - console.error('[invoice-inbox] Confirm failed:', error) - return NextResponse.json({ error: 'Confirmation failed' }, { status: 500 }) - } -} diff --git a/app/api/extensions/invoice-inbox/inbox/[id]/process/route.ts b/app/api/extensions/invoice-inbox/inbox/[id]/process/route.ts deleted file mode 100644 index 758c0544..00000000 --- a/app/api/extensions/invoice-inbox/inbox/[id]/process/route.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { ensureInitialized } from '@/lib/init' -import { eventBus } from '@/lib/events/bus' -import { analyzeInvoice } from '@/extensions/general/invoice-inbox/lib/invoice-analyzer' -import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher' -import { getSettings } from '@/extensions/general/invoice-inbox' -import { matchDocumentToTransactions } from '@/lib/documents/document-matcher' -import type { InvoiceInboxItem } from '@/types' - -ensureInitialized() - -export async function POST( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - // Fetch inbox item - const { data: inboxItem, error: findError } = await supabase - .from('invoice_inbox_items') - .select('*, document:document_attachments(id, storage_path, mime_type)') - .eq('id', id) - .eq('user_id', user.id) - .single() - - if (findError || !inboxItem) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - if (inboxItem.status === 'confirmed') { - return NextResponse.json({ error: 'Already confirmed' }, { status: 400 }) - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const document = inboxItem.document as any - if (!document?.storage_path || !document?.mime_type) { - return NextResponse.json({ error: 'No document attached' }, { status: 400 }) - } - - // Update status to processing - await supabase - .from('invoice_inbox_items') - .update({ status: 'processing', error_message: null }) - .eq('id', id) - - try { - // Download file - const { data: fileData, error: downloadError } = await supabase.storage - .from('documents') - .download(document.storage_path) - - if (downloadError || !fileData) { - await supabase - .from('invoice_inbox_items') - .update({ status: 'error', error_message: 'Failed to download document' }) - .eq('id', id) - return NextResponse.json({ error: 'Failed to download document' }, { status: 500 }) - } - - const arrayBuffer = await fileData.arrayBuffer() - const base64 = Buffer.from(arrayBuffer).toString('base64') - - // Analyze - const extraction = await analyzeInvoice(base64, document.mime_type) - - // Supplier matching - const settings = await getSettings(user.id) - let matchedSupplierId: string | null = null - - if (settings.autoMatchSupplierEnabled) { - const { data: suppliers } = await supabase - .from('suppliers') - .select('*') - .eq('user_id', user.id) - - if (suppliers && suppliers.length > 0) { - const match = matchSupplier(extraction, suppliers) - if (match && match.confidence >= settings.supplierMatchThreshold) { - matchedSupplierId = match.supplierId - } - } - } - - // Update inbox item with extraction + template suggestion - const updateData: Record = { - status: 'ready', - extracted_data: extraction as unknown as Record, - confidence: extraction.confidence, - matched_supplier_id: matchedSupplierId, - error_message: null, - // Reset previous match on re-process - matched_transaction_id: null, - match_confidence: null, - match_method: null, - } - - if (extraction.suggestedTemplateId) { - updateData.suggested_template_id = extraction.suggestedTemplateId - updateData.suggested_template_confidence = extraction.confidence - } - - const { data: updatedItem, error: updateError } = await supabase - .from('invoice_inbox_items') - .update(updateData) - .eq('id', id) - .select() - .single() - - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }) - } - - if (updatedItem) { - await eventBus.emit({ - type: 'supplier_invoice.extracted', - payload: { - inboxItem: updatedItem, - confidence: extraction.confidence, - userId: user.id, - }, - }) - - // Document-to-transaction matching (non-blocking) - try { - const matchResult = await matchDocumentToTransactions( - supabase, - user.id, - updatedItem as InvoiceInboxItem - ) - - if (matchResult) { - await supabase - .from('invoice_inbox_items') - .update({ - matched_transaction_id: matchResult.transactionId, - match_confidence: matchResult.confidence, - match_method: matchResult.method, - }) - .eq('id', id) - } - } catch (matchError) { - console.error('[invoice-inbox] Transaction matching failed:', matchError) - } - } - - return NextResponse.json({ data: updatedItem }) - } catch (error) { - const message = error instanceof Error ? error.message : 'Processing failed' - await supabase - .from('invoice_inbox_items') - .update({ status: 'error', error_message: message }) - .eq('id', id) - return NextResponse.json({ error: message }, { status: 500 }) - } -} diff --git a/app/api/extensions/invoice-inbox/inbox/[id]/route.ts b/app/api/extensions/invoice-inbox/inbox/[id]/route.ts deleted file mode 100644 index 86b7a99e..00000000 --- a/app/api/extensions/invoice-inbox/inbox/[id]/route.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' - -export async function GET( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - const { data, error } = await supabase - .from('invoice_inbox_items') - .select('*, document:document_attachments(id, file_name, mime_type, storage_path), supplier:suppliers(id, name)') - .eq('id', id) - .eq('user_id', user.id) - .single() - - if (error || !data) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - return NextResponse.json({ data }) -} - -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - const body = await request.json() - - // Verify item exists and belongs to user - const { data: existing, error: findError } = await supabase - .from('invoice_inbox_items') - .select('id, status') - .eq('id', id) - .eq('user_id', user.id) - .single() - - if (findError || !existing) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - if (existing.status === 'confirmed') { - return NextResponse.json({ error: 'Cannot edit confirmed item' }, { status: 400 }) - } - - // Only allow updating certain fields - const allowedFields: Record = {} - if (body.extracted_data !== undefined) allowedFields.extracted_data = body.extracted_data - if (body.matched_supplier_id !== undefined) allowedFields.matched_supplier_id = body.matched_supplier_id - - if (Object.keys(allowedFields).length === 0) { - return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 }) - } - - const { data, error } = await supabase - .from('invoice_inbox_items') - .update(allowedFields) - .eq('id', id) - .select() - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} - -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - // Soft delete: set status to rejected - const { data, error } = await supabase - .from('invoice_inbox_items') - .update({ status: 'rejected' }) - .eq('id', id) - .eq('user_id', user.id) - .select() - .single() - - if (error || !data) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - return NextResponse.json({ data }) -} diff --git a/app/api/extensions/invoice-inbox/inbox/__tests__/route.test.ts b/app/api/extensions/invoice-inbox/inbox/__tests__/route.test.ts deleted file mode 100644 index e551eaef..00000000 --- a/app/api/extensions/invoice-inbox/inbox/__tests__/route.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { createQueuedMockSupabase, createMockRequest, parseJsonResponse, makeInvoiceInboxItem } from '@/tests/helpers' - -// Mock dependencies -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), -})) - -vi.mock('@/lib/init', () => ({ - ensureInitialized: vi.fn(), -})) - -vi.mock('@/lib/events/bus', () => ({ - eventBus: { emit: vi.fn().mockResolvedValue(undefined), clear: vi.fn() }, -})) - -vi.mock('server-only', () => ({})) - -vi.mock('@/extensions/general/invoice-inbox/lib/invoice-analyzer', () => ({ - analyzeInvoice: vi.fn(), -})) - -vi.mock('@/extensions/general/invoice-inbox/lib/supplier-matcher', () => ({ - matchSupplier: vi.fn(), -})) - -vi.mock('@/extensions/general/invoice-inbox', () => ({ - getSettings: vi.fn().mockResolvedValue({ - autoProcessEnabled: true, - autoMatchSupplierEnabled: true, - supplierMatchThreshold: 0.7, - inboxEmail: null, - }), -})) - -import { createClient } from '@/lib/supabase/server' -import { GET } from '../route' - -const mockCreateClient = vi.mocked(createClient) - -describe('Invoice Inbox Routes', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('GET /api/extensions/invoice-inbox/inbox', () => { - it('returns 401 when not authenticated', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: null }, - error: { message: 'Not authenticated' }, - }) - mockCreateClient.mockResolvedValue(supabase as never) - - const request = createMockRequest('/api/extensions/invoice-inbox/inbox') - const response = await GET(request) - const { status } = await parseJsonResponse(response) - - expect(status).toBe(401) - }) - - it('returns inbox items for authenticated user', async () => { - const items = [ - makeInvoiceInboxItem({ id: 'item-1', status: 'ready' }), - makeInvoiceInboxItem({ id: 'item-2', status: 'pending' }), - ] - - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - enqueueMany([ - { data: items, error: null }, - ]) - mockCreateClient.mockResolvedValue(supabase as never) - - const request = createMockRequest('/api/extensions/invoice-inbox/inbox') - const response = await GET(request) - const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response) - - expect(status).toBe(200) - expect(body.data).toHaveLength(2) - }) - - it('filters by status when provided', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - enqueueMany([ - { data: [makeInvoiceInboxItem({ status: 'ready' })], error: null }, - ]) - mockCreateClient.mockResolvedValue(supabase as never) - - const request = createMockRequest('/api/extensions/invoice-inbox/inbox', { - searchParams: { status: 'ready' }, - }) - const response = await GET(request) - const { status } = await parseJsonResponse(response) - - expect(status).toBe(200) - }) - - it('returns 500 on database error', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - enqueueMany([ - { data: null, error: { message: 'Database error' } }, - ]) - mockCreateClient.mockResolvedValue(supabase as never) - - const request = createMockRequest('/api/extensions/invoice-inbox/inbox') - const response = await GET(request) - const { status } = await parseJsonResponse(response) - - expect(status).toBe(500) - }) - }) -}) diff --git a/app/api/extensions/invoice-inbox/inbox/route.ts b/app/api/extensions/invoice-inbox/inbox/route.ts deleted file mode 100644 index a984f55c..00000000 --- a/app/api/extensions/invoice-inbox/inbox/route.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { ensureInitialized } from '@/lib/init' -import { eventBus } from '@/lib/events/bus' -import { analyzeInvoice } from '@/extensions/general/invoice-inbox/lib/invoice-analyzer' -import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher' -import { getSettings } from '@/extensions/general/invoice-inbox' -import { matchDocumentToTransactions } from '@/lib/documents/document-matcher' -import type { InvoiceInboxItem, InvoiceExtractionResult } from '@/types' -import crypto from 'crypto' - -ensureInitialized() - -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const status = searchParams.get('status') - const documentType = searchParams.get('document_type') - - let query = supabase - .from('invoice_inbox_items') - .select('*, document:document_attachments(id, file_name, mime_type, storage_path), supplier:suppliers(id, name), receipt:receipts(id, merchant_name, total_amount, receipt_date, status, matched_transaction_id)') - .eq('user_id', user.id) - - if (status && status !== 'all') { - query = query.eq('status', status) - } - - if (documentType && documentType !== 'all') { - query = query.eq('document_type', documentType) - } - - const { data, error } = await query.order('created_at', { ascending: false }) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} - -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const formData = await request.formData() - - // Support batch upload: multiple `files` entries, fallback to single `file` - const files: File[] = [] - const multiFiles = formData.getAll('files') - if (multiFiles.length > 0) { - for (const f of multiFiles) { - if (f instanceof File) files.push(f) - } - } else { - const single = formData.get('file') as File | null - if (single) files.push(single) - } - - if (files.length === 0) { - return NextResponse.json({ error: 'No file provided' }, { status: 400 }) - } - - const supportedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] - const items: Array> = [] - const errors: string[] = [] - - for (const file of files) { - if (!supportedTypes.includes(file.type)) { - errors.push(`${file.name}: unsupported file type`) - continue - } - - try { - const result = await uploadAndCreateInboxItem(supabase, user.id, file) - items.push(result.inboxItem) - - // Process asynchronously - processInboxItem(result.inboxItem.id as string, user.id, result.base64, file.type).catch((err) => - console.error('[invoice-inbox] Background processing failed:', err) - ) - } catch (error) { - const message = error instanceof Error ? error.message : 'Upload failed' - errors.push(`${file.name}: ${message}`) - } - } - - // Return array for batch, single item for backward compat - if (files.length === 1 && items.length === 1) { - return NextResponse.json({ data: items[0] }) - } - - return NextResponse.json({ data: items, errors: errors.length > 0 ? errors : undefined }) -} - -async function uploadAndCreateInboxItem( - supabase: Awaited>, - userId: string, - file: File -): Promise<{ inboxItem: Record; base64: string }> { - const arrayBuffer = await file.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - const base64 = buffer.toString('base64') - const hash = crypto.createHash('sha256').update(buffer).digest('hex') - - const storagePath = `documents/${userId}/inbox/${Date.now()}-${file.name}` - const { error: uploadError } = await supabase.storage - .from('documents') - .upload(storagePath, buffer, { contentType: file.type }) - - if (uploadError) { - throw new Error('Failed to upload file') - } - - const { data: document, error: docError } = await supabase - .from('document_attachments') - .insert({ - user_id: userId, - storage_path: storagePath, - file_name: file.name, - file_size_bytes: buffer.length, - mime_type: file.type, - sha256_hash: hash, - upload_source: 'file_upload', - }) - .select() - .single() - - if (docError || !document) { - throw new Error('Failed to create document record') - } - - const { data: inboxItem, error: itemError } = await supabase - .from('invoice_inbox_items') - .insert({ - user_id: userId, - status: 'processing', - source: 'upload', - document_id: document.id, - }) - .select() - .single() - - if (itemError || !inboxItem) { - throw new Error('Failed to create inbox item') - } - - await eventBus.emit({ - type: 'supplier_invoice.received', - payload: { inboxItem, userId }, - }) - - return { inboxItem, base64 } -} - -async function processInboxItem( - itemId: string, - userId: string, - base64: string, - mimeType: string -): Promise { - const supabase = await createClient() - - try { - console.log(`[invoice-inbox] Processing item=${itemId}: starting AI extraction (${mimeType})`) - const extraction = await analyzeInvoice(base64, mimeType) - - console.log(`[invoice-inbox] item=${itemId} extraction complete:`, { - confidence: extraction.confidence, - suggestedTemplateId: extraction.suggestedTemplateId || null, - supplier: extraction.supplier?.name || null, - total: extraction.totals?.total || null, - invoiceDate: extraction.invoice?.invoiceDate || null, - dueDate: extraction.invoice?.dueDate || null, - paymentRef: extraction.invoice?.paymentReference || null, - }) - - // Supplier matching - const settings = await getSettings(userId) - let matchedSupplierId: string | null = null - - if (settings.autoMatchSupplierEnabled) { - const { data: suppliers } = await supabase - .from('suppliers') - .select('*') - .eq('user_id', userId) - - if (suppliers && suppliers.length > 0) { - const match = matchSupplier(extraction, suppliers) - if (match && match.confidence >= settings.supplierMatchThreshold) { - matchedSupplierId = match.supplierId - console.log(`[invoice-inbox] item=${itemId} supplier matched: id=${match.supplierId} confidence=${match.confidence}`) - } - } - } - - // Store extraction result with template suggestion - const updateData: Record = { - status: 'ready', - extracted_data: extraction as unknown as Record, - confidence: extraction.confidence, - matched_supplier_id: matchedSupplierId, - } - - if (extraction.suggestedTemplateId) { - updateData.suggested_template_id = extraction.suggestedTemplateId - updateData.suggested_template_confidence = extraction.confidence - console.log(`[invoice-inbox] item=${itemId} template suggestion: ${extraction.suggestedTemplateId} (confidence=${extraction.confidence})`) - } - - await supabase - .from('invoice_inbox_items') - .update(updateData) - .eq('id', itemId) - - // Fetch the updated item for event emission and matching - const { data: updatedItem } = await supabase - .from('invoice_inbox_items') - .select('*') - .eq('id', itemId) - .single() - - if (updatedItem) { - await eventBus.emit({ - type: 'supplier_invoice.extracted', - payload: { - inboxItem: updatedItem, - confidence: extraction.confidence, - userId, - }, - }) - - // Document-to-transaction matching - try { - const matchResult = await matchDocumentToTransactions( - supabase, - userId, - updatedItem as InvoiceInboxItem - ) - - if (matchResult) { - await supabase - .from('invoice_inbox_items') - .update({ - matched_transaction_id: matchResult.transactionId, - match_confidence: matchResult.confidence, - match_method: matchResult.method, - }) - .eq('id', itemId) - } - } catch (matchError) { - // Non-blocking: log but don't fail the item - console.error('[invoice-inbox] Transaction matching failed:', matchError) - } - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - await supabase - .from('invoice_inbox_items') - .update({ status: 'error', error_message: message }) - .eq('id', itemId) - } -} diff --git a/app/api/extensions/invoice-inbox/settings/route.ts b/app/api/extensions/invoice-inbox/settings/route.ts deleted file mode 100644 index 4b3abb3f..00000000 --- a/app/api/extensions/invoice-inbox/settings/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { getSettings, saveSettings } from '@/extensions/general/invoice-inbox' - -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const settings = await getSettings(user.id) - return NextResponse.json({ data: settings }) -} - -export async function PUT(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - const settings = await saveSettings(user.id, body) - return NextResponse.json({ data: settings }) -} diff --git a/app/api/extensions/ne-bilaga/route.ts b/app/api/extensions/ne-bilaga/route.ts deleted file mode 100644 index b661eca4..00000000 --- a/app/api/extensions/ne-bilaga/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NextResponse } from 'next/server' - -/** - * Legacy route — redirects to /api/reports/ne-bilaga - */ -export async function GET(request: Request) { - const { search } = new URL(request.url) - return NextResponse.redirect(new URL(`/api/reports/ne-bilaga${search}`, request.url), 308) -} diff --git a/app/api/extensions/push-notifications/settings/route.ts b/app/api/extensions/push-notifications/settings/route.ts deleted file mode 100644 index b9fecc7a..00000000 --- a/app/api/extensions/push-notifications/settings/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { getSettings, saveSettings } from '@/extensions/general/push-notifications' - -/** - * GET /api/extensions/push-notifications/settings - * Get the current user's push-notification extension settings - */ -export async function GET() { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const settings = await getSettings(user.id) - return NextResponse.json({ data: settings }) -} - -/** - * PATCH /api/extensions/push-notifications/settings - * Update the current user's push-notification extension settings - */ -export async function PATCH(request: Request) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - - const allowedKeys = [ - 'periodLockedEnabled', - 'periodYearClosedEnabled', - 'invoiceSentEnabled', - 'receiptExtractedEnabled', - 'receiptMatchedEnabled', - ] - const filtered: Record = {} - for (const key of allowedKeys) { - if (key in body) { - filtered[key] = body[key] - } - } - - if (Object.keys(filtered).length === 0) { - return NextResponse.json({ error: 'No valid settings provided' }, { status: 400 }) - } - - const settings = await saveSettings(user.id, filtered) - return NextResponse.json({ data: settings }) -} diff --git a/app/api/extensions/push-notifications/subscribe/route.ts b/app/api/extensions/push-notifications/subscribe/route.ts deleted file mode 100644 index deddb7bf..00000000 --- a/app/api/extensions/push-notifications/subscribe/route.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { getVapidPublicKey } from '@/extensions/general/push-notifications/notification-sender' - -/** - * GET /api/extensions/push-notifications/subscribe - * Get the VAPID public key for client-side subscription - */ -export async function GET() { - const vapidKey = getVapidPublicKey() - - if (!vapidKey) { - return NextResponse.json( - { error: 'Push notifications not configured' }, - { status: 500 } - ) - } - - return NextResponse.json({ vapidPublicKey: vapidKey }) -} - -/** - * POST /api/extensions/push-notifications/subscribe - * Save a new push subscription - */ -export async function POST(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - const { endpoint, keys } = body - - if (!endpoint || !keys?.p256dh || !keys?.auth) { - return NextResponse.json( - { error: 'Invalid subscription data' }, - { status: 400 } - ) - } - - // Get user agent for debugging - const userAgent = request.headers.get('user-agent') || null - - // Upsert subscription (update if endpoint exists) - const { data, error } = await supabase - .from('push_subscriptions') - .upsert( - { - user_id: user.id, - endpoint, - p256dh: keys.p256dh, - auth: keys.auth, - user_agent: userAgent, - is_active: true, - last_used_at: new Date().toISOString(), - }, - { - onConflict: 'user_id,endpoint', - } - ) - .select() - .single() - - if (error) { - console.error('Error saving subscription:', error) - return NextResponse.json( - { error: 'Failed to save subscription' }, - { status: 500 } - ) - } - - // Also ensure notification settings exist with defaults - await supabase - .from('notification_settings') - .upsert( - { - user_id: user.id, - tax_deadlines_enabled: true, - invoice_reminders_enabled: true, - push_enabled: true, - email_enabled: true, - quiet_start: '21:00', - quiet_end: '08:00', - }, - { - onConflict: 'user_id', - ignoreDuplicates: true, - } - ) - - return NextResponse.json({ success: true, id: data.id }) -} - -/** - * DELETE /api/extensions/push-notifications/subscribe - * Remove a push subscription - */ -export async function DELETE(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - const { endpoint } = body - - if (!endpoint) { - return NextResponse.json( - { error: 'Endpoint is required' }, - { status: 400 } - ) - } - - const { error } = await supabase - .from('push_subscriptions') - .delete() - .eq('user_id', user.id) - .eq('endpoint', endpoint) - - if (error) { - return NextResponse.json( - { error: 'Failed to remove subscription' }, - { status: 500 } - ) - } - - return NextResponse.json({ success: true }) -} diff --git a/app/api/extensions/receipt-ocr/[id]/confirm/route.ts b/app/api/extensions/receipt-ocr/[id]/confirm/route.ts deleted file mode 100644 index 851e4935..00000000 --- a/app/api/extensions/receipt-ocr/[id]/confirm/route.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { eventBus } from '@/lib/events/bus' -import { ensureInitialized } from '@/lib/init' -import type { ConfirmReceiptInput, Receipt, ReceiptLineItem } from '@/types' - -ensureInitialized() - -/** - * POST /api/receipts/[id]/confirm - * Confirm line item classifications and optionally link to a transaction - */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Verify receipt ownership - const { data: receipt, error: fetchError } = await supabase - .from('receipts') - .select('*') - .eq('id', id) - .eq('user_id', user.id) - .single() - - if (fetchError || !receipt) { - return NextResponse.json({ error: 'Receipt not found' }, { status: 404 }) - } - - const body: ConfirmReceiptInput = await request.json() - - // Update line items with classifications - if (body.line_items && body.line_items.length > 0) { - for (const item of body.line_items) { - const { error: updateError } = await supabase - .from('receipt_line_items') - .update({ - is_business: item.is_business, - category: item.category || null, - bas_account: item.bas_account || null, - }) - .eq('id', item.id) - .eq('receipt_id', id) - - if (updateError) { - console.error('Line item update error:', updateError) - } - } - } - - // Build receipt update - const receiptUpdate: Record = { - status: 'confirmed', - } - - // Add restaurant representation data if provided - if (body.representation_persons !== undefined) { - receiptUpdate.representation_persons = body.representation_persons - } - if (body.representation_purpose !== undefined) { - receiptUpdate.representation_purpose = body.representation_purpose - } - - // Link to transaction if provided - if (body.matched_transaction_id) { - // Verify transaction ownership - const { data: transaction, error: txError } = await supabase - .from('transactions') - .select('id') - .eq('id', body.matched_transaction_id) - .eq('user_id', user.id) - .single() - - if (!txError && transaction) { - receiptUpdate.matched_transaction_id = body.matched_transaction_id - - // Also update the transaction with the receipt link - await supabase - .from('transactions') - .update({ receipt_id: id }) - .eq('id', body.matched_transaction_id) - } - } - - // Update the receipt - const { data: updatedReceipt, error: updateError } = await supabase - .from('receipts') - .update(receiptUpdate) - .eq('id', id) - .select(` - *, - line_items:receipt_line_items(*) - `) - .single() - - if (updateError) { - console.error('Receipt update error:', updateError) - return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 }) - } - - // Calculate business/private totals from line items - const lineItems = ((updatedReceipt as unknown as Receipt).line_items || []) as ReceiptLineItem[] - let businessTotal = 0 - let privateTotal = 0 - for (const item of lineItems) { - if (item.is_business === true) { - businessTotal += item.line_total - } else if (item.is_business === false) { - privateTotal += item.line_total - } - } - - // Emit receipt.confirmed event - await eventBus.emit({ - type: 'receipt.confirmed', - payload: { - receipt: updatedReceipt as unknown as Receipt, - businessTotal: Math.round(businessTotal * 100) / 100, - privateTotal: Math.round(privateTotal * 100) / 100, - userId: user.id, - }, - }) - - return NextResponse.json({ data: updatedReceipt }) -} diff --git a/app/api/extensions/receipt-ocr/[id]/match/route.ts b/app/api/extensions/receipt-ocr/[id]/match/route.ts deleted file mode 100644 index 00a1158b..00000000 --- a/app/api/extensions/receipt-ocr/[id]/match/route.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { findTransactionMatches } from '@/extensions/general/receipt-ocr/lib/receipt-matcher' -import { eventBus } from '@/lib/events/bus' -import { ensureInitialized } from '@/lib/init' -import type { Receipt, Transaction } from '@/types' - -ensureInitialized() - -/** - * POST /api/receipts/[id]/match - * Find potential transaction matches for a receipt - */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Fetch receipt - const { data: receipt, error: receiptError } = await supabase - .from('receipts') - .select('*') - .eq('id', id) - .eq('user_id', user.id) - .single() - - if (receiptError || !receipt) { - return NextResponse.json({ error: 'Receipt not found' }, { status: 404 }) - } - - // Get date range for transaction search (±7 days from receipt date) - const receiptDate = receipt.receipt_date ? new Date(receipt.receipt_date) : new Date() - const startDate = new Date(receiptDate) - startDate.setDate(startDate.getDate() - 7) - const endDate = new Date(receiptDate) - endDate.setDate(endDate.getDate() + 7) - - // Fetch unmatched transactions in date range - const { data: transactions, error: txError } = await supabase - .from('transactions') - .select('*') - .eq('user_id', user.id) - .is('receipt_id', null) - .lt('amount', 0) // Only expenses - .gte('date', startDate.toISOString().split('T')[0]) - .lte('date', endDate.toISOString().split('T')[0]) - .order('date', { ascending: false }) - - if (txError) { - console.error('Transaction fetch error:', txError) - return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 }) - } - - // Find matches - const matches = findTransactionMatches( - receipt as unknown as Receipt, - transactions as Transaction[] - ) - - return NextResponse.json({ - data: { - receipt_id: id, - matches: matches.slice(0, 5), // Return top 5 matches - }, - }) -} - -/** - * PATCH /api/receipts/[id]/match - * Link a receipt to a specific transaction - */ -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - const { transaction_id, match_confidence } = body - - if (!transaction_id) { - return NextResponse.json({ error: 'transaction_id is required' }, { status: 400 }) - } - - // Verify receipt ownership - const { data: receipt, error: receiptError } = await supabase - .from('receipts') - .select('*, line_items:receipt_line_items(*)') - .eq('id', id) - .eq('user_id', user.id) - .single() - - if (receiptError || !receipt) { - return NextResponse.json({ error: 'Receipt not found' }, { status: 404 }) - } - - // Verify transaction ownership - const { data: transaction, error: txError } = await supabase - .from('transactions') - .select('*') - .eq('id', transaction_id) - .eq('user_id', user.id) - .single() - - if (txError || !transaction) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } - - // Update receipt with match - const { error: updateReceiptError } = await supabase - .from('receipts') - .update({ - matched_transaction_id: transaction_id, - match_confidence: match_confidence || null, - }) - .eq('id', id) - - if (updateReceiptError) { - console.error('Receipt update error:', updateReceiptError) - return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 }) - } - - // Update transaction with receipt link - const { error: updateTxError } = await supabase - .from('transactions') - .update({ receipt_id: id }) - .eq('id', transaction_id) - - if (updateTxError) { - console.error('Transaction update error:', updateTxError) - } - - // Emit receipt.matched event - await eventBus.emit({ - type: 'receipt.matched', - payload: { - receipt: receipt as unknown as Receipt, - transaction: transaction as Transaction, - confidence: match_confidence || 0, - autoMatched: false, - userId: user.id, - }, - }) - - return NextResponse.json({ - data: { - receipt_id: id, - transaction_id, - matched: true, - }, - }) -} - -/** - * DELETE /api/receipts/[id]/match - * Unlink a receipt from its matched transaction - */ -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Verify receipt ownership and get current transaction link - const { data: receipt, error: receiptError } = await supabase - .from('receipts') - .select('id, matched_transaction_id') - .eq('id', id) - .eq('user_id', user.id) - .single() - - if (receiptError || !receipt) { - return NextResponse.json({ error: 'Receipt not found' }, { status: 404 }) - } - - const transactionId = receipt.matched_transaction_id - - // Remove match from receipt - const { error: updateReceiptError } = await supabase - .from('receipts') - .update({ - matched_transaction_id: null, - match_confidence: null, - }) - .eq('id', id) - - if (updateReceiptError) { - console.error('Receipt update error:', updateReceiptError) - return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 }) - } - - // Remove receipt link from transaction - if (transactionId) { - await supabase - .from('transactions') - .update({ receipt_id: null }) - .eq('id', transactionId) - } - - return NextResponse.json({ - data: { - receipt_id: id, - unmatched: true, - }, - }) -} diff --git a/app/api/extensions/receipt-ocr/[id]/route.ts b/app/api/extensions/receipt-ocr/[id]/route.ts deleted file mode 100644 index 45ed5e97..00000000 --- a/app/api/extensions/receipt-ocr/[id]/route.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' - -/** - * GET /api/receipts/[id] - * Get a single receipt with line items - */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { data, error } = await supabase - .from('receipts') - .select(` - *, - line_items:receipt_line_items(*), - matched_transaction:transactions(*) - `) - .eq('id', id) - .eq('user_id', user.id) - .single() - - if (error) { - if (error.code === 'PGRST116') { - return NextResponse.json({ error: 'Receipt not found' }, { status: 404 }) - } - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} - -/** - * PATCH /api/receipts/[id] - * Update a receipt - */ -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - - // Allowed update fields - const allowedFields = [ - 'merchant_name', - 'receipt_date', - 'receipt_time', - 'total_amount', - 'currency', - 'vat_amount', - 'is_restaurant', - 'is_systembolaget', - 'is_foreign_merchant', - 'representation_persons', - 'representation_purpose', - 'status', - ] - - const updates: Record = {} - for (const field of allowedFields) { - if (body[field] !== undefined) { - updates[field] = body[field] - } - } - - if (Object.keys(updates).length === 0) { - return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 }) - } - - const { data, error } = await supabase - .from('receipts') - .update(updates) - .eq('id', id) - .eq('user_id', user.id) - .select(` - *, - line_items:receipt_line_items(*) - `) - .single() - - if (error) { - if (error.code === 'PGRST116') { - return NextResponse.json({ error: 'Receipt not found' }, { status: 404 }) - } - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} - -/** - * DELETE /api/receipts/[id] - * Delete a receipt and its line items - */ -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Get receipt to find image URL for cleanup - const { data: receipt } = await supabase - .from('receipts') - .select('image_url, matched_transaction_id') - .eq('id', id) - .eq('user_id', user.id) - .single() - - if (!receipt) { - return NextResponse.json({ error: 'Receipt not found' }, { status: 404 }) - } - - // Unlink from transaction if matched - if (receipt.matched_transaction_id) { - await supabase - .from('transactions') - .update({ receipt_id: null }) - .eq('id', receipt.matched_transaction_id) - } - - // Delete receipt (line items are cascade deleted) - const { error } = await supabase - .from('receipts') - .delete() - .eq('id', id) - .eq('user_id', user.id) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - // Optionally delete image from storage - if (receipt.image_url) { - try { - const urlParts = receipt.image_url.split('/receipts/') - if (urlParts[1]) { - await supabase.storage.from('receipts').remove([urlParts[1]]) - } - } catch { - // Ignore storage cleanup errors - } - } - - return NextResponse.json({ success: true }) -} diff --git a/app/api/extensions/receipt-ocr/queue/route.ts b/app/api/extensions/receipt-ocr/queue/route.ts deleted file mode 100644 index c97c807a..00000000 --- a/app/api/extensions/receipt-ocr/queue/route.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' - -/** - * GET /api/receipts/queue - * Get unmatched receipts and queue statistics - */ -export async function GET(request: Request) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Parse query params - const { searchParams } = new URL(request.url) - const status = searchParams.get('status') // 'pending', 'extracted', 'confirmed', or null for all - const unmatched = searchParams.get('unmatched') === 'true' - - // Build query - let query = supabase - .from('receipts') - .select(` - *, - line_items:receipt_line_items(*) - `) - .eq('user_id', user.id) - .order('created_at', { ascending: false }) - - if (status) { - query = query.eq('status', status) - } - - if (unmatched) { - query = query.is('matched_transaction_id', null) - } - - const { data: receipts, error: receiptsError } = await query - - if (receiptsError) { - console.error('Receipts fetch error:', receiptsError) - return NextResponse.json({ error: 'Failed to fetch receipts' }, { status: 500 }) - } - - // Get counts for queue summary - const { count: unmatchedReceiptsCount } = await supabase - .from('receipts') - .select('*', { count: 'exact', head: true }) - .eq('user_id', user.id) - .eq('status', 'confirmed') - .is('matched_transaction_id', null) - - const { count: pendingReviewCount } = await supabase - .from('receipts') - .select('*', { count: 'exact', head: true }) - .eq('user_id', user.id) - .eq('status', 'extracted') - - const { count: unmatchedTransactionsCount } = await supabase - .from('transactions') - .select('*', { count: 'exact', head: true }) - .eq('user_id', user.id) - .lt('amount', 0) - .is('receipt_id', null) - - // Calculate streak (days with at least one categorized transaction) - const { data: recentActivity } = await supabase - .from('receipts') - .select('created_at') - .eq('user_id', user.id) - .eq('status', 'confirmed') - .order('created_at', { ascending: false }) - .limit(30) - - let streakCount = 0 - if (recentActivity && recentActivity.length > 0) { - const today = new Date() - today.setHours(0, 0, 0, 0) - - const activityDates = new Set( - recentActivity.map((r) => new Date(r.created_at).toISOString().split('T')[0]) - ) - - let checkDate = new Date(today) - while (activityDates.has(checkDate.toISOString().split('T')[0])) { - streakCount++ - checkDate.setDate(checkDate.getDate() - 1) - } - } - - return NextResponse.json({ - data: { - receipts, - summary: { - unmatched_receipts_count: unmatchedReceiptsCount || 0, - unmatched_transactions_count: unmatchedTransactionsCount || 0, - pending_review_count: pendingReviewCount || 0, - streak_count: streakCount, - }, - }, - }) -} diff --git a/app/api/extensions/receipt-ocr/route.ts b/app/api/extensions/receipt-ocr/route.ts deleted file mode 100644 index e5023d7a..00000000 --- a/app/api/extensions/receipt-ocr/route.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' - -/** - * GET /api/receipts - * List receipts for the authenticated user - * Query params: - * - status: filter by status - * - limit: max results (default 50) - * - offset: pagination offset - */ -export async function GET(request: Request) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const status = searchParams.get('status') - const limit = parseInt(searchParams.get('limit') || '50', 10) - const offset = parseInt(searchParams.get('offset') || '0', 10) - - let query = supabase - .from('receipts') - .select(` - *, - line_items:receipt_line_items(*) - `, { count: 'exact' }) - .eq('user_id', user.id) - .order('created_at', { ascending: false }) - .range(offset, offset + limit - 1) - - if (status) { - query = query.eq('status', status) - } - - const { data, error, count } = await query - - if (error) { - console.error('Receipts fetch error:', error) - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ - data, - count, - limit, - offset, - }) -} diff --git a/app/api/extensions/receipt-ocr/settings/route.ts b/app/api/extensions/receipt-ocr/settings/route.ts deleted file mode 100644 index 3562786a..00000000 --- a/app/api/extensions/receipt-ocr/settings/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { getSettings, saveSettings } from '@/extensions/general/receipt-ocr' - -/** - * GET /api/extensions/receipt-ocr/settings - * Get the current user's receipt-ocr extension settings - */ -export async function GET() { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const settings = await getSettings(user.id) - return NextResponse.json({ data: settings }) -} - -/** - * PATCH /api/extensions/receipt-ocr/settings - * Update the current user's receipt-ocr extension settings - */ -export async function PATCH(request: Request) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - - // Validate setting keys - const allowedKeys = [ - 'autoOcrEnabled', - 'autoMatchEnabled', - 'autoMatchThreshold', - 'ocrConfidenceThreshold', - ] - const filtered: Record = {} - for (const key of allowedKeys) { - if (key in body) { - filtered[key] = body[key] - } - } - - if (Object.keys(filtered).length === 0) { - return NextResponse.json({ error: 'No valid settings provided' }, { status: 400 }) - } - - const settings = await saveSettings(user.id, filtered) - return NextResponse.json({ data: settings }) -} diff --git a/app/api/extensions/receipt-ocr/upload/route.ts b/app/api/extensions/receipt-ocr/upload/route.ts deleted file mode 100644 index 215792dd..00000000 --- a/app/api/extensions/receipt-ocr/upload/route.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { analyzeReceipt } from '@/extensions/general/receipt-ocr/lib/receipt-analyzer' -import { processLineItems } from '@/extensions/general/receipt-ocr/lib/receipt-categorizer' -import { eventBus } from '@/lib/events/bus' -import { ensureInitialized } from '@/lib/init' -import { uploadDocument } from '@/lib/core/documents/document-service' - -ensureInitialized() - -/** - * POST /api/receipts/upload - * Upload a receipt image and extract data using Claude Vision - * - * Accepts multipart/form-data with: - * - image: The receipt image file - */ -export async function POST(request: Request) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const formData = await request.formData() - const imageFile = formData.get('image') as File | null - - if (!imageFile) { - return NextResponse.json({ error: 'No image file provided' }, { status: 400 }) - } - - // Validate file type - const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] - if (!validTypes.includes(imageFile.type)) { - return NextResponse.json( - { error: 'Invalid file type. Supported: JPEG, PNG, WebP, GIF' }, - { status: 400 } - ) - } - - // Convert file to base64 - const arrayBuffer = await imageFile.arrayBuffer() - const base64 = Buffer.from(arrayBuffer).toString('base64') - - // Generate unique filename - const ext = imageFile.type.split('/')[1] - const filename = `${user.id}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}` - - // Upload to Supabase Storage - const { data: uploadData, error: uploadError } = await supabase.storage - .from('receipts') - .upload(filename, arrayBuffer, { - contentType: imageFile.type, - cacheControl: '3600', - }) - - if (uploadError) { - console.error('Storage upload error:', uploadError) - return NextResponse.json({ error: 'Failed to upload image' }, { status: 500 }) - } - - // WORM archive copy (non-blocking — receipt flow continues even if this fails) - let wormDocumentId: string | null = null - try { - const wormDoc = await uploadDocument(user.id, { - name: imageFile.name, - buffer: arrayBuffer, - type: imageFile.type, - }, { upload_source: 'camera' }) - wormDocumentId = wormDoc.id - } catch (archiveErr) { - console.error('[receipt-upload] WORM archive copy failed:', archiveErr) - } - - // Get public URL - const { data: urlData } = supabase.storage.from('receipts').getPublicUrl(filename) - const imageUrl = urlData.publicUrl - - // Create receipt record with pending status - const { data: receipt, error: insertError } = await supabase - .from('receipts') - .insert({ - user_id: user.id, - image_url: imageUrl, - status: 'processing', - document_id: wormDocumentId, - }) - .select() - .single() - - if (insertError) { - console.error('Receipt insert error:', insertError) - return NextResponse.json({ error: 'Failed to create receipt record' }, { status: 500 }) - } - - // Analyze receipt with Claude Vision - try { - const mimeType = imageFile.type as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' - const extraction = await analyzeReceipt(base64, mimeType) - - // Process and categorize line items - const processedLineItems = processLineItems(extraction.lineItems) - - // Update receipt with extracted data - const { error: updateError } = await supabase - .from('receipts') - .update({ - status: 'extracted', - extraction_confidence: extraction.confidence, - merchant_name: extraction.merchant.name, - merchant_org_number: extraction.merchant.orgNumber, - merchant_vat_number: extraction.merchant.vatNumber, - receipt_date: extraction.receipt.date, - receipt_time: extraction.receipt.time, - total_amount: extraction.totals.total, - currency: extraction.receipt.currency, - vat_amount: extraction.totals.vatAmount, - is_restaurant: extraction.flags.isRestaurant, - is_systembolaget: extraction.flags.isSystembolaget, - is_foreign_merchant: extraction.flags.isForeignMerchant, - raw_extraction: extraction, - }) - .eq('id', receipt.id) - - if (updateError) { - console.error('Receipt update error:', updateError) - } - - // Insert line items - if (processedLineItems.length > 0) { - const lineItemsToInsert = processedLineItems.map((item, index) => ({ - receipt_id: receipt.id, - description: item.description, - quantity: item.quantity, - unit_price: item.unitPrice, - line_total: item.lineTotal, - vat_rate: item.vatRate, - vat_amount: item.vatRate && item.lineTotal ? (item.lineTotal * item.vatRate) / (100 + item.vatRate) : null, - extraction_confidence: item.confidence, - suggested_category: item.suggestedCategory, - category: item.category, - bas_account: item.basAccount, - sort_order: index, - })) - - const { error: lineItemsError } = await supabase - .from('receipt_line_items') - .insert(lineItemsToInsert) - - if (lineItemsError) { - console.error('Line items insert error:', lineItemsError) - } - } - - // Fetch the complete receipt with line items - const { data: completeReceipt, error: fetchError } = await supabase - .from('receipts') - .select(` - *, - line_items:receipt_line_items(*) - `) - .eq('id', receipt.id) - .single() - - if (fetchError) { - console.error('Fetch error:', fetchError) - return NextResponse.json({ - data: { - id: receipt.id, - status: 'extracted', - extraction, - }, - }) - } - - // Emit receipt.extracted event - await eventBus.emit({ - type: 'receipt.extracted', - payload: { - receipt: completeReceipt, - documentId: wormDocumentId, - confidence: extraction.confidence, - userId: user.id, - }, - }) - - return NextResponse.json({ data: completeReceipt }) - } catch (analysisError) { - console.error('Receipt analysis error:', analysisError) - - // Update receipt with error status - await supabase - .from('receipts') - .update({ - status: 'error', - raw_extraction: { - error: analysisError instanceof Error ? analysisError.message : 'Unknown error', - }, - }) - .eq('id', receipt.id) - - return NextResponse.json( - { - error: 'Failed to analyze receipt', - receiptId: receipt.id, - }, - { status: 500 } - ) - } - } catch (error) { - console.error('Upload error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Upload failed' }, - { status: 500 } - ) - } -} diff --git a/app/api/extensions/sru-export/coverage/route.ts b/app/api/extensions/sru-export/coverage/route.ts deleted file mode 100644 index 0b3806b4..00000000 --- a/app/api/extensions/sru-export/coverage/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { NextResponse } from 'next/server' - -/** - * Legacy route — redirects to /api/reports/sru-export/coverage - */ -export async function GET(request: Request) { - return NextResponse.redirect(new URL('/api/reports/sru-export/coverage', request.url), 308) -} diff --git a/app/api/extensions/sru-export/route.ts b/app/api/extensions/sru-export/route.ts deleted file mode 100644 index 2bc72758..00000000 --- a/app/api/extensions/sru-export/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NextResponse } from 'next/server' - -/** - * Legacy route — redirects to /api/reports/sru-export - */ -export async function GET(request: Request) { - const { search } = new URL(request.url) - return NextResponse.redirect(new URL(`/api/reports/sru-export${search}`, request.url), 308) -} diff --git a/app/api/import/sie/execute/route.ts b/app/api/import/sie/execute/route.ts index 9de1a2d1..8f85f247 100644 --- a/app/api/import/sie/execute/route.ts +++ b/app/api/import/sie/execute/route.ts @@ -172,6 +172,7 @@ export async function POST(request: Request) { // Execute the import const result = await executeSIEImport( + supabase, user.id, parsed, mappings, diff --git a/app/api/import/sie/mappings/route.ts b/app/api/import/sie/mappings/route.ts index 1725893e..6ddd9688 100644 --- a/app/api/import/sie/mappings/route.ts +++ b/app/api/import/sie/mappings/route.ts @@ -54,7 +54,7 @@ export async function POST(request: Request) { } try { - await saveMappings(user.id, mappings) + await saveMappings(supabase, user.id, mappings) return NextResponse.json({ success: true }) } catch (error) { return NextResponse.json( diff --git a/app/api/import/sie/parse/route.ts b/app/api/import/sie/parse/route.ts index 2b3fc37a..4655612d 100644 --- a/app/api/import/sie/parse/route.ts +++ b/app/api/import/sie/parse/route.ts @@ -53,7 +53,7 @@ export async function POST(request: Request) { const content = decodeBuffer(arrayBuffer, encoding) // Check for duplicate import - const duplicate = await checkDuplicateImport(user.id, content) + const duplicate = await checkDuplicateImport(supabase, user.id, content) if (duplicate) { return NextResponse.json({ error: 'duplicate', diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts index bb2198d5..5db5dcf1 100644 --- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -123,6 +123,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => { expect(body.paid_amount).toBe(12500) expect(body.journal_entry_id).toBe('je-1') expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith( + expect.anything(), 'user-1', expect.objectContaining({ id: 'inv-1' }), expect.any(String) @@ -155,6 +156,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => { expect(body.success).toBe(true) expect(body.journal_entry_id).toBe('je-2') expect(mockCreateInvoiceCashEntry).toHaveBeenCalledWith( + expect.anything(), 'user-1', expect.objectContaining({ id: 'inv-1' }), expect.any(String), diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index fa660970..1dd544fe 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -86,6 +86,7 @@ export async function POST( if (accountingMethod === 'accrual') { // Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510) const journalEntry = await createInvoicePaymentJournalEntry( + supabase, user.id, invoice as Invoice, paymentDate @@ -94,6 +95,7 @@ export async function POST( } else { // Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx) const journalEntry = await createInvoiceCashEntry( + supabase, user.id, invoice as Invoice, paymentDate, diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index 142d319d..25b3a993 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -68,6 +68,7 @@ export async function POST( if (isRealInvoice && accountingMethod === 'accrual') { try { const journalEntry = await createInvoiceJournalEntry( + supabase, user.id, invoice as Invoice, (settings?.entity_type as EntityType) || 'enskild_firma' diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index cb836f2b..8631ceff 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -34,10 +34,12 @@ vi.mock('@/lib/invoices/pdf-template', () => ({ })) const mockSendEmail = vi.fn() -const mockIsResendConfigured = vi.fn() -vi.mock('@/lib/email/resend', () => ({ - sendEmail: (...args: unknown[]) => mockSendEmail(...args), - isResendConfigured: () => mockIsResendConfigured(), +const mockIsConfigured = vi.fn() +vi.mock('@/lib/email/service', () => ({ + getEmailService: () => ({ + sendEmail: (...args: unknown[]) => mockSendEmail(...args), + isConfigured: () => mockIsConfigured(), + }), })) vi.mock('@/lib/email/invoice-templates', () => ({ @@ -84,7 +86,7 @@ describe('POST /api/invoices/[id]/send', () => { reset() eventBus.clear() mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) - mockIsResendConfigured.mockReturnValue(true) + mockIsConfigured.mockReturnValue(true) mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf')) }) @@ -100,7 +102,7 @@ describe('POST /api/invoices/[id]/send', () => { }) it('returns 503 when email service is not configured', async () => { - mockIsResendConfigured.mockReturnValue(false) + mockIsConfigured.mockReturnValue(false) const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) @@ -181,6 +183,7 @@ describe('POST /api/invoices/[id]/send', () => { }) ) expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith( + expect.anything(), 'user-1', expect.objectContaining({ id: 'inv-1' }), 'enskild_firma' diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index d7dcfe1e..864437e0 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -4,7 +4,7 @@ import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' -import { sendEmail, isResendConfigured } from '@/lib/email/resend' +import { getEmailService } from '@/lib/email/service' import { generateInvoiceEmailHtml, generateInvoiceEmailText, @@ -29,10 +29,11 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // Check if Resend is configured - if (!isResendConfigured()) { + // Check if email is configured + const emailService = getEmailService() + if (!emailService.isConfigured()) { return NextResponse.json( - { error: 'E-posttjänsten är inte konfigurerad. Kontakta support.' }, + { error: 'E-posttjänsten är inte konfigurerad. Aktivera e-posttillägget i inställningar.' }, { status: 503 } ) } @@ -129,7 +130,7 @@ export async function POST( } // Send email - const result = await sendEmail({ + const result = await emailService.sendEmail({ to: customer.email, subject: generateInvoiceEmailSubject(emailData), html: generateInvoiceEmailHtml(emailData), @@ -171,6 +172,7 @@ export async function POST( if (isRealInvoice && ((company as Record).accounting_method === 'accrual' || !(company as Record).accounting_method)) { try { const journalEntry = await createInvoiceJournalEntry( + supabase, user.id, invoice as Invoice, (company as CompanySettings).entity_type @@ -192,7 +194,7 @@ export async function POST( if (isRealInvoice) { try { const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer - await uploadDocument(user.id, { + await uploadDocument(supabase, user.id, { name: filename, buffer: pdfArrayBuffer, type: 'application/pdf', diff --git a/app/api/invoices/reminders/cron/route.ts b/app/api/invoices/reminders/cron/route.ts index 12dd0a95..2efb97ae 100644 --- a/app/api/invoices/reminders/cron/route.ts +++ b/app/api/invoices/reminders/cron/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server' import { processOverdueReminders } from '@/lib/invoices/reminder-processor' -import { isResendConfigured } from '@/lib/email/resend' +import { getEmailService } from '@/lib/email/service' // Verify cron secret for security function verifyCronSecret(request: Request): boolean { @@ -31,8 +31,8 @@ export async function GET(request: Request) { } // Check if email service is configured - if (!isResendConfigured()) { - console.error('Resend not configured, skipping reminder cron') + if (!getEmailService().isConfigured()) { + console.error('Email service not configured, skipping reminder cron') return NextResponse.json({ success: false, error: 'Email service not configured' diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index f44db2c4..a1ce5ef2 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -378,6 +378,7 @@ async function createCreditNote( if (completeCreditNote && accountingMethod === 'accrual') { try { const journalEntry = await createCreditNoteJournalEntry( + supabase, userId, completeCreditNote as Invoice, entityType diff --git a/app/api/reports/ar-ledger/route.ts b/app/api/reports/ar-ledger/route.ts index 282d49ab..c5a8318b 100644 --- a/app/api/reports/ar-ledger/route.ts +++ b/app/api/reports/ar-ledger/route.ts @@ -16,11 +16,11 @@ export async function GET(request: Request) { const asOfDate = searchParams.get('as_of_date') || undefined const periodId = searchParams.get('period_id') || undefined - const ledger = await generateARLedger(user.id, asOfDate) + const ledger = await generateARLedger(supabase, user.id, asOfDate) let reconciliation = null if (periodId) { - reconciliation = await generateARReconciliation(user.id, periodId) + reconciliation = await generateARReconciliation(supabase, user.id, periodId) } return NextResponse.json({ diff --git a/app/api/reports/balance-sheet/route.ts b/app/api/reports/balance-sheet/route.ts index 9984586c..aaf9d62a 100644 --- a/app/api/reports/balance-sheet/route.ts +++ b/app/api/reports/balance-sheet/route.ts @@ -25,7 +25,7 @@ export async function GET(request: Request) { .single() try { - const result = await generateBalanceSheet(user.id, periodId) + const result = await generateBalanceSheet(supabase, user.id, periodId) if (period) { result.period = { diff --git a/app/api/reports/general-ledger/route.ts b/app/api/reports/general-ledger/route.ts index 1b410f8c..70d4fc11 100644 --- a/app/api/reports/general-ledger/route.ts +++ b/app/api/reports/general-ledger/route.ts @@ -20,7 +20,7 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) } - const data = await generateGeneralLedger(user.id, periodId, accountFrom, accountTo) + const data = await generateGeneralLedger(supabase, user.id, periodId, accountFrom, accountTo) return NextResponse.json({ data }) } diff --git a/app/api/reports/income-statement/route.ts b/app/api/reports/income-statement/route.ts index 7e57a61f..4005669d 100644 --- a/app/api/reports/income-statement/route.ts +++ b/app/api/reports/income-statement/route.ts @@ -26,7 +26,7 @@ export async function GET(request: Request) { .single() try { - const result = await generateIncomeStatement(user.id, periodId) + const result = await generateIncomeStatement(supabase, user.id, periodId) if (period) { result.period = { diff --git a/app/api/reports/journal-register/route.ts b/app/api/reports/journal-register/route.ts index fda018ba..db7365f0 100644 --- a/app/api/reports/journal-register/route.ts +++ b/app/api/reports/journal-register/route.ts @@ -18,7 +18,7 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) } - const data = await generateJournalRegister(user.id, periodId) + const data = await generateJournalRegister(supabase, user.id, periodId) return NextResponse.json({ data }) } diff --git a/app/api/reports/monthly-breakdown/route.ts b/app/api/reports/monthly-breakdown/route.ts index d20adfba..db3fc9c9 100644 --- a/app/api/reports/monthly-breakdown/route.ts +++ b/app/api/reports/monthly-breakdown/route.ts @@ -18,7 +18,7 @@ export async function GET(request: Request) { } try { - const data = await generateMonthlyBreakdown(user.id, periodId) + const data = await generateMonthlyBreakdown(supabase, user.id, periodId) return NextResponse.json({ data }) } catch { return NextResponse.json({ error: 'Failed to generate monthly breakdown' }, { status: 500 }) diff --git a/app/api/reports/ne-bilaga/route.ts b/app/api/reports/ne-bilaga/route.ts index 8737edfe..dd9431e7 100644 --- a/app/api/reports/ne-bilaga/route.ts +++ b/app/api/reports/ne-bilaga/route.ts @@ -40,7 +40,7 @@ export async function GET(request: Request) { } try { - const declaration = await generateNEDeclaration(user.id, periodId) + const declaration = await generateNEDeclaration(supabase, user.id, periodId) if (format === 'sru') { // Generate and return SRU file diff --git a/app/api/reports/sie-export/route.ts b/app/api/reports/sie-export/route.ts index bcd8461f..dcdb2e02 100644 --- a/app/api/reports/sie-export/route.ts +++ b/app/api/reports/sie-export/route.ts @@ -29,7 +29,7 @@ export async function GET(request: Request) { } try { - const sieContent = await generateSIEExport(user.id, { + const sieContent = await generateSIEExport(supabase, user.id, { fiscal_period_id: periodId, company_name: company.company_name || 'Unknown', org_number: company.org_number, diff --git a/app/api/reports/sru-export/coverage/route.ts b/app/api/reports/sru-export/coverage/route.ts index 64508eac..b4066f05 100644 --- a/app/api/reports/sru-export/coverage/route.ts +++ b/app/api/reports/sru-export/coverage/route.ts @@ -17,7 +17,7 @@ export async function GET() { } try { - const coverage = await getSRUCoverage(user.id) + const coverage = await getSRUCoverage(supabase, user.id) return NextResponse.json({ data: coverage }) } catch (err) { console.error('Error fetching SRU coverage:', err) diff --git a/app/api/reports/sru-export/route.ts b/app/api/reports/sru-export/route.ts index bfacb828..61deb16f 100644 --- a/app/api/reports/sru-export/route.ts +++ b/app/api/reports/sru-export/route.ts @@ -73,7 +73,7 @@ export async function GET(request: Request) { } // Aggregate balances by SRU code - const sruBalances = await aggregateBalancesBySRU(user.id, periodId) + const sruBalances = await aggregateBalancesBySRU(supabase, user.id, periodId) if (format === 'sru') { // Generate and return SRU file diff --git a/app/api/reports/supplier-ledger/route.ts b/app/api/reports/supplier-ledger/route.ts index 37dcd71f..e0ddc6a3 100644 --- a/app/api/reports/supplier-ledger/route.ts +++ b/app/api/reports/supplier-ledger/route.ts @@ -16,11 +16,11 @@ export async function GET(request: Request) { const asOfDate = searchParams.get('as_of_date') || undefined const periodId = searchParams.get('period_id') || undefined - const ledger = await generateSupplierLedger(user.id, asOfDate) + const ledger = await generateSupplierLedger(supabase, user.id, asOfDate) let reconciliation = null if (periodId) { - reconciliation = await generateReconciliation(user.id, periodId) + reconciliation = await generateReconciliation(supabase, user.id, periodId) } return NextResponse.json({ diff --git a/app/api/reports/trial-balance/route.ts b/app/api/reports/trial-balance/route.ts index 7c4e32bb..fb2b9daf 100644 --- a/app/api/reports/trial-balance/route.ts +++ b/app/api/reports/trial-balance/route.ts @@ -18,7 +18,7 @@ export async function GET(request: Request) { } try { - const result = await generateTrialBalance(user.id, periodId) + const result = await generateTrialBalance(supabase, user.id, periodId) return NextResponse.json({ data: result }) } catch (err) { return NextResponse.json( diff --git a/app/api/reports/vat-declaration/route.ts b/app/api/reports/vat-declaration/route.ts index 85225c53..8f1a2162 100644 --- a/app/api/reports/vat-declaration/route.ts +++ b/app/api/reports/vat-declaration/route.ts @@ -101,6 +101,7 @@ export async function GET(request: Request) { try { const declaration = await calculateVatDeclaration( + supabase, user.id, periodType, year, diff --git a/app/api/supplier-invoices/[id]/credit/route.ts b/app/api/supplier-invoices/[id]/credit/route.ts index 7d200e75..607f9c74 100644 --- a/app/api/supplier-invoices/[id]/credit/route.ts +++ b/app/api/supplier-invoices/[id]/credit/route.ts @@ -103,6 +103,7 @@ export async function POST( if (accountingMethod === 'accrual') { try { const journalEntry = await createSupplierCreditNoteEntry( + supabase, user.id, creditNote as SupplierInvoice, creditItems as SupplierInvoiceItem[], diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts index ddf572dd..c6f92189 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts @@ -63,6 +63,7 @@ export async function POST( try { if (accountingMethod === 'cash') { const journalEntry = await createSupplierInvoiceCashEntry( + supabase, user.id, invoice as SupplierInvoice, (invoice.items || []) as SupplierInvoiceItem[], @@ -72,6 +73,7 @@ export async function POST( if (journalEntry) journalEntryId = journalEntry.id } else { const journalEntry = await createSupplierInvoicePaymentEntry( + supabase, user.id, invoice as SupplierInvoice, paymentAmount, diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index a3729c1b..478da92c 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -167,6 +167,7 @@ export async function POST(request: Request) { if (accountingMethod === 'accrual') { try { const journalEntry = await createSupplierInvoiceRegistrationEntry( + supabase, user.id, invoice as SupplierInvoice, items as SupplierInvoiceItem[], diff --git a/app/api/transactions/[id]/book/__tests__/route.test.ts b/app/api/transactions/[id]/book/__tests__/route.test.ts index 0d701a4d..d99ef199 100644 --- a/app/api/transactions/[id]/book/__tests__/route.test.ts +++ b/app/api/transactions/[id]/book/__tests__/route.test.ts @@ -155,7 +155,7 @@ describe('POST /api/transactions/[id]/book', () => { expect(body.journal_entry_id).toBe('je-new') expect(body.data.id).toBe('je-new') - expect(mockCreateJournalEntry).toHaveBeenCalledWith('user-1', { + expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', { fiscal_period_id: VALID_UUID, entry_date: '2025-01-15', description: 'Test booking', diff --git a/app/api/transactions/[id]/book/route.ts b/app/api/transactions/[id]/book/route.ts index 1323d8b5..621a22cb 100644 --- a/app/api/transactions/[id]/book/route.ts +++ b/app/api/transactions/[id]/book/route.ts @@ -49,7 +49,7 @@ export async function POST( // Create journal entry via the engine let journalEntry try { - journalEntry = await createJournalEntry(user.id, { + journalEntry = await createJournalEntry(supabase, user.id, { fiscal_period_id, entry_date, description, diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index ff8da182..83726d1e 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -156,6 +156,7 @@ describe('POST /api/transactions/[id]/categorize', () => { expect(body.journal_entry_id).toBe('je-1') expect(body.category).toBe('expense_software') expect(mockSaveUserMappingRule).toHaveBeenCalledWith( + expect.anything(), 'user-1', 'GitHub', '6200', diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 62d458cb..b8265f2c 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -245,6 +245,7 @@ export async function POST( try { const journalEntry = await createTransactionJournalEntry( + supabase, user.id, transaction as Transaction, mappingResult @@ -264,6 +265,7 @@ export async function POST( if (is_business && transaction.merchant_name) { try { await saveUserMappingRule( + supabase, user.id, transaction.merchant_name, mappingResult.debit_account, diff --git a/app/api/transactions/[id]/describe/__tests__/route.test.ts b/app/api/transactions/[id]/describe/__tests__/route.test.ts index f492c80f..a2c60190 100644 --- a/app/api/transactions/[id]/describe/__tests__/route.test.ts +++ b/app/api/transactions/[id]/describe/__tests__/route.test.ts @@ -10,10 +10,19 @@ import { // Mock init vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) -// Mock template embeddings +// Mock extension registry const mockFindSimilarTemplates = vi.fn().mockResolvedValue([]) -vi.mock('@/lib/bookkeeping/template-embeddings', () => ({ - findSimilarTemplates: (...args: unknown[]) => mockFindSimilarTemplates(...args), +vi.mock('@/lib/extensions/registry', () => ({ + extensionRegistry: { + get: vi.fn().mockReturnValue({ + id: 'ai-categorization', + name: 'AI', + version: '1.0.0', + services: { + findSimilarTemplates: (...args: unknown[]) => mockFindSimilarTemplates(...args), + }, + }), + }, })) // Mock Supabase diff --git a/app/api/transactions/[id]/describe/route.ts b/app/api/transactions/[id]/describe/route.ts index 1d9d55a5..41e08a75 100644 --- a/app/api/transactions/[id]/describe/route.ts +++ b/app/api/transactions/[id]/describe/route.ts @@ -3,7 +3,8 @@ import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' import { DescribeTransactionSchema } from '@/lib/api/schemas' -import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings' +import { extensionRegistry } from '@/lib/extensions/registry' +import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates' import type { Transaction, EntityType } from '@/types' ensureInitialized() @@ -47,12 +48,19 @@ export async function POST( const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' // Run embedding search with user description dominating the query - const templates = await findSimilarTemplates( - transaction as Transaction, - entityType, - 10, - description - ) + let templates: TemplateMatch[] + const aiExt = extensionRegistry.get('ai-categorization') + if (aiExt?.services?.findSimilarTemplates) { + templates = await aiExt.services.findSimilarTemplates( + transaction as Transaction, + entityType, + 10, + description + ) + } else { + // Fallback to keyword matching when AI extension not loaded + templates = findMatchingTemplates(transaction as Transaction, entityType) + } // Flag if top confidence is too low const needsMoreDetail = templates.length === 0 || templates[0].confidence < 0.55 diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index da811939..b7836644 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -187,6 +187,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // Verify accrual payment entry was called expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith( + expect.anything(), 'user-1', expect.objectContaining({ id: VALID_UUID }), '2024-06-15' diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 8a8d40e9..3ae526fb 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -106,6 +106,7 @@ export async function POST( if (accountingMethod === 'cash') { // Kontantmetoden: combined revenue entry with per-line VAT rates const journalEntry = await createInvoiceCashEntry( + supabase, user.id, invoice as Invoice, transaction.date, @@ -115,6 +116,7 @@ export async function POST( } else { // Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510) const journalEntry = await createInvoicePaymentJournalEntry( + supabase, user.id, invoice as Invoice, transaction.date diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts index 934fb60b..1adf8e55 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts @@ -17,7 +17,7 @@ async function ensureFiscalPeriod( userId: string, date: string ): Promise { - const existingPeriodId = await findFiscalPeriod(userId, date) + const existingPeriodId = await findFiscalPeriod(supabase, userId, date) if (existingPeriodId) return existingPeriodId const transactionDate = new Date(date) @@ -130,6 +130,7 @@ export async function POST( try { if (accountingMethod === 'cash') { const journalEntry = await createSupplierInvoiceCashEntry( + supabase, user.id, invoice as SupplierInvoice, (invoice.items || []) as SupplierInvoiceItem[], @@ -139,6 +140,7 @@ export async function POST( if (journalEntry) journalEntryId = journalEntry.id } else { const journalEntry = await createSupplierInvoicePaymentEntry( + supabase, user.id, invoice as SupplierInvoice, paymentAmount, diff --git a/app/api/transactions/batch-describe/__tests__/route.test.ts b/app/api/transactions/batch-describe/__tests__/route.test.ts index 04346cb1..efa386cb 100644 --- a/app/api/transactions/batch-describe/__tests__/route.test.ts +++ b/app/api/transactions/batch-describe/__tests__/route.test.ts @@ -170,6 +170,7 @@ describe('POST /api/transactions/batch-describe', () => { // Verify mapping rule was saved with user description expect(mockSaveUserMappingRule).toHaveBeenCalledWith( + expect.anything(), 'user-1', 'Staples', '6110', diff --git a/app/api/transactions/batch-describe/route.ts b/app/api/transactions/batch-describe/route.ts index ceaf1d08..d348296e 100644 --- a/app/api/transactions/batch-describe/route.ts +++ b/app/api/transactions/batch-describe/route.ts @@ -108,6 +108,7 @@ export async function POST(request: Request) { let journalEntryId: string | null = null try { const journalEntry = await createTransactionJournalEntry( + supabase, user.id, tx as Transaction, mappingResult @@ -152,6 +153,7 @@ export async function POST(request: Request) { const sampleTx = transactions[0] as Transaction const sampleResult = buildMappingResultFromTemplate(template, sampleTx, entityType) await saveUserMappingRule( + supabase, user.id, merchant_name, sampleResult.debit_account, diff --git a/app/api/transactions/batch-match-invoices/route.ts b/app/api/transactions/batch-match-invoices/route.ts index a3d4e9eb..a33046de 100644 --- a/app/api/transactions/batch-match-invoices/route.ts +++ b/app/api/transactions/batch-match-invoices/route.ts @@ -37,6 +37,7 @@ export async function POST() { for (const tx of transactions) { try { const bestMatch = await getBestInvoiceMatch( + supabase, user.id, tx as Transaction, 0.50 diff --git a/app/api/transactions/suggest-categories/route.ts b/app/api/transactions/suggest-categories/route.ts index 8e67ca78..ae536ff1 100644 --- a/app/api/transactions/suggest-categories/route.ts +++ b/app/api/transactions/suggest-categories/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions' +import { extensionRegistry } from '@/lib/extensions/registry' import type { Transaction, TransactionCategory, EntityType } from '@/types' // Minimum confidence threshold — below this, suggestions are considered weak @@ -195,10 +196,12 @@ export async function POST(request: Request) { ) try { - const { categorizeTransactions } = await import( - '@/extensions/general/ai-categorization' - ) - const aiResults = await categorizeTransactions(user.id, needsAiIds) + const aiExt = extensionRegistry.get('ai-categorization') + if (!aiExt?.services?.categorizeTransactions) { + console.log('[suggest-categories] AI categorization extension not loaded, skipping on-demand AI') + return NextResponse.json({ suggestions, template_suggestions }) + } + const aiResults: Array<{ transactionId: string; category: string; basAccount: string; confidence: number; reasoning: string; isPrivate?: boolean; templateId?: string }> = await aiExt.services.categorizeTransactions(user.id, needsAiIds) console.log( '[suggest-categories] AI categorization results:', diff --git a/components/chat/useChatStream.ts b/components/chat/useChatStream.ts index 5df6ed2a..7e1dd36c 100644 --- a/components/chat/useChatStream.ts +++ b/components/chat/useChatStream.ts @@ -60,7 +60,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream try { abortControllerRef.current = new AbortController() - const response = await fetch('/api/extensions/ai-chat/stream', { + const response = await fetch('/api/extensions/ext/ai-chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -175,7 +175,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream setError(null) try { - const response = await fetch(`/api/extensions/ai-chat/sessions/${id}`) + const response = await fetch(`/api/extensions/ext/ai-chat/sessions/${id}`) if (!response.ok) { throw new Error('Failed to load session') } diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index 3249ecdc..97d42047 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -241,7 +241,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard // Quick action items const quickActions = [ { href: '/invoices/new', icon: Receipt, label: 'Ny faktura', desc: 'Skapa och skicka', accent: true }, - { href: '/receipts/scan', icon: Camera, label: 'Skanna kvitto', desc: 'Fotografera & spara' }, { href: '/customers', icon: Users, label: 'Ny kund', desc: 'Lägg till kunduppgifter' }, { href: '/transactions', icon: ArrowLeftRight, label: 'Transaktioner', desc: 'Bokför' }, ] diff --git a/components/extensions/construction/ProjectCostWorkspace.tsx b/components/extensions/construction/ProjectCostWorkspace.tsx deleted file mode 100644 index 77604b8a..00000000 --- a/components/extensions/construction/ProjectCostWorkspace.tsx +++ /dev/null @@ -1,864 +0,0 @@ -'use client' - -import { useState, useMemo } from 'react' -import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' -import { useExtensionData } from '@/lib/extensions/use-extension-data' -import KPICard from '@/components/extensions/shared/KPICard' -import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' -import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog' -import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog' -import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { Progress } from '@/components/ui/progress' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' -import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table' -import { Pencil, Plus, ChevronDown, ChevronUp, Trash2, AlertTriangle, CheckCircle } from 'lucide-react' -import { cn } from '@/lib/utils' - -interface Project { - id: string - name: string - budget: number - status: 'active' | 'completed' - startDate: string -} - -interface CostEntry { - id: string - projectId: string - description: string - amount: number - date: string - category: string -} - -interface RevenueEntry { - id: string - projectId: string - description: string - amount: number - date: string -} - -const COST_CATEGORIES = ['Material', 'Arbetskraft', 'Underentreprenor', 'Maskiner', 'Ovrigt'] - -function getBudgetStatus(totalCost: number, budget: number): 'ok' | 'warning' | 'danger' { - if (budget <= 0) return 'ok' - const ratio = totalCost / budget - if (ratio >= 1) return 'danger' - if (ratio >= 0.8) return 'warning' - return 'ok' -} - -function getProgressColor(status: 'ok' | 'warning' | 'danger'): string { - switch (status) { - case 'danger': return '[&>div]:bg-red-500' - case 'warning': return '[&>div]:bg-amber-500' - default: return '' - } -} - -export default function ProjectCostWorkspace({}: WorkspaceComponentProps) { - const { data, save, remove, refresh, isLoading } = useExtensionData('construction', 'project-cost') - - // --- Date range filter --- - const now = new Date() - const [dateRange, setDateRange] = useState<{ start: string; end: string } | null>(null) - - // --- Parse data --- - const projects = useMemo(() => - data.filter(d => d.key.startsWith('project:')) - .map(d => ({ id: d.key.replace('project:', ''), ...(d.value as Omit) })) - .sort((a, b) => b.startDate.localeCompare(a.startDate)) - , [data]) - - const allCosts = useMemo(() => - data.filter(d => d.key.startsWith('cost:')) - .map(d => ({ id: d.key.replace('cost:', ''), ...(d.value as Omit) })) - .sort((a, b) => b.date.localeCompare(a.date)) - , [data]) - - const allRevenues = useMemo(() => - data.filter(d => d.key.startsWith('revenue:')) - .map(d => ({ id: d.key.replace('revenue:', ''), ...(d.value as Omit) })) - .sort((a, b) => b.date.localeCompare(a.date)) - , [data]) - - // Filtered costs/revenues based on date range - const costs = useMemo(() => { - if (!dateRange) return allCosts - return allCosts.filter(c => c.date >= dateRange.start && c.date <= dateRange.end) - }, [allCosts, dateRange]) - - const revenues = useMemo(() => { - if (!dateRange) return allRevenues - return allRevenues.filter(r => r.date >= dateRange.start && r.date <= dateRange.end) - }, [allRevenues, dateRange]) - - // --- UI state --- - const [expandedProject, setExpandedProject] = useState(null) - const [showNewProject, setShowNewProject] = useState(false) - const [newProjectName, setNewProjectName] = useState('') - const [newProjectBudget, setNewProjectBudget] = useState('') - - // Cost/Revenue entry forms - const [costDesc, setCostDesc] = useState('') - const [costAmount, setCostAmount] = useState('') - const [costCategory, setCostCategory] = useState(COST_CATEGORIES[0]) - const [revDesc, setRevDesc] = useState('') - const [revAmount, setRevAmount] = useState('') - const [isSubmitting, setIsSubmitting] = useState(false) - - // Delete confirmation state - const [deleteTarget, setDeleteTarget] = useState<{ - type: 'cost' | 'revenue' | 'project' - id: string - label: string - } | null>(null) - const [isDeleting, setIsDeleting] = useState(false) - - // Edit cost/revenue entry state - const [editEntry, setEditEntry] = useState<{ - type: 'cost' | 'revenue' - id: string - projectId: string - description: string - amount: string - date: string - category?: string - } | null>(null) - const [isSavingEdit, setIsSavingEdit] = useState(false) - - // Edit project state - const [editProject, setEditProject] = useState<{ - id: string - name: string - budget: string - } | null>(null) - const [isSavingProject, setIsSavingProject] = useState(false) - - // Complete project confirmation state - const [completeProjectId, setCompleteProjectId] = useState(null) - - // --- Computed stats --- - const projectStats = useMemo(() => { - return projects.map(p => { - const projectCosts = costs.filter(c => c.projectId === p.id) - const projectRevenues = revenues.filter(r => r.projectId === p.id) - const totalCost = projectCosts.reduce((s, c) => s + c.amount, 0) - const totalRevenue = projectRevenues.reduce((s, r) => s + r.amount, 0) - const margin = totalRevenue > 0 ? Math.round(((totalRevenue - totalCost) / totalRevenue) * 100) : 0 - const budgetUsed = p.budget > 0 ? Math.round((totalCost / p.budget) * 100) : 0 - const budgetStatus = getBudgetStatus(totalCost, p.budget) - - // Cost category breakdown - const categoryTotals = COST_CATEGORIES.map(cat => { - const catTotal = projectCosts - .filter(c => c.category === cat) - .reduce((s, c) => s + c.amount, 0) - return { - category: cat, - total: catTotal, - pct: totalCost > 0 ? Math.round((catTotal / totalCost) * 100) : 0, - } - }).filter(ct => ct.total > 0) - - return { - ...p, - totalCost, - totalRevenue, - margin, - budgetUsed, - budgetStatus, - costs: projectCosts, - revenues: projectRevenues, - categoryTotals, - } - }) - }, [projects, costs, revenues]) - - const activeProjects = projectStats.filter(p => p.status === 'active') - const completedProjects = projectStats.filter(p => p.status === 'completed') - const totalRevenue = projectStats.reduce((s, p) => s + p.totalRevenue, 0) - const totalCosts = projectStats.reduce((s, p) => s + p.totalCost, 0) - const avgMargin = totalRevenue > 0 ? Math.round(((totalRevenue - totalCosts) / totalRevenue) * 100) : 0 - - // --- Handlers --- - const handleAddProject = async () => { - if (!newProjectName.trim()) return - const id = crypto.randomUUID() - await save(`project:${id}`, { - name: newProjectName.trim(), - budget: Math.round((parseFloat(newProjectBudget) || 0) * 100) / 100, - status: 'active', - startDate: new Date().toISOString().slice(0, 10), - }) - setNewProjectName('') - setNewProjectBudget('') - setShowNewProject(false) - await refresh() - } - - const handleAddCost = async (projectId: string) => { - const amt = parseFloat(costAmount) - if (isNaN(amt) || amt <= 0) return - setIsSubmitting(true) - const id = crypto.randomUUID() - await save(`cost:${id}`, { - projectId, - description: costDesc, - amount: Math.round(amt * 100) / 100, - date: new Date().toISOString().slice(0, 10), - category: costCategory, - }) - setCostDesc('') - setCostAmount('') - await refresh() - setIsSubmitting(false) - } - - const handleAddRevenue = async (projectId: string) => { - const amt = parseFloat(revAmount) - if (isNaN(amt) || amt <= 0) return - setIsSubmitting(true) - const id = crypto.randomUUID() - await save(`revenue:${id}`, { - projectId, - description: revDesc, - amount: Math.round(amt * 100) / 100, - date: new Date().toISOString().slice(0, 10), - }) - setRevDesc('') - setRevAmount('') - await refresh() - setIsSubmitting(false) - } - - const handleDelete = async () => { - if (!deleteTarget) return - setIsDeleting(true) - if (deleteTarget.type === 'project') { - // Delete all costs and revenues for the project, then the project itself - const projectCosts = allCosts.filter(c => c.projectId === deleteTarget.id) - const projectRevenues = allRevenues.filter(r => r.projectId === deleteTarget.id) - for (const c of projectCosts) { - await remove(`cost:${c.id}`) - } - for (const r of projectRevenues) { - await remove(`revenue:${r.id}`) - } - await remove(`project:${deleteTarget.id}`) - } else if (deleteTarget.type === 'cost') { - await remove(`cost:${deleteTarget.id}`) - } else { - await remove(`revenue:${deleteTarget.id}`) - } - await refresh() - setIsDeleting(false) - setDeleteTarget(null) - } - - const handleSaveEditEntry = async () => { - if (!editEntry) return - const amt = parseFloat(editEntry.amount) - if (isNaN(amt) || amt <= 0) return - setIsSavingEdit(true) - if (editEntry.type === 'cost') { - await save(`cost:${editEntry.id}`, { - projectId: editEntry.projectId, - description: editEntry.description, - amount: Math.round(amt * 100) / 100, - date: editEntry.date, - category: editEntry.category || COST_CATEGORIES[0], - }) - } else { - await save(`revenue:${editEntry.id}`, { - projectId: editEntry.projectId, - description: editEntry.description, - amount: Math.round(amt * 100) / 100, - date: editEntry.date, - }) - } - await refresh() - setIsSavingEdit(false) - setEditEntry(null) - } - - const handleSaveEditProject = async () => { - if (!editProject) return - const project = projects.find(p => p.id === editProject.id) - if (!project) return - setIsSavingProject(true) - await save(`project:${editProject.id}`, { - name: editProject.name.trim(), - budget: Math.round((parseFloat(editProject.budget) || 0) * 100) / 100, - status: project.status, - startDate: project.startDate, - }) - await refresh() - setIsSavingProject(false) - setEditProject(null) - } - - const handleCompleteProject = async () => { - if (!completeProjectId) return - const project = projects.find(p => p.id === completeProjectId) - if (!project) return - await save(`project:${completeProjectId}`, { - name: project.name, - budget: project.budget, - status: 'completed' as const, - startDate: project.startDate, - }) - await refresh() - setCompleteProjectId(null) - } - - if (isLoading) return - - // --- Render helper for budget alert banner --- - const renderBudgetAlert = (p: (typeof projectStats)[number]) => { - if (p.budget <= 0) return null - if (p.budgetStatus === 'danger') { - return ( -
- - Kostnaden overskrider budgeten ({p.budgetUsed}% anvant) -
- ) - } - if (p.budgetStatus === 'warning') { - return ( -
- - Budgetvarning: {p.budgetUsed}% av budgeten anvand -
- ) - } - return null - } - - // --- Render helper for category breakdown --- - const renderCategoryBreakdown = (categoryTotals: { category: string; total: number; pct: number }[]) => { - if (categoryTotals.length === 0) return null - return ( -
-

Kostnadsfordelning per kategori

-
- - - - Kategori - Belopp - Andel - - - - {categoryTotals.map(ct => ( - - {ct.category} - - {ct.total.toLocaleString('sv-SE')} kr - - {ct.pct}% - - ))} - -
-
-
- ) - } - - // --- Render project card for the Projects tab --- - const renderProjectCard = (p: (typeof projectStats)[number]) => { - const isExpanded = expandedProject === p.id - return ( - - -
-
setExpandedProject(isExpanded ? null : p.id)} - > - - {p.name} - - {p.status === 'active' ? 'Aktiv' : 'Avslutad'} - - -
-
- - -
setExpandedProject(isExpanded ? null : p.id)} - > - {isExpanded ? : } -
-
-
-
- {isExpanded && ( - - {/* Budget alert banner */} - {renderBudgetAlert(p)} - - {/* Stats row */} -
-
-

Kostnad

-

{p.totalCost.toLocaleString('sv-SE')} kr

-
-
-

Intakt

-

{p.totalRevenue.toLocaleString('sv-SE')} kr

-
-
-

Marginal

-

{p.margin}%

-
-
- - {/* Budget progress */} - {p.budget > 0 && ( -
-
- Budget anvand - {p.budgetUsed}% av {p.budget.toLocaleString('sv-SE')} kr -
- -
- )} - - {/* Category breakdown */} - {renderCategoryBreakdown(p.categoryTotals)} - - {/* Cost entries */} -
-

Kostnader

-
- setCostDesc(e.target.value)} className="max-w-xs" /> - setCostAmount(e.target.value)} className="w-28" /> - - -
- {p.costs.length > 0 && ( -
- - - - Datum - Beskrivning - Kategori - Belopp - - - - - {p.costs.map(c => ( - - {c.date} - {c.description} - {c.category} - {c.amount.toLocaleString('sv-SE')} kr - -
- - -
-
-
- ))} -
-
-
- )} -
- - {/* Revenue entries */} -
-

Intakter

-
- setRevDesc(e.target.value)} className="max-w-xs" /> - setRevAmount(e.target.value)} className="w-28" /> - -
- {p.revenues.length > 0 && ( -
- - - - Datum - Beskrivning - Belopp - - - - - {p.revenues.map(r => ( - - {r.date} - {r.description} - {r.amount.toLocaleString('sv-SE')} kr - -
- - -
-
-
- ))} -
-
-
- )} -
- - {/* Complete project button (active only) */} - {p.status === 'active' && ( -
- -
- )} -
- )} -
- ) - } - - return ( -
- - - Oversikt - Projekt - - - - setDateRange({ start, end })} /> - -
- - - - -
- - {/* Active projects */} - {activeProjects.length > 0 && ( -
-

Aktiva projekt

- {activeProjects.map(p => ( - - - {/* Budget alert */} - {renderBudgetAlert(p)} - -
-
-

{p.name}

- Aktiv -
-
- Marginal: - {p.margin}% -
-
-
- Kostnad: {p.totalCost.toLocaleString('sv-SE')} kr - Intakt: {p.totalRevenue.toLocaleString('sv-SE')} kr - {p.budget > 0 && Budget: {p.budget.toLocaleString('sv-SE')} kr} -
- {p.budget > 0 && ( - - )} -
-
- ))} -
- )} - - {/* Completed projects */} - {completedProjects.length > 0 && ( -
-

Avslutade projekt

- {completedProjects.map(p => ( - - -
-
-

{p.name}

- Avslutad -
-
-

= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' - )}> - {p.margin}% marginal -

-
-
-
- Kostnad: {p.totalCost.toLocaleString('sv-SE')} kr - Intakt: {p.totalRevenue.toLocaleString('sv-SE')} kr - Resultat: {(Math.round((p.totalRevenue - p.totalCost) * 100) / 100).toLocaleString('sv-SE')} kr -
-
-
- ))} -
- )} -
- - - {!showNewProject ? ( - - ) : ( - - -
- setNewProjectName(e.target.value)} className="max-w-xs" /> - setNewProjectBudget(e.target.value)} className="max-w-xs" /> - - -
-
-
- )} - - {activeProjects.length > 0 && ( -
-

Aktiva projekt

- {activeProjects.map(p => renderProjectCard(p))} -
- )} - - {completedProjects.length > 0 && ( -
-

Avslutade projekt

- {completedProjects.map(p => renderProjectCard(p))} -
- )} -
-
- - {/* Delete confirmation dialog */} - { if (!open) setDeleteTarget(null) }} - title={ - deleteTarget?.type === 'project' - ? 'Ta bort projekt' - : deleteTarget?.type === 'cost' - ? 'Ta bort kostnad' - : 'Ta bort intakt' - } - description={ - deleteTarget?.type === 'project' - ? `Vill du ta bort projektet "${deleteTarget?.label}"? Alla kostnader och intakter kopplade till projektet tas ocksa bort. Atgarden kan inte angras.` - : `Vill du ta bort "${deleteTarget?.label}"? Atgarden kan inte angras.` - } - onConfirm={handleDelete} - isDeleting={isDeleting} - /> - - {/* Edit cost/revenue entry dialog */} - { if (!open) setEditEntry(null) }} - title={editEntry?.type === 'cost' ? 'Redigera kostnad' : 'Redigera intakt'} - description="Andra uppgifterna och klicka Spara." - onSave={handleSaveEditEntry} - isSaving={isSavingEdit} - > - {editEntry && ( -
-
- - setEditEntry({ ...editEntry, description: e.target.value })} - /> -
-
- - setEditEntry({ ...editEntry, amount: e.target.value })} - /> -
-
- - setEditEntry({ ...editEntry, date: e.target.value })} - /> -
- {editEntry.type === 'cost' && ( -
- - -
- )} -
- )} -
- - {/* Edit project dialog */} - { if (!open) setEditProject(null) }} - title="Redigera projekt" - description="Andra projektnamn och budget." - onSave={handleSaveEditProject} - isSaving={isSavingProject} - > - {editProject && ( -
-
- - setEditProject({ ...editProject, name: e.target.value })} - /> -
-
- - setEditProject({ ...editProject, budget: e.target.value })} - /> -
-
- )} -
- - {/* Complete project confirmation dialog */} - { if (!open) setCompleteProjectId(null) }} - title="Avsluta projekt" - description={`Vill du markera projektet som avslutat? Projektet flyttas till "Avslutade" och kan inte ateraktiveras.`} - onConfirm={handleCompleteProject} - /> -
- ) -} diff --git a/components/extensions/construction/RotCalculatorWorkspace.tsx b/components/extensions/construction/RotCalculatorWorkspace.tsx deleted file mode 100644 index f7f707d5..00000000 --- a/components/extensions/construction/RotCalculatorWorkspace.tsx +++ /dev/null @@ -1,613 +0,0 @@ -'use client' - -import { useState, useMemo, useCallback } from 'react' -import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' -import { useExtensionData } from '@/lib/extensions/use-extension-data' -import KPICard from '@/components/extensions/shared/KPICard' -import DataEntryForm from '@/components/extensions/shared/DataEntryForm' -import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' -import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog' -import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog' -import { validateSwedishPersonalNumber } from '@/lib/extensions/validation' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { Progress } from '@/components/ui/progress' -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' -import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table' -import { Card, CardContent } from '@/components/ui/card' -import { Pencil, Trash2, Plus, Download, Check } from 'lucide-react' - -const MAX_ROT_YEARLY = 50000 -const ROT_RATE = 0.30 - -interface Job { - id: string - customerId: string - customerName: string - description: string - total: number - material: number - labor: number - rotDeduction: number - date: string - status: 'draft' | 'completed' -} - -interface Customer { - id: string - name: string - personalNumber: string -} - -function buildYearOptions(): number[] { - const current = new Date().getFullYear() - const years: number[] = [] - for (let y = current; y >= current - 5; y--) { - years.push(y) - } - return years -} - -export default function RotCalculatorWorkspace({}: WorkspaceComponentProps) { - const { data, save, remove, refresh, isLoading } = useExtensionData('construction', 'rot-calculator') - - const customers = useMemo(() => - data.filter(d => d.key.startsWith('customer:')) - .map(d => ({ - id: d.key.replace('customer:', ''), - ...(d.value as { name: string; personalNumber: string }), - })) - , [data]) - - const allJobs = useMemo(() => - data.filter(d => d.key.startsWith('job:')) - .map(d => ({ id: d.key.replace('job:', ''), ...(d.value as Omit) })) - .sort((a, b) => b.date.localeCompare(a.date)) - , [data]) - - // Year filter - const currentYear = new Date().getFullYear() - const [selectedYear, setSelectedYear] = useState(String(currentYear)) - const yearOptions = useMemo(() => buildYearOptions(), []) - - const jobs = useMemo(() => - allJobs.filter(j => j.date.startsWith(selectedYear)) - , [allJobs, selectedYear]) - - // Calculator form - const [selectedCustomerId, setCustomerId] = useState('') - const customerId = selectedCustomerId || (customers.length > 0 ? customers[0].id : '') - const [description, setDescription] = useState('') - const [total, setTotal] = useState('') - const [material, setMaterial] = useState('') - const [isSubmitting, setIsSubmitting] = useState(false) - - // New customer form - const [newCustomerName, setNewCustomerName] = useState('') - const [newCustomerPnr, setNewCustomerPnr] = useState('') - const newCustomerPnrError = useMemo(() => { - if (!newCustomerPnr.trim()) return null - return validateSwedishPersonalNumber(newCustomerPnr.trim()) - }, [newCustomerPnr]) - const canAddCustomer = newCustomerName.trim().length > 0 && !newCustomerPnrError - - // Edit customer dialog - const [editingCustomer, setEditingCustomer] = useState(null) - const [editCustomerName, setEditCustomerName] = useState('') - const [editCustomerPnr, setEditCustomerPnr] = useState('') - const [isSavingCustomer, setIsSavingCustomer] = useState(false) - const editCustomerPnrError = useMemo(() => { - if (!editCustomerPnr.trim()) return null - return validateSwedishPersonalNumber(editCustomerPnr.trim()) - }, [editCustomerPnr]) - - // Edit job dialog - const [editingJob, setEditingJob] = useState(null) - const [editJobCustomerId, setEditJobCustomerId] = useState('') - const [editJobDescription, setEditJobDescription] = useState('') - const [editJobTotal, setEditJobTotal] = useState('') - const [editJobMaterial, setEditJobMaterial] = useState('') - const [isSavingJob, setIsSavingJob] = useState(false) - - // Delete job dialog - const [deletingJobId, setDeletingJobId] = useState(null) - const [isDeletingJob, setIsDeletingJob] = useState(false) - - // Per-customer used quota for selected year (only completed jobs count) - const customerYearlyUsed = useMemo(() => { - const map = new Map() - for (const job of jobs) { - if (job.status === 'completed') { - map.set(job.customerId, (map.get(job.customerId) ?? 0) + job.rotDeduction) - } - } - return map - }, [jobs]) - - // Calculate ROT for current form input - const totalNum = parseFloat(total) || 0 - const materialNum = parseFloat(material) || 0 - const labor = Math.max(totalNum - materialNum, 0) - const usedQuota = customerYearlyUsed.get(customerId) ?? 0 - const remainingQuota = Math.max(MAX_ROT_YEARLY - usedQuota, 0) - const rotDeduction = Math.round(Math.min(labor * ROT_RATE, remainingQuota) * 100) / 100 - const customerPays = Math.round((totalNum - rotDeduction) * 100) / 100 - - // Calculate ROT deduction respecting quota for a specific customer - const calculateRotDeduction = useCallback((custId: string, laborAmount: number, excludeJobId?: string) => { - let used = 0 - for (const job of allJobs) { - if ( - job.customerId === custId && - job.status === 'completed' && - job.date.startsWith(selectedYear) && - job.id !== excludeJobId - ) { - used += job.rotDeduction - } - } - const remaining = Math.max(MAX_ROT_YEARLY - used, 0) - return Math.round(Math.min(laborAmount * ROT_RATE, remaining) * 100) / 100 - }, [allJobs, selectedYear]) - - const handleSubmitJob = async (e: React.FormEvent) => { - e.preventDefault() - if (!customerId || totalNum <= 0) return - setIsSubmitting(true) - const customer = customers.find(c => c.id === customerId) - const id = crypto.randomUUID() - await save(`job:${id}`, { - customerId, - customerName: customer?.name ?? '', - description, - total: totalNum, - material: materialNum, - labor, - rotDeduction, - date: new Date().toISOString().slice(0, 10), - status: 'draft', - }) - setDescription('') - setTotal('') - setMaterial('') - await refresh() - setIsSubmitting(false) - } - - const handleAddCustomer = async () => { - if (!canAddCustomer) return - const id = crypto.randomUUID() - await save(`customer:${id}`, { name: newCustomerName.trim(), personalNumber: newCustomerPnr.trim() }) - setNewCustomerName('') - setNewCustomerPnr('') - await refresh() - } - - const openEditCustomer = (cust: Customer) => { - setEditingCustomer(cust) - setEditCustomerName(cust.name) - setEditCustomerPnr(cust.personalNumber) - } - - const handleSaveCustomer = async () => { - if (!editingCustomer || !editCustomerName.trim() || editCustomerPnrError) return - setIsSavingCustomer(true) - await save(`customer:${editingCustomer.id}`, { - name: editCustomerName.trim(), - personalNumber: editCustomerPnr.trim(), - }) - // Update customerName on all jobs belonging to this customer - const customerJobs = allJobs.filter(j => j.customerId === editingCustomer.id) - for (const job of customerJobs) { - await save(`job:${job.id}`, { - customerId: job.customerId, - customerName: editCustomerName.trim(), - description: job.description, - total: job.total, - material: job.material, - labor: job.labor, - rotDeduction: job.rotDeduction, - date: job.date, - status: job.status, - }) - } - await refresh() - setIsSavingCustomer(false) - } - - const openEditJob = (job: Job) => { - setEditingJob(job) - setEditJobCustomerId(job.customerId) - setEditJobDescription(job.description) - setEditJobTotal(String(job.total)) - setEditJobMaterial(String(job.material)) - } - - const handleSaveJob = async () => { - if (!editingJob) return - const editTotalNum = parseFloat(editJobTotal) || 0 - const editMaterialNum = parseFloat(editJobMaterial) || 0 - if (editTotalNum <= 0) return - setIsSavingJob(true) - const editLabor = Math.max(editTotalNum - editMaterialNum, 0) - const newRot = calculateRotDeduction(editJobCustomerId, editLabor, editingJob.id) - const customer = customers.find(c => c.id === editJobCustomerId) - await save(`job:${editingJob.id}`, { - customerId: editJobCustomerId, - customerName: customer?.name ?? editingJob.customerName, - description: editJobDescription, - total: editTotalNum, - material: editMaterialNum, - labor: editLabor, - rotDeduction: newRot, - date: editingJob.date, - status: editingJob.status, - }) - await refresh() - setIsSavingJob(false) - } - - const handleDeleteJob = async () => { - if (!deletingJobId) return - setIsDeletingJob(true) - await remove(`job:${deletingJobId}`) - await refresh() - setIsDeletingJob(false) - setDeletingJobId(null) - } - - const handleMarkCompleted = async (job: Job) => { - const rot = calculateRotDeduction(job.customerId, job.labor, job.id) - await save(`job:${job.id}`, { - customerId: job.customerId, - customerName: job.customerName, - description: job.description, - total: job.total, - material: job.material, - labor: job.labor, - rotDeduction: rot, - date: job.date, - status: 'completed', - }) - await refresh() - } - - const handleExportCsv = () => { - const completedJobs = jobs.filter(j => j.status === 'completed') - const header = 'Personnummer;Kundnamn;Arbetskostnad;ROTAvdrag;Datum' - const rows = completedJobs.map(job => { - const cust = customers.find(c => c.id === job.customerId) - const pnr = cust?.personalNumber ?? '' - return `${pnr};${job.customerName};${job.labor};${job.rotDeduction};${job.date}` - }) - const csv = [header, ...rows].join('\n') - const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }) - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = `rot-avdrag-${selectedYear}.csv` - link.click() - URL.revokeObjectURL(url) - } - - if (isLoading) return - - const completedJobCount = jobs.filter(j => j.status === 'completed').length - const totalRot = jobs.filter(j => j.status === 'completed').reduce((s, j) => s + j.rotDeduction, 0) - const totalRevenue = jobs.reduce((s, j) => s + j.total, 0) - - return ( -
- - - Kalkylator - Kunder - Jobb - - - -
- - -
- - {customers.length === 0 ? ( -
-

- Lagg till kunder under fliken "Kunder" for att borja berakna ROT-avdrag. -

-
- ) : ( - <> - -
-
- - -
-
- - setDescription(e.target.value)} /> -
-
- - setTotal(e.target.value)} /> -
-
- - setMaterial(e.target.value)} /> -
-
- - {totalNum > 0 && ( - - -
- Arbetskostnad: - {labor.toLocaleString('sv-SE')} kr - ROT-avdrag (30%): - -{rotDeduction.toLocaleString('sv-SE')} kr - Kunden betalar: - {customerPays.toLocaleString('sv-SE')} kr - Kvarvarande kvot: - {Math.max(remainingQuota - rotDeduction, 0).toLocaleString('sv-SE')} kr -
-
-
- )} -
- - )} -
- - -
- setNewCustomerName(e.target.value)} className="max-w-xs" /> -
- setNewCustomerPnr(e.target.value)} - className="max-w-xs" - /> - {newCustomerPnrError && ( -

{newCustomerPnrError}

- )} -
- -
- - {customers.length === 0 ? ( -

Inga kunder tillagda annu.

- ) : ( -
- {customers.map(cust => { - const used = customerYearlyUsed.get(cust.id) ?? 0 - const pct = Math.min(Math.round((used / MAX_ROT_YEARLY) * 100), 100) - return ( - - -
-
-

{cust.name}

- {cust.personalNumber && ( -

{cust.personalNumber}

- )} -
-
- - {used.toLocaleString('sv-SE')} / {MAX_ROT_YEARLY.toLocaleString('sv-SE')} kr - - -
-
- -
-
- ) - })} -
- )} -
- - -
-
- - -
- {completedJobCount > 0 && ( - - )} -
- -
- - - -
- - {jobs.length === 0 ? ( -

Inga jobb registrerade for {selectedYear}.

- ) : ( -
- - - - Datum - Kund - Beskrivning - Totalt - ROT-avdrag - Status - - - - - {jobs.map(job => ( - - {job.date} - {job.customerName} - {job.description} - {job.total.toLocaleString('sv-SE')} kr - {job.rotDeduction.toLocaleString('sv-SE')} kr - - - {job.status === 'completed' ? 'Klar' : 'Utkast'} - - - -
- {job.status === 'draft' && ( - - )} - - -
-
-
- ))} -
-
-
- )} -
-
- - {/* Edit Customer Dialog */} - { if (!open) setEditingCustomer(null) }} - title="Redigera kund" - description="Uppdatera kunduppgifter." - onSave={handleSaveCustomer} - isSaving={isSavingCustomer} - > -
- - setEditCustomerName(e.target.value)} /> -
-
- - setEditCustomerPnr(e.target.value)} - /> - {editCustomerPnrError && ( -

{editCustomerPnrError}

- )} -
-
- - {/* Edit Job Dialog */} - { if (!open) setEditingJob(null) }} - title="Redigera jobb" - description="Uppdatera jobbdetaljer. ROT-avdrag beraknas om automatiskt." - onSave={handleSaveJob} - isSaving={isSavingJob} - > -
- - -
-
- - setEditJobDescription(e.target.value)} /> -
-
- - setEditJobTotal(e.target.value)} /> -
-
- - setEditJobMaterial(e.target.value)} /> -
- {(() => { - const editTotalNum = parseFloat(editJobTotal) || 0 - const editMaterialNum = parseFloat(editJobMaterial) || 0 - const editLabor = Math.max(editTotalNum - editMaterialNum, 0) - const editRot = editingJob - ? calculateRotDeduction(editJobCustomerId, editLabor, editingJob.id) - : 0 - return editTotalNum > 0 ? ( -
-
- Arbetskostnad: - {editLabor.toLocaleString('sv-SE')} kr -
-
- ROT-avdrag (30%): - -{editRot.toLocaleString('sv-SE')} kr -
-
- ) : null - })()} -
- - {/* Delete Job Confirmation */} - { if (!open) setDeletingJobId(null) }} - title="Ta bort jobb" - description="Ar du saker pa att du vill ta bort detta jobb? Kundens anvanda kvot minskar om jobbet var slutfort." - onConfirm={handleDeleteJob} - isDeleting={isDeletingJob} - /> -
- ) -} diff --git a/components/extensions/ecommerce/MultichannelRevenueWorkspace.tsx b/components/extensions/ecommerce/MultichannelRevenueWorkspace.tsx deleted file mode 100644 index f4b59869..00000000 --- a/components/extensions/ecommerce/MultichannelRevenueWorkspace.tsx +++ /dev/null @@ -1,989 +0,0 @@ -'use client' - -import { useState, useMemo, useCallback } from 'react' -import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' -import { useExtensionData } from '@/lib/extensions/use-extension-data' -import KPICard from '@/components/extensions/shared/KPICard' -import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter' -import SetupPrompt from '@/components/extensions/shared/SetupPrompt' -import DataEntryForm from '@/components/extensions/shared/DataEntryForm' -import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' -import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog' -import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' -import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table' -import { - Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, -} from '@/components/ui/dialog' -import { Pencil, Plus, Trash2, ArrowUp, ArrowDown, Minus, TrendingUp } from 'lucide-react' -import { cn } from '@/lib/utils' - -interface Channel { - name: string - color: string -} - -interface RevenueEntry { - id: string - month: string - channel: string - revenue: number - orderCount: number -} - -const DEFAULT_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'] - -const COLOR_PRESETS = [ - '#3b82f6', '#10b981', '#f59e0b', '#ef4444', - '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16', -] - -type SortMode = 'revenue' | 'growth' - -function formatCurrency(value: number): string { - return Math.round(value * 100) / 100 === 0 - ? '0' - : (Math.round(value * 100) / 100).toLocaleString('sv-SE') -} - -function formatAOV(revenue: number, orders: number): string { - if (orders <= 0) return '-' - return Math.round(revenue / orders).toLocaleString('sv-SE') -} - -function GrowthIndicator({ current, previous }: { current: number; previous: number }) { - if (previous === 0 && current === 0) { - return ( - - - 0% - - ) - } - if (previous === 0) { - return ( - - - Ny - - ) - } - const pctChange = Math.round(((current - previous) / previous) * 1000) / 10 - if (pctChange === 0) { - return ( - - - 0% - - ) - } - const improving = pctChange > 0 - return ( - - {improving - ? - : - } - {pctChange > 0 ? '+' : ''}{pctChange}% - - ) -} - -export default function MultichannelRevenueWorkspace({}: WorkspaceComponentProps) { - const now = new Date() - const [dateRange, setDateRange] = useState({ - start: new Date(now.getFullYear(), 0, 1).toISOString().slice(0, 10), - end: new Date(now.getFullYear(), 11, 31).toISOString().slice(0, 10), - }) - - const { data, save, remove, refresh, isLoading } = useExtensionData('ecommerce', 'multichannel-revenue') - - const channels = useMemo(() => { - const s = data.find(d => d.key === 'settings')?.value as { channels?: Channel[] } | undefined - return s?.channels ?? [] - }, [data]) - - // All entries (unfiltered by date, needed for previous period comparison) - const allEntries = useMemo(() => - data.filter(d => d.key.startsWith('entry:')) - .map(d => ({ - id: d.key.replace('entry:', ''), - ...(d.value as Omit), - })) - , [data]) - - // Entries filtered to current date range - const entries = useMemo(() => - allEntries - .filter(e => { - const eStart = e.month + '-01' - const eEnd = e.month + '-31' - return eEnd >= dateRange.start && eStart <= dateRange.end - }) - .sort((a, b) => b.month.localeCompare(a.month)) - , [allEntries, dateRange]) - - // Previous year entries for the same period - const prevYearEntries = useMemo(() => { - const startDate = new Date(dateRange.start + 'T00:00:00') - const endDate = new Date(dateRange.end + 'T00:00:00') - const prevStart = new Date(startDate) - prevStart.setFullYear(prevStart.getFullYear() - 1) - const prevEnd = new Date(endDate) - prevEnd.setFullYear(prevEnd.getFullYear() - 1) - const prevStartStr = prevStart.toISOString().slice(0, 10) - const prevEndStr = prevEnd.toISOString().slice(0, 10) - return allEntries.filter(e => { - const eStart = e.month + '-01' - const eEnd = e.month + '-31' - return eEnd >= prevStartStr && eStart <= prevEndStr - }) - }, [allEntries, dateRange]) - - // Form state - const [entryMonth, setEntryMonth] = useState(now.toISOString().slice(0, 7)) - const [selectedChannel, setEntryChannel] = useState('') - const entryChannel = selectedChannel || (channels.length > 0 ? channels[0].name : '') - const [entryRevenue, setEntryRevenue] = useState('') - const [entryOrders, setEntryOrders] = useState('') - const [isSubmitting, setIsSubmitting] = useState(false) - - // Channel management - const [newChannelName, setNewChannelName] = useState('') - const [sortMode, setSortMode] = useState('revenue') - - // Duplicate confirmation dialog state - const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) - const [pendingEntry, setPendingEntry] = useState<{ - month: string; channel: string; revenue: number; orderCount: number; existingId: string - } | null>(null) - - // Edit entry dialog state - const [editDialogOpen, setEditDialogOpen] = useState(false) - const [editingEntry, setEditingEntry] = useState(null) - const [editMonth, setEditMonth] = useState('') - const [editChannel, setEditChannel] = useState('') - const [editRevenue, setEditRevenue] = useState('') - const [editOrders, setEditOrders] = useState('') - const [isSavingEdit, setIsSavingEdit] = useState(false) - - // Delete entry dialog state - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) - const [deletingEntryId, setDeletingEntryId] = useState(null) - const [isDeleting, setIsDeleting] = useState(false) - - // Rename channel dialog state - const [renameDialogOpen, setRenameDialogOpen] = useState(false) - const [renamingChannel, setRenamingChannel] = useState(null) - const [newName, setNewName] = useState('') - const [isSavingRename, setIsSavingRename] = useState(false) - - // Color picker state - const [colorPickerChannel, setColorPickerChannel] = useState(null) - - // ---- Computed values ---- - - const totalRevenue = entries.reduce((s, e) => s + e.revenue, 0) - const totalOrders = entries.reduce((s, e) => s + e.orderCount, 0) - const overallAOV = totalOrders > 0 ? Math.round(totalRevenue / totalOrders) : 0 - - const prevYearTotalRevenue = prevYearEntries.reduce((s, e) => s + e.revenue, 0) - - // Channel totals for current period - const channelTotals = useMemo(() => { - const map = new Map() - for (const e of entries) { - const existing = map.get(e.channel) ?? { revenue: 0, orders: 0 } - existing.revenue += e.revenue - existing.orders += e.orderCount - map.set(e.channel, existing) - } - return Array.from(map.entries()) - .map(([channel, d]) => ({ channel, ...d })) - }, [entries]) - - // Channel totals for previous year period - const prevYearChannelTotals = useMemo(() => { - const map = new Map() - for (const e of prevYearEntries) { - const existing = map.get(e.channel) ?? { revenue: 0, orders: 0 } - existing.revenue += e.revenue - existing.orders += e.orderCount - map.set(e.channel, existing) - } - return map - }, [prevYearEntries]) - - // Growth rate per channel - const channelGrowth = useMemo(() => { - const growth = new Map() - for (const ct of channelTotals) { - const prev = prevYearChannelTotals.get(ct.channel) - const prevRev = prev?.revenue ?? 0 - if (prevRev > 0) { - growth.set(ct.channel, ((ct.revenue - prevRev) / prevRev) * 100) - } else if (ct.revenue > 0) { - growth.set(ct.channel, Infinity) // New channel - } else { - growth.set(ct.channel, 0) - } - } - return growth - }, [channelTotals, prevYearChannelTotals]) - - // Sorted channel totals based on sort mode - const sortedChannelTotals = useMemo(() => { - const sorted = [...channelTotals] - if (sortMode === 'growth') { - sorted.sort((a, b) => { - const growthA = channelGrowth.get(a.channel) ?? 0 - const growthB = channelGrowth.get(b.channel) ?? 0 - // Infinity (new channels) goes to the top - if (growthA === Infinity && growthB !== Infinity) return -1 - if (growthB === Infinity && growthA !== Infinity) return 1 - return growthB - growthA - }) - } else { - sorted.sort((a, b) => b.revenue - a.revenue) - } - return sorted - }, [channelTotals, sortMode, channelGrowth]) - - const bestChannel = useMemo(() => { - const sorted = [...channelTotals].sort((a, b) => b.revenue - a.revenue) - return sorted[0]?.channel ?? '-' - }, [channelTotals]) - - // Monthly comparison (months as rows, channels as columns) - const monthlyComparison = useMemo(() => { - const monthMap = new Map>() - for (const e of entries) { - if (!monthMap.has(e.month)) monthMap.set(e.month, new Map()) - const channelMap = monthMap.get(e.month)! - const existing = channelMap.get(e.channel) ?? { revenue: 0, orders: 0 } - existing.revenue += e.revenue - existing.orders += e.orderCount - channelMap.set(e.channel, existing) - } - return Array.from(monthMap.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([month, channelData]) => ({ - month, - channels: Object.fromEntries( - Array.from(channelData.entries()).map(([ch, d]) => [ch, d]) - ) as Record, - total: Array.from(channelData.values()).reduce((s, v) => s + v.revenue, 0), - totalOrders: Array.from(channelData.values()).reduce((s, v) => s + v.orders, 0), - })) - }, [entries]) - - // Channel bar chart (CSS-based) - const maxChannelRevenue = Math.max(...sortedChannelTotals.map(c => c.revenue), 1) - - // ---- Handlers ---- - - const findDuplicateEntry = useCallback((month: string, channel: string) => { - return allEntries.find(e => e.month === month && e.channel === channel) ?? null - }, [allEntries]) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - const rev = parseFloat(entryRevenue) - const orders = parseInt(entryOrders) || 0 - if (isNaN(rev) || rev <= 0 || !entryChannel) return - - // Check for duplicate - const existing = findDuplicateEntry(entryMonth, entryChannel) - if (existing) { - setPendingEntry({ - month: entryMonth, - channel: entryChannel, - revenue: rev, - orderCount: orders, - existingId: existing.id, - }) - setDuplicateDialogOpen(true) - return - } - - setIsSubmitting(true) - const id = crypto.randomUUID() - await save(`entry:${id}`, { - month: entryMonth, - channel: entryChannel, - revenue: rev, - orderCount: orders, - }) - setEntryRevenue('') - setEntryOrders('') - await refresh() - setIsSubmitting(false) - } - - const handleDuplicateUpdate = async () => { - if (!pendingEntry) return - setIsSubmitting(true) - await save(`entry:${pendingEntry.existingId}`, { - month: pendingEntry.month, - channel: pendingEntry.channel, - revenue: pendingEntry.revenue, - orderCount: pendingEntry.orderCount, - }) - setEntryRevenue('') - setEntryOrders('') - setDuplicateDialogOpen(false) - setPendingEntry(null) - await refresh() - setIsSubmitting(false) - } - - const handleDuplicateCreateNew = async () => { - if (!pendingEntry) return - setIsSubmitting(true) - const id = crypto.randomUUID() - await save(`entry:${id}`, { - month: pendingEntry.month, - channel: pendingEntry.channel, - revenue: pendingEntry.revenue, - orderCount: pendingEntry.orderCount, - }) - setEntryRevenue('') - setEntryOrders('') - setDuplicateDialogOpen(false) - setPendingEntry(null) - await refresh() - setIsSubmitting(false) - } - - const handleAddChannel = async () => { - if (!newChannelName.trim()) return - const color = DEFAULT_COLORS[channels.length % DEFAULT_COLORS.length] - const updated = [...channels, { name: newChannelName.trim(), color }] - await save('settings', { channels: updated }) - setNewChannelName('') - } - - const handleRemoveChannel = async (name: string) => { - const updated = channels.filter(c => c.name !== name) - await save('settings', { channels: updated }) - } - - const handleChangeChannelColor = async (channelName: string, color: string) => { - const updated = channels.map(c => - c.name === channelName ? { ...c, color } : c - ) - await save('settings', { channels: updated }) - setColorPickerChannel(null) - } - - const handleStartRename = (channelName: string) => { - setRenamingChannel(channelName) - setNewName(channelName) - setRenameDialogOpen(true) - } - - const handleRenameChannel = async () => { - if (!renamingChannel || !newName.trim() || newName.trim() === renamingChannel) return - setIsSavingRename(true) - const trimmedName = newName.trim() - - // Update channel settings - const updatedChannels = channels.map(c => - c.name === renamingChannel ? { ...c, name: trimmedName } : c - ) - await save('settings', { channels: updatedChannels }) - - // Update all entries that reference the old channel name - const entriesToUpdate = allEntries.filter(e => e.channel === renamingChannel) - for (const entry of entriesToUpdate) { - await save(`entry:${entry.id}`, { - month: entry.month, - channel: trimmedName, - revenue: entry.revenue, - orderCount: entry.orderCount, - }) - } - - setIsSavingRename(false) - setRenameDialogOpen(false) - setRenamingChannel(null) - setNewName('') - await refresh() - } - - const handleStartEdit = (entry: RevenueEntry) => { - setEditingEntry(entry) - setEditMonth(entry.month) - setEditChannel(entry.channel) - setEditRevenue(String(entry.revenue)) - setEditOrders(String(entry.orderCount)) - setEditDialogOpen(true) - } - - const handleSaveEdit = async () => { - if (!editingEntry) return - const rev = parseFloat(editRevenue) - const orders = parseInt(editOrders) || 0 - if (isNaN(rev) || rev <= 0 || !editChannel) return - setIsSavingEdit(true) - await save(`entry:${editingEntry.id}`, { - month: editMonth, - channel: editChannel, - revenue: rev, - orderCount: orders, - }) - setIsSavingEdit(false) - setEditDialogOpen(false) - setEditingEntry(null) - await refresh() - } - - const handleStartDelete = (entryId: string) => { - setDeletingEntryId(entryId) - setDeleteDialogOpen(true) - } - - const handleConfirmDelete = async () => { - if (!deletingEntryId) return - setIsDeleting(true) - await remove(`entry:${deletingEntryId}`) - setIsDeleting(false) - setDeleteDialogOpen(false) - setDeletingEntryId(null) - } - - const handleSetup = async (values: Record) => { - const names = values.channels.split(',').map(n => n.trim()).filter(Boolean) - const channelList = names.map((name, i) => ({ - name, - color: DEFAULT_COLORS[i % DEFAULT_COLORS.length], - })) - await save('settings', { channels: channelList }) - } - - if (isLoading) return - - if (channels.length === 0) { - return ( - - ) - } - - return ( -
- setDateRange({ start, end })} /> - - {/* KPI Cards */} -
- 0 ? { - value: Math.round(((totalRevenue - prevYearTotalRevenue) / prevYearTotalRevenue) * 1000) / 10, - label: 'mot fg ar', - } : undefined} - /> - - 0 ? overallAOV.toLocaleString('sv-SE') : '-'} - suffix={overallAOV > 0 ? 'kr' : undefined} - /> - -
- - {/* Channel management */} -
-

Kanaler

-
- {channels.map(ch => ( -
- {/* Color swatch - clickable for color picker */} - - - - {/* Color picker dropdown */} - {colorPickerChannel === ch.name && ( -
-
- {COLOR_PRESETS.map(color => ( -
-
- )} -
- ))} -
-
- setNewChannelName(e.target.value)} - className="max-w-xs" - onKeyDown={e => { - if (e.key === 'Enter') { - e.preventDefault() - handleAddChannel() - } - }} - /> - -
-
- - {/* Entry form */} - -
-
- - setEntryMonth(e.target.value)} /> -
-
- - -
-
- - setEntryRevenue(e.target.value)} /> -
-
- - setEntryOrders(e.target.value)} /> -
-
-
- - {/* Duplicate confirmation dialog */} - - - - Post finns redan - - Det finns redan en post for {pendingEntry?.channel} i {pendingEntry?.month}. - Vill du uppdatera den befintliga posten eller skapa en ny? - - - - - - - - - - - {/* Channel comparison bar chart */} - {sortedChannelTotals.length > 0 && ( -
-
-

Kanaljamforelse

-
- Sortera: - - -
-
-
- {sortedChannelTotals.map(ct => { - const channelConfig = channels.find(c => c.name === ct.channel) - const barWidth = Math.round((ct.revenue / maxChannelRevenue) * 100) - const prevData = prevYearChannelTotals.get(ct.channel) - const prevRev = prevData?.revenue ?? 0 - const aov = ct.orders > 0 ? Math.round(ct.revenue / ct.orders) : 0 - return ( -
-
- {ct.channel} -
- - AOV: {aov > 0 ? aov.toLocaleString('sv-SE') + ' kr' : '-'} - - - {formatCurrency(ct.revenue)} kr -
-
-
-
-
-
- ) - })} -
-
- )} - - {/* Entries table with edit/delete */} - {entries.length > 0 && ( -
-

Registrerade poster

-
- - - - Manad - Kanal - Intakt - Ordrar - AOV - Atgarder - - - - {entries.map(entry => { - const channelConfig = channels.find(c => c.name === entry.channel) - return ( - handleStartEdit(entry)} - > - {entry.month} - -
-
- {entry.channel} -
- - - {formatCurrency(entry.revenue)} kr - - - {entry.orderCount} - - - {formatAOV(entry.revenue, entry.orderCount)} {entry.orderCount > 0 ? 'kr' : ''} - - -
e.stopPropagation()}> - - -
-
- - ) - })} - -
-
-
- )} - - {/* Monthly comparison table */} - {monthlyComparison.length > 0 && ( -
-

Manadsjamforelse

-
- - - - Manad - {channels.map(ch => ( - {ch.name} - ))} - Total - AOV - - - - {monthlyComparison.map(row => ( - - {row.month} - {channels.map(ch => { - const chData = row.channels[ch.name] - return ( - - {chData ? formatCurrency(chData.revenue) : '0'} - - ) - })} - - {formatCurrency(row.total)} - - - {row.totalOrders > 0 - ? Math.round(row.total / row.totalOrders).toLocaleString('sv-SE') + ' kr' - : '-'} - - - ))} - -
-
-
- )} - - {/* Period comparison: previous year */} - {(prevYearEntries.length > 0 || channelTotals.length > 0) && ( -
-

Arsjamforelse per kanal

-
- - - - Kanal - Nuvarande period - Foregaende ar - Tillvaxt - AOV (nu) - AOV (fg ar) - - - - {sortedChannelTotals.map(ct => { - const prevData = prevYearChannelTotals.get(ct.channel) - const prevRev = prevData?.revenue ?? 0 - const prevOrd = prevData?.orders ?? 0 - return ( - - -
-
c.name === ct.channel)?.color ?? '#3b82f6' }} - /> - {ct.channel} -
- - - {formatCurrency(ct.revenue)} kr - - - {prevRev > 0 ? formatCurrency(prevRev) + ' kr' : '-'} - - - - - - {formatAOV(ct.revenue, ct.orders)} {ct.orders > 0 ? 'kr' : ''} - - - {formatAOV(prevRev, prevOrd)} {prevOrd > 0 ? 'kr' : ''} - - - ) - })} - {/* Totals row */} - - Totalt - - {formatCurrency(totalRevenue)} kr - - - {prevYearTotalRevenue > 0 ? formatCurrency(prevYearTotalRevenue) + ' kr' : '-'} - - - - - - {overallAOV > 0 ? overallAOV.toLocaleString('sv-SE') + ' kr' : '-'} - - - {(() => { - const prevTotalOrders = prevYearEntries.reduce((s, e) => s + e.orderCount, 0) - return prevTotalOrders > 0 - ? Math.round(prevYearTotalRevenue / prevTotalOrders).toLocaleString('sv-SE') + ' kr' - : '-' - })()} - - - -
-
-
- )} - - {/* Edit entry dialog */} - -
-
- - setEditMonth(e.target.value)} - /> -
-
- - -
-
- - setEditRevenue(e.target.value)} - /> -
-
- - setEditOrders(e.target.value)} - /> -
-
-
- - {/* Delete confirmation dialog */} - - - {/* Rename channel dialog */} - -
- - setNewName(e.target.value)} - placeholder="Kanalnamn" - /> -
-
-
- ) -} diff --git a/components/extensions/ecommerce/ShopifyImportWorkspace.tsx b/components/extensions/ecommerce/ShopifyImportWorkspace.tsx deleted file mode 100644 index 31172855..00000000 --- a/components/extensions/ecommerce/ShopifyImportWorkspace.tsx +++ /dev/null @@ -1,875 +0,0 @@ -'use client' - -import { useState, useMemo } from 'react' -import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' -import { useExtensionData } from '@/lib/extensions/use-extension-data' -import KPICard from '@/components/extensions/shared/KPICard' -import CsvImportWizard from '@/components/extensions/shared/CsvImportWizard' -import MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable' -import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' -import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog' -import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog' -import DataEntryForm from '@/components/extensions/shared/DataEntryForm' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Button } from '@/components/ui/button' -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' -import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table' -import { Badge } from '@/components/ui/badge' -import { Pencil, Trash2, ChevronLeft, ChevronRight } from 'lucide-react' - -interface ShopifyOrder { - id: string - name: string - createdAt: string - total: number - subtotal: number - shipping: number - taxes: number - paymentMethod: string - fulfillmentStatus: string -} - -interface ImportRecord { - id: string - date: string - rowCount: number -} - -const TARGET_FIELDS = [ - { key: 'name', label: 'Order', required: true }, - { key: 'createdAt', label: 'Datum', required: true }, - { key: 'total', label: 'Total', required: true }, - { key: 'subtotal', label: 'Subtotal' }, - { key: 'shipping', label: 'Frakt' }, - { key: 'taxes', label: 'Moms' }, - { key: 'paymentMethod', label: 'Betalmetod' }, - { key: 'fulfillmentStatus', label: 'Leveransstatus' }, -] - -const DEFAULT_MAPPINGS: Record = { - name: 'Name', - createdAt: 'Created at', - total: 'Total', - subtotal: 'Subtotal', - shipping: 'Shipping', - taxes: 'Taxes', - paymentMethod: 'Payment Method', - fulfillmentStatus: 'Fulfillment Status', -} - -const PAGES_SIZE = 20 - -export default function ShopifyImportWorkspace({}: WorkspaceComponentProps) { - const { data, save, remove, refresh, isLoading } = useExtensionData('ecommerce', 'shopify-import') - - const orders = useMemo(() => - data.filter(d => d.key.startsWith('order:')) - .map(d => ({ id: d.key.replace('order:', ''), ...(d.value as Omit) })) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - , [data]) - - const imports = useMemo(() => - data.filter(d => d.key.startsWith('import:')) - .map(d => ({ id: d.key, ...(d.value as Omit) })) - .sort((a, b) => b.date.localeCompare(a.date)) - , [data]) - - // --------------------------------------------------------------------------- - // Filter state - // --------------------------------------------------------------------------- - - const [searchQuery, setSearchQuery] = useState('') - const [dateFrom, setDateFrom] = useState('') - const [dateTo, setDateTo] = useState('') - const [paymentFilter, setPaymentFilter] = useState('__all__') - const [fulfillmentFilter, setFulfillmentFilter] = useState('__all__') - - // Pagination state - const [currentPage, setCurrentPage] = useState(1) - - // Edit order dialog state - const [editOrder, setEditOrder] = useState(null) - const [editName, setEditName] = useState('') - const [editDate, setEditDate] = useState('') - const [editTotal, setEditTotal] = useState('') - const [editSubtotal, setEditSubtotal] = useState('') - const [editShipping, setEditShipping] = useState('') - const [editTaxes, setEditTaxes] = useState('') - const [editPaymentMethod, setEditPaymentMethod] = useState('') - const [editFulfillmentStatus, setEditFulfillmentStatus] = useState('') - const [isSavingEdit, setIsSavingEdit] = useState(false) - - // Delete order dialog state - const [deleteOrderId, setDeleteOrderId] = useState(null) - const [isDeleting, setIsDeleting] = useState(false) - - // Manual entry form state - const [manualName, setManualName] = useState('') - const [manualDate, setManualDate] = useState(new Date().toISOString().slice(0, 10)) - const [manualTotal, setManualTotal] = useState('') - const [manualSubtotal, setManualSubtotal] = useState('') - const [manualShipping, setManualShipping] = useState('') - const [manualTaxes, setManualTaxes] = useState('') - const [manualPaymentMethod, setManualPaymentMethod] = useState('') - const [manualFulfillmentStatus, setManualFulfillmentStatus] = useState('') - const [isSubmittingManual, setIsSubmittingManual] = useState(false) - - // --------------------------------------------------------------------------- - // Distinct values for filter dropdowns - // --------------------------------------------------------------------------- - - const paymentMethods = useMemo(() => { - const set = new Set() - for (const o of orders) { - if (o.paymentMethod) set.add(o.paymentMethod) - } - return Array.from(set).sort() - }, [orders]) - - const fulfillmentStatuses = useMemo(() => { - const set = new Set() - for (const o of orders) { - if (o.fulfillmentStatus) set.add(o.fulfillmentStatus) - } - return Array.from(set).sort() - }, [orders]) - - // --------------------------------------------------------------------------- - // Active filter count - // --------------------------------------------------------------------------- - - const activeFilterCount = useMemo(() => { - let count = 0 - if (searchQuery.trim()) count++ - if (dateFrom) count++ - if (dateTo) count++ - if (paymentFilter !== '__all__') count++ - if (fulfillmentFilter !== '__all__') count++ - return count - }, [searchQuery, dateFrom, dateTo, paymentFilter, fulfillmentFilter]) - - // --------------------------------------------------------------------------- - // CSV import handler - // --------------------------------------------------------------------------- - - const handleImport = async (rows: Record[]) => { - const importId = crypto.randomUUID() - let count = 0 - - for (const row of rows) { - const parseNum = (v?: string) => { - if (!v) return 0 - return Math.round(parseFloat(v.replace(/\s/g, '').replace(',', '.')) * 100) / 100 || 0 - } - - const orderId = crypto.randomUUID() - await save(`order:${orderId}`, { - name: row.name ?? '', - createdAt: row.createdAt ?? new Date().toISOString().slice(0, 10), - total: parseNum(row.total), - subtotal: parseNum(row.subtotal), - shipping: parseNum(row.shipping), - taxes: parseNum(row.taxes), - paymentMethod: row.paymentMethod ?? '', - fulfillmentStatus: row.fulfillmentStatus ?? '', - }) - count++ - } - - await save(`import:${importId}`, { - date: new Date().toISOString().slice(0, 10), - rowCount: count, - }) - - await refresh() - } - - // --------------------------------------------------------------------------- - // Manual order entry handler - // --------------------------------------------------------------------------- - - const handleManualSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!manualName.trim()) return - const parseNum = (v: string) => Math.round(parseFloat(v.replace(/\s/g, '').replace(',', '.')) * 100) / 100 || 0 - - setIsSubmittingManual(true) - const orderId = crypto.randomUUID() - await save(`order:${orderId}`, { - name: manualName.trim(), - createdAt: manualDate || new Date().toISOString().slice(0, 10), - total: parseNum(manualTotal), - subtotal: parseNum(manualSubtotal), - shipping: parseNum(manualShipping), - taxes: parseNum(manualTaxes), - paymentMethod: manualPaymentMethod, - fulfillmentStatus: manualFulfillmentStatus, - }) - - setManualName('') - setManualDate(new Date().toISOString().slice(0, 10)) - setManualTotal('') - setManualSubtotal('') - setManualShipping('') - setManualTaxes('') - setManualPaymentMethod('') - setManualFulfillmentStatus('') - await refresh() - setIsSubmittingManual(false) - } - - // --------------------------------------------------------------------------- - // Edit order handlers - // --------------------------------------------------------------------------- - - const openEditOrder = (order: ShopifyOrder) => { - setEditOrder(order) - setEditName(order.name) - setEditDate(order.createdAt) - setEditTotal(String(order.total)) - setEditSubtotal(String(order.subtotal)) - setEditShipping(String(order.shipping)) - setEditTaxes(String(order.taxes)) - setEditPaymentMethod(order.paymentMethod) - setEditFulfillmentStatus(order.fulfillmentStatus) - } - - const handleSaveEdit = async () => { - if (!editOrder) return - const parseNum = (v: string) => Math.round(parseFloat(v.replace(/\s/g, '').replace(',', '.')) * 100) / 100 || 0 - - setIsSavingEdit(true) - await save(`order:${editOrder.id}`, { - name: editName, - createdAt: editDate || new Date().toISOString().slice(0, 10), - total: parseNum(editTotal), - subtotal: parseNum(editSubtotal), - shipping: parseNum(editShipping), - taxes: parseNum(editTaxes), - paymentMethod: editPaymentMethod, - fulfillmentStatus: editFulfillmentStatus, - }) - await refresh() - setIsSavingEdit(false) - } - - // --------------------------------------------------------------------------- - // Delete order handler - // --------------------------------------------------------------------------- - - const handleConfirmDelete = async () => { - if (!deleteOrderId) return - setIsDeleting(true) - await remove(`order:${deleteOrderId}`) - await refresh() - setIsDeleting(false) - } - - // --------------------------------------------------------------------------- - // Stats - // --------------------------------------------------------------------------- - - const totalRevenue = orders.reduce((s, o) => s + o.total, 0) - const aov = orders.length > 0 ? Math.round(totalRevenue / orders.length) : 0 - const totalTaxes = orders.reduce((s, o) => s + o.taxes, 0) - const totalSubtotal = orders.reduce((s, o) => s + o.subtotal, 0) - const avgVatRate = totalSubtotal > 0 - ? Math.round((totalTaxes / totalSubtotal) * 10000) / 100 - : 0 - - // Monthly trend - const monthlyTrend = useMemo(() => { - const map = new Map() - for (const o of orders) { - const month = o.createdAt.slice(0, 7) - const existing = map.get(month) ?? { revenue: 0, count: 0 } - existing.revenue += o.total - existing.count++ - map.set(month, existing) - } - return Array.from(map.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([month, data]) => ({ month, value: data.revenue })) - }, [orders]) - - // Monthly VAT breakdown - const monthlyVat = useMemo(() => { - const map = new Map() - for (const o of orders) { - const month = o.createdAt.slice(0, 7) - const existing = map.get(month) ?? { taxes: 0, subtotal: 0 } - existing.taxes += o.taxes - existing.subtotal += o.subtotal - map.set(month, existing) - } - return Array.from(map.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([month, d]) => ({ - month, - taxes: Math.round(d.taxes * 100) / 100, - subtotal: Math.round(d.subtotal * 100) / 100, - rate: d.subtotal > 0 ? Math.round((d.taxes / d.subtotal) * 10000) / 100 : 0, - })) - }, [orders]) - - // Payment method breakdown - const paymentBreakdown = useMemo(() => { - const map = new Map() - for (const o of orders) { - const method = o.paymentMethod || 'Okant' - const existing = map.get(method) ?? { count: 0, total: 0 } - existing.count++ - existing.total += o.total - map.set(method, existing) - } - return Array.from(map.entries()) - .map(([method, data]) => ({ method, ...data })) - .sort((a, b) => b.total - a.total) - }, [orders]) - - // Fulfillment breakdown - const fulfillmentBreakdown = useMemo(() => { - const map = new Map() - for (const o of orders) { - const status = o.fulfillmentStatus || 'Okant' - map.set(status, (map.get(status) ?? 0) + 1) - } - return Array.from(map.entries()) - .map(([status, count]) => ({ status, count })) - .sort((a, b) => b.count - a.count) - }, [orders]) - - // --------------------------------------------------------------------------- - // Filtered & paginated orders - // --------------------------------------------------------------------------- - - const filteredOrders = useMemo(() => { - let result = orders - - if (searchQuery.trim()) { - const q = searchQuery.toLowerCase() - result = result.filter(o => - o.name.toLowerCase().includes(q) || - o.paymentMethod.toLowerCase().includes(q) || - o.fulfillmentStatus.toLowerCase().includes(q) - ) - } - - if (dateFrom) { - result = result.filter(o => o.createdAt >= dateFrom) - } - - if (dateTo) { - result = result.filter(o => o.createdAt <= dateTo) - } - - if (paymentFilter !== '__all__') { - result = result.filter(o => o.paymentMethod === paymentFilter) - } - - if (fulfillmentFilter !== '__all__') { - result = result.filter(o => o.fulfillmentStatus === fulfillmentFilter) - } - - return result - }, [orders, searchQuery, dateFrom, dateTo, paymentFilter, fulfillmentFilter]) - - const totalPages = Math.max(1, Math.ceil(filteredOrders.length / PAGES_SIZE)) - const safePage = Math.min(currentPage, totalPages) - const paginatedOrders = filteredOrders.slice( - (safePage - 1) * PAGES_SIZE, - safePage * PAGES_SIZE - ) - - // Reset page when filters change - const resetPage = () => setCurrentPage(1) - - // --------------------------------------------------------------------------- - // Clear filters - // --------------------------------------------------------------------------- - - const clearFilters = () => { - setSearchQuery('') - setDateFrom('') - setDateTo('') - setPaymentFilter('__all__') - setFulfillmentFilter('__all__') - setCurrentPage(1) - } - - // --------------------------------------------------------------------------- - // Render - // --------------------------------------------------------------------------- - - if (isLoading) return - - return ( -
- {/* Edit order dialog */} - { if (!open) setEditOrder(null) }} - title="Redigera order" - description="Andra uppgifterna for denna order." - onSave={handleSaveEdit} - isSaving={isSavingEdit} - > -
-
- - setEditName(e.target.value)} /> -
-
- - setEditDate(e.target.value)} /> -
-
- - setEditTotal(e.target.value)} /> -
-
- - setEditSubtotal(e.target.value)} /> -
-
- - setEditShipping(e.target.value)} /> -
-
- - setEditTaxes(e.target.value)} /> -
-
- - setEditPaymentMethod(e.target.value)} /> -
-
- - setEditFulfillmentStatus(e.target.value)} /> -
-
-
- - {/* Delete order dialog */} - { if (!open) setDeleteOrderId(null) }} - title="Ta bort order" - description="Ar du saker pa att du vill ta bort denna order? Atgarden kan inte angras." - onConfirm={handleConfirmDelete} - isDeleting={isDeleting} - /> - - - - Import - Ordrar - Statistik - - - {/* ------------------------------------------------------------------ */} - {/* Import tab */} - {/* ------------------------------------------------------------------ */} - - - - {/* Manual order entry */} - -
-
- - setManualName(e.target.value)} - placeholder="t.ex. #1001" - required - /> -
-
- - setManualDate(e.target.value)} - required - /> -
-
- - setManualTotal(e.target.value)} - /> -
-
- - setManualSubtotal(e.target.value)} - /> -
-
- - setManualShipping(e.target.value)} - /> -
-
- - setManualTaxes(e.target.value)} - /> -
-
- - setManualPaymentMethod(e.target.value)} - placeholder="t.ex. Stripe" - /> -
-
- - setManualFulfillmentStatus(e.target.value)} - placeholder="t.ex. fulfilled" - /> -
-
-
- - {imports.length > 0 && ( -
-

Importhistorik

-
- - - - Datum - Ordrar - - - - {imports.map(imp => ( - - {imp.date} - {imp.rowCount} - - ))} - -
-
-
- )} -
- - {/* ------------------------------------------------------------------ */} - {/* Orders tab */} - {/* ------------------------------------------------------------------ */} - - {/* Filters */} -
-
-
- - { setSearchQuery(e.target.value); resetPage() }} - className="w-48" - /> -
-
- - { setDateFrom(e.target.value); resetPage() }} - className="w-40" - /> -
-
- - { setDateTo(e.target.value); resetPage() }} - className="w-40" - /> -
-
- - -
-
- - -
-
- - {activeFilterCount > 0 && ( -
- {activeFilterCount} aktiva filter - -
- )} -
- - {filteredOrders.length === 0 ? ( -

Inga ordrar hittades.

- ) : ( - <> -
- - - - Order - Datum - Total - Frakt - Moms - Betalning - Status - - - - - {paginatedOrders.map(o => ( - - {o.name} - {o.createdAt} - {o.total.toLocaleString('sv-SE')} kr - {o.shipping.toLocaleString('sv-SE')} kr - {o.taxes.toLocaleString('sv-SE')} kr - {o.paymentMethod} - - - {o.fulfillmentStatus || 'Okant'} - - - -
- - -
-
-
- ))} -
-
-
- - {/* Pagination */} -
-

- {filteredOrders.length} ordrar totalt -

-
- - - Sida {safePage} av {totalPages} - - -
-
- - )} -
- - {/* ------------------------------------------------------------------ */} - {/* Stats tab */} - {/* ------------------------------------------------------------------ */} - -
- - - -
- - {/* VAT analytics */} -
-

Momsanalys

-
- - -
- - {monthlyVat.length > 0 && ( -
- - - - Manad - Subtotal - Moms - Momssats - - - - {monthlyVat.map(m => ( - - {m.month} - {m.subtotal.toLocaleString('sv-SE')} kr - {m.taxes.toLocaleString('sv-SE')} kr - {m.rate}% - - ))} - -
-
- )} -
- - {monthlyTrend.length > 0 && ( -
-

Intakt per manad

- -
- )} - - {paymentBreakdown.length > 0 && ( -
-

Per betalmetod

-
- - - - Betalmetod - Ordrar - Total - - - - {paymentBreakdown.map(p => ( - - {p.method} - {p.count} - {p.total.toLocaleString('sv-SE')} kr - - ))} - -
-
-
- )} - - {fulfillmentBreakdown.length > 0 && ( -
-

Per leveransstatus

-
- - - - Status - Ordrar - - - - {fulfillmentBreakdown.map(f => ( - - {f.status} - {f.count} - - ))} - -
-
-
- )} -
-
-
- ) -} diff --git a/components/extensions/export/CurrencyReceivablesWorkspace.tsx b/components/extensions/export/CurrencyReceivablesWorkspace.tsx deleted file mode 100644 index 2cdd1e00..00000000 --- a/components/extensions/export/CurrencyReceivablesWorkspace.tsx +++ /dev/null @@ -1,715 +0,0 @@ -'use client' - -import { useState, useEffect, useCallback } from 'react' -import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' -import { useMockData } from '@/lib/extensions/use-mock-data' -import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' -import MockDataBanner from '@/components/extensions/shared/MockDataBanner' -import MockDataImportDialog from '@/components/extensions/shared/MockDataImportDialog' -import type { CsvFieldDef } from '@/components/extensions/shared/MockDataImportDialog' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' -import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table' -import { - TrendingUp, TrendingDown, RefreshCw, Info, ArrowUpDown, FlaskConical, -} from 'lucide-react' -import { Button } from '@/components/ui/button' -import { cn } from '@/lib/utils' - -// ── Types ───────────────────────────────────────────────────── - -interface CurrencyExposure { - currency: string - totalForeignAmount: number - bookedSekValue: number - currentSekValue: number - unrealizedGainLoss: number - invoiceCount: number - averageBookedRate: number - currentRate: number -} - -interface ForeignReceivable { - invoiceId: string - invoiceNumber: string - customerName: string - customerCountry: string - currency: string - foreignAmount: number - bookedSekAmount: number - bookedRate: number - currentSekAmount: number - currentRate: number - unrealizedGainLoss: number - invoiceDate: string - dueDate: string - daysOutstanding: number -} - -interface MonthlyFXTrend { - month: string - realizedGains: number - realizedLosses: number - netRealized: number -} - -interface ExchangeRateInfo { - currency: string - rate: number - date: string -} - -interface RevalPreview { - totalUnrealizedGainLoss: number - gains: number - losses: number -} - -interface ReportData { - referenceDate: string - exchangeRates: ExchangeRateInfo[] - exposureByCurrency: CurrencyExposure[] - receivables: ForeignReceivable[] - realizedGainLoss: { - year: number - gains: number - losses: number - net: number - } - monthlyTrend: MonthlyFXTrend[] - revalPreview: RevalPreview - totals: { - bookedSekValue: number - currentSekValue: number - totalUnrealizedGainLoss: number - receivableCount: number - currencyCount: number - } -} - -// ── Helpers ─────────────────────────────────────────────────── - -function formatSEK(amount: number): string { - return Math.round(amount).toLocaleString('sv-SE') -} - -function formatAmount(amount: number, decimals = 2): string { - return amount.toLocaleString('sv-SE', { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, - }) -} - -const CURRENCY_SYMBOLS: Record = { - EUR: '€', USD: '$', GBP: '£', NOK: 'kr', DKK: 'kr', SEK: 'kr', -} - -function currencySymbol(code: string): string { - return CURRENCY_SYMBOLS[code] || code -} - -const MONTH_NAMES = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'Maj', 'Jun', - 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec', -] - -function monthLabel(monthKey: string): string { - const m = parseInt(monthKey.split('-')[1], 10) - return MONTH_NAMES[m - 1] || monthKey -} - -function currentYear(): number { return new Date().getFullYear() } - -type SortField = 'unrealizedGainLoss' | 'foreignAmount' | 'daysOutstanding' | 'customerName' -type SortDir = 'asc' | 'desc' - -// ── Mock Data Config ────────────────────────────────────────── - -const MOCK_CSV_FIELDS: CsvFieldDef[] = [ - { key: 'invoiceNumber', label: 'Fakturanummer', required: true }, - { key: 'customerName', label: 'Kund', required: true }, - { key: 'currency', label: 'Valuta', required: true }, - { key: 'foreignAmount', label: 'Belopp (utl. valuta)', required: true }, - { key: 'bookedSekAmount', label: 'Bokfört (SEK)' }, - { key: 'bookedRate', label: 'Bokförd kurs' }, - { key: 'currentSekAmount', label: 'Aktuellt (SEK)' }, - { key: 'currentRate', label: 'Aktuell kurs' }, - { key: 'invoiceDate', label: 'Fakturadatum' }, - { key: 'dueDate', label: 'Förfallodatum' }, -] - -const MOCK_CSV_TEMPLATE = `invoiceNumber;customerName;currency;foreignAmount;bookedSekAmount;bookedRate;currentSekAmount;currentRate;invoiceDate;dueDate -1001;Beispiel GmbH;EUR;10000;112500;11.25;114200;11.42;2025-01-15;2025-02-15 -1002;Example Corp;USD;25000;262500;10.50;260000;10.40;2025-01-20;2025-02-20 -1003;London Ltd;GBP;8000;106400;13.30;108000;13.50;2025-02-01;2025-03-01` - -function parseMockCsvRows(rows: Record[]): ReportData { - const today = new Date().toISOString().slice(0, 10) - const receivables: ForeignReceivable[] = rows.map(r => { - const foreignAmount = parseFloat(r.foreignAmount || '0') || 0 - const bookedRate = parseFloat(r.bookedRate || '0') || 0 - const currentRate = parseFloat(r.currentRate || '0') || bookedRate - const bookedSek = parseFloat(r.bookedSekAmount || '0') || Math.round(foreignAmount * bookedRate * 100) / 100 - const currentSek = parseFloat(r.currentSekAmount || '0') || Math.round(foreignAmount * currentRate * 100) / 100 - const invoiceDate = r.invoiceDate || today - const dueDate = r.dueDate || today - const daysOutstanding = Math.max(0, Math.floor((Date.now() - new Date(invoiceDate).getTime()) / 86400000)) - - return { - invoiceId: r.invoiceNumber || '', - invoiceNumber: r.invoiceNumber || '', - customerName: r.customerName || '', - customerCountry: '', - currency: r.currency || 'EUR', - foreignAmount, - bookedSekAmount: bookedSek, - bookedRate, - currentSekAmount: currentSek, - currentRate, - unrealizedGainLoss: Math.round((currentSek - bookedSek) * 100) / 100, - invoiceDate, - dueDate, - daysOutstanding, - } - }) - - // Group by currency for exposure - const currencyMap = new Map() - for (const r of receivables) { - const existing = currencyMap.get(r.currency) - if (existing) { - existing.totalForeignAmount += r.foreignAmount - existing.bookedSekValue += r.bookedSekAmount - existing.currentSekValue += r.currentSekAmount - existing.unrealizedGainLoss += r.unrealizedGainLoss - existing.invoiceCount++ - } else { - currencyMap.set(r.currency, { - currency: r.currency, - totalForeignAmount: r.foreignAmount, - bookedSekValue: r.bookedSekAmount, - currentSekValue: r.currentSekAmount, - unrealizedGainLoss: r.unrealizedGainLoss, - invoiceCount: 1, - averageBookedRate: r.bookedRate, - currentRate: r.currentRate, - }) - } - } - - const exposureByCurrency = Array.from(currencyMap.values()) - const totalBookedSek = receivables.reduce((s, r) => s + r.bookedSekAmount, 0) - const totalCurrentSek = receivables.reduce((s, r) => s + r.currentSekAmount, 0) - const totalUnrealized = Math.round((totalCurrentSek - totalBookedSek) * 100) / 100 - - return { - referenceDate: today, - exchangeRates: exposureByCurrency.map(e => ({ currency: e.currency, rate: e.currentRate, date: today })), - exposureByCurrency, - receivables, - realizedGainLoss: { year: new Date().getFullYear(), gains: 0, losses: 0, net: 0 }, - monthlyTrend: [], - revalPreview: { - totalUnrealizedGainLoss: totalUnrealized, - gains: Math.max(0, totalUnrealized), - losses: Math.abs(Math.min(0, totalUnrealized)), - }, - totals: { - bookedSekValue: totalBookedSek, - currentSekValue: totalCurrentSek, - totalUnrealizedGainLoss: totalUnrealized, - receivableCount: receivables.length, - currencyCount: exposureByCurrency.length, - }, - } -} - -function validateMockReport(data: unknown): { valid: boolean; error?: string } { - if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' } - const obj = data as Record - if (!Array.isArray(obj.receivables) && !Array.isArray(obj.exposureByCurrency)) { - return { valid: false, error: 'Fältet "receivables" eller "exposureByCurrency" saknas' } - } - if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' } - return { valid: true } -} - -// ── Component ───────────────────────────────────────────────── - -export default function CurrencyReceivablesWorkspace({ userId }: WorkspaceComponentProps) { - void userId - - // Mock data - const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData('export', 'currency-receivables') - const [importDialogOpen, setImportDialogOpen] = useState(false) - - const [year, setYear] = useState(currentYear()) - const [report, setReport] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - const [refreshing, setRefreshing] = useState(false) - - const [sortField, setSortField] = useState('unrealizedGainLoss') - const [sortDir, setSortDir] = useState('desc') - - const years = [currentYear(), currentYear() - 1, currentYear() - 2] - - const fetchReport = useCallback(async () => { - if (isMockActive && mockReport) { - setReport(mockReport) - setIsLoading(false) - setRefreshing(false) - return - } - - setIsLoading(true) - setError(null) - try { - const params = new URLSearchParams({ year: String(year) }) - const res = await fetch(`/api/extensions/export/currency-receivables/report?${params}`) - if (!res.ok) { - const json = await res.json() - setError(json.error || 'Kunde inte hämta rapporten') - setReport(null) - return - } - const json = await res.json() - setReport(json.data) - } catch { - setError('Nätverksfel') - setReport(null) - } finally { - setIsLoading(false) - setRefreshing(false) - } - }, [year, isMockActive, mockReport]) - - useEffect(() => { - fetchReport() - }, [fetchReport]) - - const handleMockImport = useCallback(async (data: ReportData, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => { - await saveMockData(data, meta) - setReport(data) - }, [saveMockData]) - - const handleMockClear = useCallback(async () => { - await clearMockData() - setReport(null) - setIsLoading(true) - try { - const params = new URLSearchParams({ year: String(year) }) - const res = await fetch(`/api/extensions/export/currency-receivables/report?${params}`) - if (res.ok) { - const json = await res.json() - setReport(json.data) - } - } catch { /* ignore */ } - setIsLoading(false) - }, [clearMockData, year]) - - const handleRefresh = () => { - setRefreshing(true) - fetchReport() - } - - const toggleSort = (field: SortField) => { - if (sortField === field) { - setSortDir(d => d === 'asc' ? 'desc' : 'asc') - } else { - setSortField(field) - setSortDir('desc') - } - } - - const sortedReceivables = report?.receivables.slice().sort((a, b) => { - const mul = sortDir === 'asc' ? 1 : -1 - switch (sortField) { - case 'unrealizedGainLoss': return mul * (Math.abs(a.unrealizedGainLoss) - Math.abs(b.unrealizedGainLoss)) - case 'foreignAmount': return mul * (a.foreignAmount - b.foreignAmount) - case 'daysOutstanding': return mul * (a.daysOutstanding - b.daysOutstanding) - case 'customerName': return mul * a.customerName.localeCompare(b.customerName) - default: return 0 - } - }) || [] - - // Only show trend months that have data or are <= current month - const activeTrend = report?.monthlyTrend.filter(t => { - const m = parseInt(t.month.split('-')[1], 10) - const trendYear = parseInt(t.month.split('-')[0], 10) - if (trendYear < currentYear()) return true - return m <= new Date().getMonth() + 1 - }) || [] - - if ((isLoading || mockLoading) && !report) { - return - } - - return ( -
- {/* ── Header ─────────────────────────────────────── */} -
-
- - -
-
- - -
-
- - {/* ── Mock Data Banner ──────────────────────────────── */} - {isMockActive && ( - setImportDialogOpen(true)} - /> - )} - - {error && ( - - -

{error}

-
-
- )} - - {report && ( - <> - {/* ── Exchange Rates ──────────────────────────── */} - - -
-

Växelkurser

- - {report.referenceDate} - -
-
- {report.exchangeRates.map(r => ( -
- {r.currency}: - {formatAmount(r.rate, 4)} -
- ))} -
-
-
- - {/* ── Exposure Cards ─────────────────────────── */} - {report.exposureByCurrency.length > 0 ? ( -
- {report.exposureByCurrency.map(exp => ( - - ))} - - {/* Total card */} - - -
- Totalt - {report.totals.receivableCount} fakturor -
-

- {formatSEK(report.totals.currentSekValue)} -

-

SEK (aktuell kurs)

-
- -
-
-
-
- ) : ( - - -

- Inga öppna fordringar i utländsk valuta. -

-
-
- )} - - {/* ── Receivables Table ──────────────────────── */} - {sortedReceivables.length > 0 && ( - - - Öppna fordringar - - -
- - - - Faktura - - Valuta - - Bokfört (SEK) - Aktuellt (SEK) - - - - - - {sortedReceivables.map(r => ( - - {r.invoiceNumber} - -
- {r.customerName} - {r.customerCountry && ( - {r.customerCountry} - )} -
-
- - {r.currency} - - - {currencySymbol(r.currency)}{formatAmount(r.foreignAmount)} - - - {formatSEK(r.bookedSekAmount)} - - - {formatSEK(r.currentSekAmount)} - - - - - - 30 ? 'text-destructive font-medium' : - r.daysOutstanding > 14 ? 'text-warning-foreground' : '' - )}> - {r.daysOutstanding} - - -
- ))} -
-
-
-
-
- )} - - {/* ── Realized FX Trend ──────────────────────── */} - - - - Realiserade kursdifferenser {year} - - - - {activeTrend.length === 0 ? ( -

- Inga realiserade kursdifferenser för {year}. -

- ) : ( -
- - - - Månad - Vinst (3960) - Förlust (7960) - Netto - - - - {activeTrend.map(t => ( - - {monthLabel(t.month)} - - {t.realizedGains > 0 ? `+${formatSEK(t.realizedGains)}` : '—'} - - - {t.realizedLosses > 0 ? `-${formatSEK(t.realizedLosses)}` : '—'} - - - - - - ))} - {/* Totals row */} - - Totalt {year} - - +{formatSEK(report.realizedGainLoss.gains)} - - - -{formatSEK(report.realizedGainLoss.losses)} - - - - - - -
-
- )} -
-
- - {/* ── Revaluation Preview ────────────────────── */} - {report.receivables.length > 0 && ( - - -
- -
-

Omvärdering vid periodbokslut

-

- Om bokslut görs idag: netto orealiserad{' '} - = 0 ? 'text-green-600' : 'text-red-600' - )}> - {report.revalPreview.totalUnrealizedGainLoss >= 0 ? 'vinst' : 'förlust'}{' '} - {report.revalPreview.totalUnrealizedGainLoss >= 0 ? '+' : ''} - {formatSEK(report.revalPreview.totalUnrealizedGainLoss)} SEK - -

- {report.revalPreview.gains > 0 && ( -

- Konto 3969 (orealiserad kursvinst): {formatSEK(report.revalPreview.gains)} kr -

- )} - {report.revalPreview.losses > 0 && ( -

- Konto 7969 (orealiserad kursförlust): {formatSEK(report.revalPreview.losses)} kr -

- )} -

- Bokföringsposterna skapas inte av detta tillägg. Använd värdena ovan som underlag vid periodbokslut. -

-
-
-
-
- )} - - )} - - {/* ── Mock Data Import Dialog ───────────────────────── */} - - open={importDialogOpen} - onOpenChange={setImportDialogOpen} - csvFields={MOCK_CSV_FIELDS} - parseCsvRows={parseMockCsvRows} - validateReport={validateMockReport} - templateCsvContent={MOCK_CSV_TEMPLATE} - templateFileName="currency-receivables-template.csv" - onImport={handleMockImport} - /> -
- ) -} - -// ── Sub-components ──────────────────────────────────────────── - -function ExposureCard({ exposure }: { exposure: CurrencyExposure }) { - const sym = currencySymbol(exposure.currency) - return ( - - -
- {exposure.currency} - {exposure.invoiceCount} fakturor -
-

- {sym}{formatAmount(exposure.totalForeignAmount)} -

-

- {formatSEK(exposure.currentSekValue)} SEK -

-
- -
- Bokförd kurs: {formatAmount(exposure.averageBookedRate, 4)} - Aktuell: {formatAmount(exposure.currentRate, 4)} -
-
-
-
- ) -} - -function FXIndicator({ label, amount }: { label: string; amount: number }) { - const isGain = amount >= 0 - return ( -
- {label} -
- {isGain ? : } - {isGain ? '+' : ''}{formatSEK(amount)} kr -
-
- ) -} - -function FXBadge({ amount }: { amount: number }) { - if (amount === 0) return — - const isGain = amount > 0 - return ( - - {isGain ? '+' : ''}{formatSEK(amount)} - - ) -} - -function SortableHead({ - field, label, current, dir, onSort, className, -}: { - field: SortField - label: string - current: SortField - dir: SortDir - onSort: (f: SortField) => void - className?: string -}) { - const isActive = current === field - return ( - - - - ) -} diff --git a/components/extensions/export/EuSalesListWorkspace.tsx b/components/extensions/export/EuSalesListWorkspace.tsx deleted file mode 100644 index 507d98ea..00000000 --- a/components/extensions/export/EuSalesListWorkspace.tsx +++ /dev/null @@ -1,777 +0,0 @@ -'use client' - -import { useState, useEffect, useMemo, useCallback } from 'react' -import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' -import { useMockData } from '@/lib/extensions/use-mock-data' -import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' -import MockDataBanner from '@/components/extensions/shared/MockDataBanner' -import MockDataImportDialog from '@/components/extensions/shared/MockDataImportDialog' -import type { CsvFieldDef } from '@/components/extensions/shared/MockDataImportDialog' -import KPICard from '@/components/extensions/shared/KPICard' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' -import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table' -import { - AlertTriangle, CheckCircle2, FileSpreadsheet, FileCode, - Clock, ChevronDown, ChevronUp, Users, Package, Briefcase, - FlaskConical, -} from 'lucide-react' -import { cn } from '@/lib/utils' - -// ── Types ───────────────────────────────────────────────────── - -interface ECSalesListLine { - customerVatNumber: string - customerName: string - customerCountry: string - customerId: string - goodsAmount: number - servicesAmount: number - triangulationAmount: number - invoiceCount: number -} - -interface ECSalesListWarning { - type: string - severity: 'error' | 'warning' - invoiceId?: string - invoiceNumber?: string - customerId?: string - customerName?: string - message: string -} - -interface CrossCheckResult { - box35Match: boolean - box35ReportTotal: number - box35GLTotal: number - box39Match: boolean - box39ReportTotal: number - box39GLTotal: number -} - -interface ReportData { - period: { year: number; month?: number; quarter?: number } - filingType: 'monthly' | 'quarterly' - reporterVatNumber: string - reporterName: string - lines: ECSalesListLine[] - totals: { goods: number; services: number; triangulation: number; total: number } - warnings: ECSalesListWarning[] - crossCheck: CrossCheckResult | null - invoiceCount: number - customerCount: number - deadline: string - daysUntilDeadline: number -} - -type SortField = 'country' | 'vatNumber' | 'goods' | 'services' | 'invoices' -type SortDir = 'asc' | 'desc' - -// ── Helpers ─────────────────────────────────────────────────── - -function formatSEK(amount: number): string { - return Math.round(amount).toLocaleString('sv-SE') -} - -function formatDeadlineDate(dateStr: string): string { - const d = new Date(dateStr + 'T00:00:00') - return d.toLocaleDateString('sv-SE', { year: 'numeric', month: 'long', day: 'numeric' }) -} - -const MONTHS = [ - 'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni', - 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December', -] - -const QUARTERS = ['Q1 (jan–mar)', 'Q2 (apr–jun)', 'Q3 (jul–sep)', 'Q4 (okt–dec)'] - -function currentYear(): number { - return new Date().getFullYear() -} - -function currentMonth(): number { - return new Date().getMonth() + 1 -} - -function currentQuarter(): number { - return Math.ceil(currentMonth() / 3) -} - -// ── Mock Data Config ────────────────────────────────────────── - -const MOCK_CSV_FIELDS: CsvFieldDef[] = [ - { key: 'customerVatNumber', label: 'VAT-nummer', required: true }, - { key: 'customerName', label: 'Kundnamn', required: true }, - { key: 'customerCountry', label: 'Land', required: true }, - { key: 'goodsAmount', label: 'Varor (SEK)' }, - { key: 'servicesAmount', label: 'Tjänster (SEK)' }, - { key: 'triangulationAmount', label: 'Trepartshandel (SEK)' }, - { key: 'invoiceCount', label: 'Antal fakturor' }, -] - -const MOCK_CSV_TEMPLATE = `customerVatNumber;customerName;customerCountry;goodsAmount;servicesAmount;triangulationAmount;invoiceCount -DE123456789;Beispiel GmbH;DE;150000;25000;0;3 -FR987654321;Exemple SARL;FR;0;80000;0;2 -NL456789012;Voorbeeld BV;NL;45000;0;12000;1` - -function parseMockCsvRows(rows: Record[]): ReportData { - const lines: ECSalesListLine[] = rows.map(r => ({ - customerVatNumber: r.customerVatNumber || '', - customerName: r.customerName || '', - customerCountry: r.customerCountry || '', - customerId: r.customerVatNumber || '', - goodsAmount: parseFloat(r.goodsAmount || '0') || 0, - servicesAmount: parseFloat(r.servicesAmount || '0') || 0, - triangulationAmount: parseFloat(r.triangulationAmount || '0') || 0, - invoiceCount: parseInt(r.invoiceCount || '1', 10) || 1, - })) - - const goods = lines.reduce((s, l) => s + l.goodsAmount, 0) - const services = lines.reduce((s, l) => s + l.servicesAmount, 0) - const triangulation = lines.reduce((s, l) => s + l.triangulationAmount, 0) - const invoiceCount = lines.reduce((s, l) => s + l.invoiceCount, 0) - - return { - period: { year: new Date().getFullYear(), quarter: Math.ceil((new Date().getMonth() + 1) / 3) }, - filingType: 'quarterly', - reporterVatNumber: 'SE000000000001', - reporterName: 'Testdata', - lines, - totals: { goods, services, triangulation, total: goods + services + triangulation }, - warnings: [], - crossCheck: null, - invoiceCount, - customerCount: lines.length, - deadline: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10), - daysUntilDeadline: 30, - } -} - -function validateMockReport(data: unknown): { valid: boolean; error?: string } { - if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' } - const obj = data as Record - if (!Array.isArray(obj.lines)) return { valid: false, error: 'Fältet "lines" saknas eller är inte en array' } - if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' } - return { valid: true } -} - -// ── Component ───────────────────────────────────────────────── - -export default function EuSalesListWorkspace({ userId }: WorkspaceComponentProps) { - void userId - - // Mock data - const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData('export', 'eu-sales-list') - const [importDialogOpen, setImportDialogOpen] = useState(false) - - // Period selection state - const [year, setYear] = useState(currentYear()) - const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('quarterly') - const [month, setMonth] = useState(currentMonth()) - const [quarter, setQuarter] = useState(currentQuarter()) - - // Report state - const [report, setReport] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - - // Table sort - const [sortField, setSortField] = useState('country') - const [sortDir, setSortDir] = useState('asc') - - // Warning expansion - const [warningsExpanded, setWarningsExpanded] = useState(false) - - // Download state - const [downloading, setDownloading] = useState<'csv' | 'xml' | null>(null) - - // Available years (current year and 2 previous) - const years = useMemo(() => { - const cy = currentYear() - return [cy, cy - 1, cy - 2] - }, []) - - // Fetch report - const fetchReport = useCallback(async () => { - if (isMockActive && mockReport) { - setReport(mockReport) - setIsLoading(false) - return - } - - setIsLoading(true) - setError(null) - - const params = new URLSearchParams({ year: String(year) }) - if (periodType === 'monthly') { - params.set('month', String(month)) - } else { - params.set('quarter', String(quarter)) - } - - try { - const res = await fetch(`/api/extensions/export/eu-sales-list/report?${params}`) - if (!res.ok) { - const json = await res.json() - setError(json.error || 'Kunde inte generera rapporten') - setReport(null) - return - } - const json = await res.json() - setReport(json.data) - } catch { - setError('Nätverksfel — kunde inte hämta rapporten') - setReport(null) - } finally { - setIsLoading(false) - } - }, [year, month, quarter, periodType, isMockActive, mockReport]) - - useEffect(() => { - fetchReport() - }, [fetchReport]) - - const handleMockImport = useCallback(async (data: ReportData, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => { - await saveMockData(data, meta) - setReport(data) - }, [saveMockData]) - - const handleMockClear = useCallback(async () => { - await clearMockData() - setReport(null) - // Re-fetch from API - setIsLoading(true) - setError(null) - const params = new URLSearchParams({ year: String(year) }) - if (periodType === 'monthly') { - params.set('month', String(month)) - } else { - params.set('quarter', String(quarter)) - } - try { - const res = await fetch(`/api/extensions/export/eu-sales-list/report?${params}`) - if (res.ok) { - const json = await res.json() - setReport(json.data) - } - } catch { /* ignore */ } - setIsLoading(false) - }, [clearMockData, year, month, quarter, periodType]) - - // Sort lines - const sortedLines = useMemo(() => { - if (!report) return [] - const lines = [...report.lines] - lines.sort((a, b) => { - let cmp = 0 - switch (sortField) { - case 'country': - cmp = a.customerCountry.localeCompare(b.customerCountry) - break - case 'vatNumber': - cmp = a.customerVatNumber.localeCompare(b.customerVatNumber) - break - case 'goods': - cmp = a.goodsAmount - b.goodsAmount - break - case 'services': - cmp = a.servicesAmount - b.servicesAmount - break - case 'invoices': - cmp = a.invoiceCount - b.invoiceCount - break - } - return sortDir === 'asc' ? cmp : -cmp - }) - return lines - }, [report, sortField, sortDir]) - - // Toggle sort - const toggleSort = (field: SortField) => { - if (sortField === field) { - setSortDir(d => d === 'asc' ? 'desc' : 'asc') - } else { - setSortField(field) - setSortDir('asc') - } - } - - // Download handler - const handleDownload = async (format: 'csv' | 'xml') => { - setDownloading(format) - const params = new URLSearchParams({ year: String(year), format }) - if (periodType === 'monthly') { - params.set('month', String(month)) - } else { - params.set('quarter', String(quarter)) - } - - try { - const res = await fetch(`/api/extensions/export/eu-sales-list/download?${params}`) - if (!res.ok) { - const json = await res.json() - setError(json.error || 'Kunde inte ladda ner filen') - return - } - - const blob = await res.blob() - const disposition = res.headers.get('Content-Disposition') || '' - const filenameMatch = disposition.match(/filename="(.+)"/) - const filename = filenameMatch ? filenameMatch[1] : `PS_${year}.${format}` - - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) - } catch { - setError('Kunde inte ladda ner filen') - } finally { - setDownloading(null) - } - } - - // Derived counts - const errorCount = report?.warnings.filter(w => w.severity === 'error').length ?? 0 - const warningCount = report?.warnings.filter(w => w.severity === 'warning').length ?? 0 - - if ((isLoading || mockLoading) && !report) { - return - } - - return ( -
- {/* ── Period Selector ─────────────────────────────────── */} -
-
- - -
- -
- - -
- -
- - {periodType === 'monthly' ? ( - - ) : ( - - )} -
- - {/* Download + Import buttons */} -
- - - -
-
- - {/* ── Mock Data Banner ──────────────────────────────── */} - {isMockActive && ( - setImportDialogOpen(true)} - /> - )} - - {/* ── Error state ────────────────────────────────────── */} - {error && ( - - -

{error}

-
-
- )} - - {report && ( - <> - {/* ── KPI Cards ────────────────────────────────────── */} -
- - - - -
- - {/* ── Deadline + Cross-Check Row ────────────────────── */} -
- {/* Deadline */} - - -
- -
-

Inlämningsdeadline

-

- {formatDeadlineDate(report.deadline)} -

-

- {report.daysUntilDeadline > 0 - ? `${report.daysUntilDeadline} dagar kvar` - : report.daysUntilDeadline === 0 - ? 'Deadline idag!' - : `${Math.abs(report.daysUntilDeadline)} dagar försenad` - } -

-
-
-
-
- - {/* Cross-check */} - - -

Avstämning mot huvudbok

- {report.crossCheck ? ( -
- - -
- ) : ( -

- Ingen bokföringsdata tillgänglig för perioden. -

- )} -
-
-
- - {/* ── Warnings ─────────────────────────────────────── */} - {report.warnings.length > 0 && ( - 0 ? 'border-l-destructive' : 'border-l-warning' - )}> - - - - {warningsExpanded && ( -
- {report.warnings.map((w, i) => ( -
- - {w.message} -
- ))} -
- )} -
-
- )} - - {/* ── Customer Table ────────────────────────────────── */} - - - - - Kunder per land - - - - {sortedLines.length === 0 ? ( -

- Inga EU-försäljningar hittades för vald period. -

- ) : ( -
- - - - - Land - - - VAT-nummer - - Kund - - - - Varor (ruta 35) - - - - - - Tjänster (ruta 39) - - - - Fakturor - - - - - {sortedLines.map(line => ( - - - - {line.customerCountry} - - - - {line.customerVatNumber} - - {line.customerName} - - {line.goodsAmount !== 0 ? formatSEK(line.goodsAmount) : '—'} - - - {line.servicesAmount !== 0 ? formatSEK(line.servicesAmount) : '—'} - - - {line.invoiceCount} - - - ))} - - {/* Totals row */} - - - Summa ({sortedLines.length} kunder) - - - {formatSEK(report.totals.goods)} - - - {formatSEK(report.totals.services)} - - - {report.invoiceCount} - - - -
-
- )} -
-
- - {/* ── Filing Info Footer ────────────────────────────── */} -
- - Uppgiftslämnare: {report.reporterName} ({report.reporterVatNumber}) - - - Redovisningsperiod: {report.period.year} - {report.period.month !== undefined && `, ${MONTHS[report.period.month - 1]}`} - {report.period.quarter !== undefined && `, ${QUARTERS[report.period.quarter - 1]}`} - -
- - )} - - {/* ── Mock Data Import Dialog ───────────────────────── */} - - open={importDialogOpen} - onOpenChange={setImportDialogOpen} - csvFields={MOCK_CSV_FIELDS} - parseCsvRows={parseMockCsvRows} - validateReport={validateMockReport} - templateCsvContent={MOCK_CSV_TEMPLATE} - templateFileName="eu-sales-list-template.csv" - onImport={handleMockImport} - /> -
- ) -} - -// ── Sub-components ──────────────────────────────────────────── - -function CrossCheckRow({ - label, - reportTotal, - glTotal, - match, -}: { - label: string - reportTotal: number - glTotal: number - match: boolean -}) { - const diff = Math.round(reportTotal * 100) / 100 - Math.round(glTotal * 100) / 100 - - return ( -
- {match ? ( - - ) : ( - - )} - {label} - - {formatSEK(reportTotal)} SEK - - {!match && ( - - (diff: {diff > 0 ? '+' : ''}{formatSEK(diff)}) - - )} -
- ) -} - -function SortableHead({ - field, - current, - dir, - onSort, - className, - children, -}: { - field: SortField - current: SortField - dir: SortDir - onSort: (field: SortField) => void - className?: string - children: React.ReactNode -}) { - const isActive = current === field - return ( - onSort(field)}> - - {children} - {isActive && ( - dir === 'asc' - ? - : - )} - - - ) -} diff --git a/components/extensions/export/IntrastatWorkspace.tsx b/components/extensions/export/IntrastatWorkspace.tsx deleted file mode 100644 index 22bda841..00000000 --- a/components/extensions/export/IntrastatWorkspace.tsx +++ /dev/null @@ -1,747 +0,0 @@ -'use client' - -import { useState, useEffect, useMemo, useCallback } from 'react' -import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' -import { useExtensionData } from '@/lib/extensions/use-extension-data' -import { useMockData } from '@/lib/extensions/use-mock-data' -import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' -import MockDataBanner from '@/components/extensions/shared/MockDataBanner' -import MockDataImportDialog from '@/components/extensions/shared/MockDataImportDialog' -import type { CsvFieldDef } from '@/components/extensions/shared/MockDataImportDialog' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Progress } from '@/components/ui/progress' -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' -import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table' -import { - Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, -} from '@/components/ui/dialog' -import { - AlertTriangle, Plus, Pencil, Trash2, FileSpreadsheet, Clock, - ChevronDown, ChevronUp, Package, FlaskConical, -} from 'lucide-react' -import { cn } from '@/lib/utils' - -// ── Types ───────────────────────────────────────────────────── - -interface IntrastatLine { - cnCode: string - partnerCountry: string - countryOfOrigin: string - transactionNature: string - deliveryTerms: string - invoicedValue: number - netMass: number - supplementaryUnit: number | null - supplementaryUnitType: string | null - partnerVatId: string -} - -interface ThresholdStatus { - cumulativeValue: number - threshold: number - isObligated: boolean - percentageUsed: number -} - -interface IntrastatWarning { - type: string - severity: 'error' | 'warning' - invoiceId?: string - invoiceNumber?: string - productId?: string - message: string -} - -interface ReportData { - period: { year: number; month: number } - reporterVatNumber: string - reporterName: string - lines: IntrastatLine[] - totals: { invoicedValue: number; netMass: number; lineCount: number } - thresholdStatus: ThresholdStatus - warnings: IntrastatWarning[] - invoiceCount: number -} - -interface ProductRecord { - key: string - productId: string - description: string - cn_code: string | null - net_weight_kg: number | null - country_of_origin: string -} - -interface ProductForm { - productId: string - description: string - cnCode: string - netWeightKg: string - countryOfOrigin: string -} - -// ── Helpers ─────────────────────────────────────────────────── - -function formatSEK(amount: number): string { - return Math.round(amount).toLocaleString('sv-SE') -} - -const MONTHS = [ - 'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni', - 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December', -] - -function currentYear(): number { return new Date().getFullYear() } -function currentMonth(): number { return new Date().getMonth() + 1 } - -const EMPTY_PRODUCT: ProductForm = { - productId: '', description: '', cnCode: '', netWeightKg: '', countryOfOrigin: 'SE', -} - -// ── Mock Data Config ────────────────────────────────────────── - -const MOCK_CSV_FIELDS: CsvFieldDef[] = [ - { key: 'cnCode', label: 'CN-kod', required: true }, - { key: 'partnerCountry', label: 'Partnerland', required: true }, - { key: 'countryOfOrigin', label: 'Ursprungsland' }, - { key: 'transactionNature', label: 'Transaktionstyp' }, - { key: 'deliveryTerms', label: 'Leveransvillkor' }, - { key: 'invoicedValue', label: 'Fakturerat värde (SEK)', required: true }, - { key: 'netMass', label: 'Nettovikt (kg)' }, - { key: 'partnerVatId', label: 'Partner VAT-ID' }, -] - -const MOCK_CSV_TEMPLATE = `cnCode;partnerCountry;countryOfOrigin;transactionNature;deliveryTerms;invoicedValue;netMass;partnerVatId -72163100;DE;SE;11;DAP;245000;4500;DE123456789 -84713000;FR;CN;11;EXW;128000;85;FR987654321 -39269090;NL;SE;11;FCA;67000;320;NL456789012` - -function parseMockCsvRows(rows: Record[]): ReportData { - const lines: IntrastatLine[] = rows.map(r => ({ - cnCode: r.cnCode || '00000000', - partnerCountry: r.partnerCountry || '', - countryOfOrigin: r.countryOfOrigin || 'SE', - transactionNature: r.transactionNature || '11', - deliveryTerms: r.deliveryTerms || 'DAP', - invoicedValue: parseFloat(r.invoicedValue || '0') || 0, - netMass: parseFloat(r.netMass || '0') || 0, - supplementaryUnit: null, - supplementaryUnitType: null, - partnerVatId: r.partnerVatId || '', - })) - - const invoicedValue = lines.reduce((s, l) => s + l.invoicedValue, 0) - const netMass = lines.reduce((s, l) => s + l.netMass, 0) - - return { - period: { year: new Date().getFullYear(), month: new Date().getMonth() + 1 }, - reporterVatNumber: 'SE000000000001', - reporterName: 'Testdata', - lines, - totals: { invoicedValue, netMass, lineCount: lines.length }, - thresholdStatus: { - cumulativeValue: invoicedValue, - threshold: 9000000, - isObligated: invoicedValue >= 9000000, - percentageUsed: Math.round(invoicedValue / 9000000 * 100), - }, - warnings: [], - invoiceCount: lines.length, - } -} - -function validateMockReport(data: unknown): { valid: boolean; error?: string } { - if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' } - const obj = data as Record - if (!Array.isArray(obj.lines)) return { valid: false, error: 'Fältet "lines" saknas eller är inte en array' } - if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' } - return { valid: true } -} - -// ── Component ───────────────────────────────────────────────── - -export default function IntrastatWorkspace({ userId }: WorkspaceComponentProps) { - void userId - - // Mock data - const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData('export', 'intrastat') - const [importDialogOpen, setImportDialogOpen] = useState(false) - - const [year, setYear] = useState(currentYear()) - const [month, setMonth] = useState(currentMonth()) - - const [report, setReport] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - const [downloading, setDownloading] = useState(false) - - const [warningsExpanded, setWarningsExpanded] = useState(false) - - // Product CRUD - const { data: extData, save, remove, isLoading: productsLoading } = useExtensionData('export', 'intrastat') - const [productDialogOpen, setProductDialogOpen] = useState(false) - const [editingProduct, setEditingProduct] = useState(null) - const [productForm, setProductForm] = useState(EMPTY_PRODUCT) - const [deleteConfirm, setDeleteConfirm] = useState(null) - - const years = useMemo(() => { - const cy = currentYear() - return [cy, cy - 1, cy - 2] - }, []) - - // Parse products from extension data - const products: ProductRecord[] = useMemo(() => { - return extData - .filter(d => d.key.startsWith('product:')) - .map(d => ({ - key: d.key, - productId: d.key.replace('product:', ''), - description: String(d.value.description || ''), - cn_code: d.value.cn_code ? String(d.value.cn_code) : null, - net_weight_kg: d.value.net_weight_kg !== undefined ? Number(d.value.net_weight_kg) : null, - country_of_origin: String(d.value.country_of_origin || 'SE'), - })) - .sort((a, b) => a.description.localeCompare(b.description)) - }, [extData]) - - // Fetch report - const fetchReport = useCallback(async () => { - if (isMockActive && mockReport) { - setReport(mockReport) - setIsLoading(false) - return - } - - setIsLoading(true) - setError(null) - - try { - const params = new URLSearchParams({ year: String(year), month: String(month) }) - const res = await fetch(`/api/extensions/export/intrastat/report?${params}`) - if (!res.ok) { - const json = await res.json() - setError(json.error || 'Kunde inte generera rapporten') - setReport(null) - return - } - const json = await res.json() - setReport(json.data) - } catch { - setError('Nätverksfel') - setReport(null) - } finally { - setIsLoading(false) - } - }, [year, month, isMockActive, mockReport]) - - useEffect(() => { - fetchReport() - }, [fetchReport]) - - const handleMockImport = useCallback(async (data: ReportData, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => { - await saveMockData(data, meta) - setReport(data) - }, [saveMockData]) - - const handleMockClear = useCallback(async () => { - await clearMockData() - setReport(null) - setIsLoading(true) - try { - const params = new URLSearchParams({ year: String(year), month: String(month) }) - const res = await fetch(`/api/extensions/export/intrastat/report?${params}`) - if (res.ok) { - const json = await res.json() - setReport(json.data) - } - } catch { /* ignore */ } - setIsLoading(false) - }, [clearMockData, year, month]) - - // Product CRUD handlers - const openNewProduct = () => { - setEditingProduct(null) - setProductForm(EMPTY_PRODUCT) - setProductDialogOpen(true) - } - - const openEditProduct = (productId: string) => { - const product = products.find(p => p.productId === productId) - if (!product) return - setEditingProduct(productId) - setProductForm({ - productId, - description: product.description, - cnCode: product.cn_code || '', - netWeightKg: product.net_weight_kg !== null ? String(product.net_weight_kg) : '', - countryOfOrigin: product.country_of_origin, - }) - setProductDialogOpen(true) - } - - const saveProduct = async () => { - const id = editingProduct || productForm.productId.trim() - if (!id) return - - await save(`product:${id}`, { - description: productForm.description.trim(), - cn_code: productForm.cnCode.trim() || null, - net_weight_kg: productForm.netWeightKg ? parseFloat(productForm.netWeightKg) : null, - country_of_origin: productForm.countryOfOrigin || 'SE', - }) - - setProductDialogOpen(false) - // Refresh report to pick up new product metadata - fetchReport() - } - - const deleteProduct = async (productId: string) => { - await remove(`product:${productId}`) - setDeleteConfirm(null) - fetchReport() - } - - // Download handler - const handleDownload = async () => { - setDownloading(true) - try { - const params = new URLSearchParams({ year: String(year), month: String(month) }) - const res = await fetch(`/api/extensions/export/intrastat/download?${params}`) - if (!res.ok) { - setError('Kunde inte ladda ner filen') - return - } - const blob = await res.blob() - const disposition = res.headers.get('Content-Disposition') || '' - const match = disposition.match(/filename="(.+)"/) - const filename = match ? match[1] : `INTRASTAT_${year}-${String(month).padStart(2, '0')}.csv` - - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) - } catch { - setError('Kunde inte ladda ner filen') - } finally { - setDownloading(false) - } - } - - const errorCount = report?.warnings.filter(w => w.severity === 'error').length ?? 0 - const warningCount = report?.warnings.filter(w => w.severity === 'warning').length ?? 0 - - if ((isLoading || productsLoading || mockLoading) && !report) { - return - } - - return ( -
- {/* ── Period Selector ─────────────────────────────────── */} -
-
- - -
-
- - -
-
- - -
-
- - {/* ── Mock Data Banner ──────────────────────────────── */} - {isMockActive && ( - setImportDialogOpen(true)} - /> - )} - - {error && ( - - -

{error}

-
-
- )} - - {report && ( - <> - {/* ── Threshold Progress ───────────────────────────── */} - - -
-

Tröskelvärde Intrastat (utförsel)

- - {report.thresholdStatus.isObligated ? 'Obligatorisk rapportering' : 'Frivillig rapportering'} - -
- -
- - Ackumulerat (12 mån): {formatSEK(report.thresholdStatus.cumulativeValue)} SEK - - - {report.thresholdStatus.percentageUsed}% av {formatSEK(report.thresholdStatus.threshold)} SEK - -
-
-
- - {/* ── KPI Row ──────────────────────────────────────── */} -
- - -

Fakturerat värde

-

{formatSEK(report.totals.invoicedValue)}

-

SEK

-
-
- - -

Nettovikt

-

{report.totals.netMass.toLocaleString('sv-SE')}

-

kg

-
-
- - -

Deklarationsrader

-

{report.totals.lineCount}

-

{report.invoiceCount} fakturor

-
-
-
- - {/* ── Product Registry ─────────────────────────────── */} - - -
- - - Produktregister - - -
-
- - {products.length === 0 ? ( -

- Inga produkter registrerade. Lägg till produkter med CN-kod och vikt för att generera Intrastat-deklarationer. -

- ) : ( -
- - - - Produkt - CN-kod - Vikt (kg) - Ursprung - - - - - {products.map(p => ( - - -
- {p.description || p.productId} - {p.productId !== p.description && ( - ({p.productId}) - )} -
-
- - {p.cn_code ? ( - {p.cn_code} - ) : ( - - Saknas - - )} - - - {p.net_weight_kg !== null - ? String(p.net_weight_kg) - : — - } - - - - {p.country_of_origin} - - - -
- - -
-
-
- ))} -
-
-
- )} -
-
- - {/* ── Declaration Table ─────────────────────────────── */} - - - - Deklaration {MONTHS[month - 1]} {year} - - - - {report.lines.length === 0 ? ( -

- Inga EU-varuförsäljningar hittades för perioden. -

- ) : ( -
- - - - CN-kod - Land - Urspr. - Värde (SEK) - Vikt (kg) - Partner-VAT - - - - {report.lines.map((line, i) => ( - - - - {line.cnCode} - - - - {line.partnerCountry} - - {line.countryOfOrigin} - - {formatSEK(line.invoicedValue)} - - - {line.netMass > 0 ? line.netMass.toLocaleString('sv-SE') : '—'} - - {line.partnerVatId || '—'} - - ))} - - Summa - - {formatSEK(report.totals.invoicedValue)} - - - {report.totals.netMass.toLocaleString('sv-SE')} - - - - -
-
- )} -
-
- - {/* ── Warnings ─────────────────────────────────────── */} - {report.warnings.length > 0 && ( - 0 ? 'border-l-destructive' : 'border-l-warning')}> - - - {warningsExpanded && ( -
- {report.warnings.map((w, i) => ( -
- - {w.message} -
- ))} -
- )} -
-
- )} - - {/* ── Deadline Footer ───────────────────────────────── */} -
- - - Deadline: 10:e arbetsdagen efter redovisningsperiodens slut - -
- - )} - - {/* ── Mock Data Import Dialog ───────────────────────── */} - - open={importDialogOpen} - onOpenChange={setImportDialogOpen} - csvFields={MOCK_CSV_FIELDS} - parseCsvRows={parseMockCsvRows} - validateReport={validateMockReport} - templateCsvContent={MOCK_CSV_TEMPLATE} - templateFileName="intrastat-template.csv" - onImport={handleMockImport} - /> - - {/* ── Product Dialog ────────────────────────────────────── */} - - - - {editingProduct ? 'Redigera produkt' : 'Lägg till produkt'} - -
- {!editingProduct && ( -
- - setProductForm(f => ({ ...f, productId: e.target.value }))} - placeholder="T.ex. STALBALK-M8" - /> -
- )} -
- - setProductForm(f => ({ ...f, description: e.target.value }))} - placeholder="T.ex. Stålbalk M8 200mm" - /> -
-
- - setProductForm(f => ({ ...f, cnCode: e.target.value.replace(/\D/g, '').slice(0, 8) }))} - placeholder="T.ex. 72163100" - maxLength={8} - className="font-mono" - /> -
-
-
- - setProductForm(f => ({ ...f, netWeightKg: e.target.value }))} - placeholder="45.5" - /> -
-
- - setProductForm(f => ({ ...f, countryOfOrigin: e.target.value.toUpperCase().slice(0, 2) }))} - placeholder="SE" - maxLength={2} - /> -
-
-
- - - - -
-
- - {/* ── Delete Confirmation ───────────────────────────────── */} - setDeleteConfirm(null)}> - - - Ta bort produkt? - -

- Är du säker på att du vill ta bort produkten “{deleteConfirm}”? Denna åtgärd kan inte ångras. -

- - - - -
-
-
- ) -} diff --git a/components/extensions/export/VatMonitorWorkspace.tsx b/components/extensions/export/VatMonitorWorkspace.tsx deleted file mode 100644 index 2cccc7e3..00000000 --- a/components/extensions/export/VatMonitorWorkspace.tsx +++ /dev/null @@ -1,656 +0,0 @@ -'use client' - -import { useState, useEffect, useMemo, useCallback } from 'react' -import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' -import { useMockData } from '@/lib/extensions/use-mock-data' -import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' -import MockDataBanner from '@/components/extensions/shared/MockDataBanner' -import MockDataImportDialog from '@/components/extensions/shared/MockDataImportDialog' -import type { CsvFieldDef } from '@/components/extensions/shared/MockDataImportDialog' -import KPICard from '@/components/extensions/shared/KPICard' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' -import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table' -import { - AlertTriangle, CheckCircle2, ChevronDown, ChevronUp, - ArrowUp, ArrowDown, Minus, BarChart3, FlaskConical, -} from 'lucide-react' -import { cn } from '@/lib/utils' - -// ── Types ───────────────────────────────────────────────────── - -interface VatBoxData { - boxNumber: string - label: string - amount: number - accounts: string[] -} - -interface RevenueBreakdown { - domestic: { amount: number; percentage: number } - euGoods: { amount: number; percentage: number } - euServices: { amount: number; percentage: number } - exportGoods: { amount: number; percentage: number } - exportServices: { amount: number; percentage: number } - triangular: { amount: number; percentage: number } - totalRevenue: number -} - -interface PeriodDelta { - current: number - previous: number - change: number - changePercent: number | null -} - -interface PeriodComparison { - domestic: PeriodDelta - euGoods: PeriodDelta - euServices: PeriodDelta - exportGoods: PeriodDelta - exportServices: PeriodDelta - triangular: PeriodDelta - totalRevenue: PeriodDelta - netVat: PeriodDelta -} - -interface VatMonitorWarning { - type: string - severity: 'error' | 'warning' - invoiceId?: string - invoiceNumber?: string - customerName?: string - message: string -} - -interface ReportData { - period: { year: number; month?: number; quarter?: number } - boxes: VatBoxData[] - revenueBreakdown: RevenueBreakdown - vatSummary: { - outputVat25: number - outputVat12: number - outputVat6: number - totalOutputVat: number - inputVat: number - netVat: number - isRefund: boolean - } - warnings: VatMonitorWarning[] - comparison: PeriodComparison | null -} - -// ── Helpers ─────────────────────────────────────────────────── - -function formatSEK(amount: number): string { - return Math.round(amount).toLocaleString('sv-SE') -} - -const MONTHS = [ - 'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni', - 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December', -] - -const QUARTERS = ['Q1 (jan–mar)', 'Q2 (apr–jun)', 'Q3 (jul–sep)', 'Q4 (okt–dec)'] - -function currentYear(): number { - return new Date().getFullYear() -} - -function currentMonth(): number { - return new Date().getMonth() + 1 -} - -function currentQuarter(): number { - return Math.ceil(currentMonth() / 3) -} - -// Revenue breakdown cards config -const REVENUE_CARDS: { key: keyof Omit; label: string; compKey: keyof PeriodComparison }[] = [ - { key: 'domestic', label: 'Inrikes', compKey: 'domestic' }, - { key: 'euGoods', label: 'EU varor', compKey: 'euGoods' }, - { key: 'euServices', label: 'EU tjänster', compKey: 'euServices' }, - { key: 'exportGoods', label: 'Export varor', compKey: 'exportGoods' }, - { key: 'exportServices', label: 'Export tjänster', compKey: 'exportServices' }, - { key: 'triangular', label: 'Trepartshandel', compKey: 'triangular' }, -] - -// Box display order (only show relevant ones) -const DISPLAY_BOX_ORDER = ['05', '10', '11', '12', '35', '36', '38', '39', '40', '48', '49'] - -// ── Mock Data Config ────────────────────────────────────────── - -const MOCK_CSV_FIELDS: CsvFieldDef[] = [ - { key: 'boxNumber', label: 'Ruta', required: true }, - { key: 'label', label: 'Beskrivning', required: true }, - { key: 'amount', label: 'Belopp (SEK)', required: true }, - { key: 'accounts', label: 'Konton (kommaseparerade)' }, -] - -const MOCK_CSV_TEMPLATE = `boxNumber;label;amount;accounts -05;Momspliktiga intäkter;500000;3001,3002,3003 -10;Utgående moms 25%;100000;2611 -11;Utgående moms 12%;6000;2621 -12;Utgående moms 6%;3000;2631 -35;Varuförsäljning EU;75000;3305 -36;Tjänsteförsäljning EU;45000;3308 -38;Exportförsäljning;30000;3305 -39;Omvänd skattskyldighet;20000; -40;Inköp varor EU;60000; -48;Ingående moms;65000;2641 -49;Moms att betala;44000;` - -function parseMockCsvRows(rows: Record[]): ReportData { - const boxes: VatBoxData[] = rows.map(r => ({ - boxNumber: r.boxNumber || '', - label: r.label || '', - amount: parseFloat(r.amount || '0') || 0, - accounts: r.accounts ? r.accounts.split(',').map(a => a.trim()) : [], - })) - - // Derive revenue breakdown from box values - const getBox = (num: string) => boxes.find(b => b.boxNumber === num)?.amount || 0 - const domestic = getBox('05') - const euGoods = getBox('35') - const euServices = getBox('36') - const exportGoods = getBox('38') - const exportServices = 0 - const triangular = getBox('39') - const totalRevenue = domestic + euGoods + euServices + exportGoods + exportServices + triangular - - const revenueBreakdown: RevenueBreakdown = { - domestic: { amount: domestic, percentage: totalRevenue > 0 ? Math.round(domestic / totalRevenue * 100) : 0 }, - euGoods: { amount: euGoods, percentage: totalRevenue > 0 ? Math.round(euGoods / totalRevenue * 100) : 0 }, - euServices: { amount: euServices, percentage: totalRevenue > 0 ? Math.round(euServices / totalRevenue * 100) : 0 }, - exportGoods: { amount: exportGoods, percentage: totalRevenue > 0 ? Math.round(exportGoods / totalRevenue * 100) : 0 }, - exportServices: { amount: exportServices, percentage: 0 }, - triangular: { amount: triangular, percentage: totalRevenue > 0 ? Math.round(triangular / totalRevenue * 100) : 0 }, - totalRevenue, - } - - const outputVat25 = getBox('10') - const outputVat12 = getBox('11') - const outputVat6 = getBox('12') - const inputVat = getBox('48') - const netVat = getBox('49') - - return { - period: { year: new Date().getFullYear(), month: new Date().getMonth() + 1 }, - boxes, - revenueBreakdown, - vatSummary: { - outputVat25, - outputVat12, - outputVat6, - totalOutputVat: outputVat25 + outputVat12 + outputVat6, - inputVat, - netVat, - isRefund: netVat < 0, - }, - warnings: [], - comparison: null, - } -} - -function validateMockReport(data: unknown): { valid: boolean; error?: string } { - if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' } - const obj = data as Record - if (!Array.isArray(obj.boxes)) return { valid: false, error: 'Fältet "boxes" saknas eller är inte en array' } - if (!obj.revenueBreakdown || typeof obj.revenueBreakdown !== 'object') { - return { valid: false, error: 'Fältet "revenueBreakdown" saknas' } - } - if (!obj.vatSummary || typeof obj.vatSummary !== 'object') { - return { valid: false, error: 'Fältet "vatSummary" saknas' } - } - return { valid: true } -} - -// ── Component ───────────────────────────────────────────────── - -export default function VatMonitorWorkspace({ userId }: WorkspaceComponentProps) { - void userId - - // Mock data - const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData('export', 'vat-monitor') - const [importDialogOpen, setImportDialogOpen] = useState(false) - - const [year, setYear] = useState(currentYear()) - const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('monthly') - const [month, setMonth] = useState(currentMonth()) - const [quarter, setQuarter] = useState(currentQuarter()) - const [compareEnabled, setCompareEnabled] = useState(true) - - const [report, setReport] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - - const [warningsExpanded, setWarningsExpanded] = useState(false) - - const years = useMemo(() => { - const cy = currentYear() - return [cy, cy - 1, cy - 2] - }, []) - - const fetchReport = useCallback(async () => { - if (isMockActive && mockReport) { - setReport(mockReport) - setIsLoading(false) - return - } - - setIsLoading(true) - setError(null) - - const params = new URLSearchParams({ year: String(year) }) - if (periodType === 'monthly') { - params.set('month', String(month)) - } else { - params.set('quarter', String(quarter)) - } - if (compareEnabled) { - params.set('compare', 'previous') - } - - try { - const res = await fetch(`/api/extensions/export/vat-monitor/report?${params}`) - if (!res.ok) { - const json = await res.json() - setError(json.error || 'Kunde inte generera rapporten') - setReport(null) - return - } - const json = await res.json() - setReport(json.data) - } catch { - setError('Nätverksfel — kunde inte hämta rapporten') - setReport(null) - } finally { - setIsLoading(false) - } - }, [year, month, quarter, periodType, compareEnabled, isMockActive, mockReport]) - - useEffect(() => { - fetchReport() - }, [fetchReport]) - - const handleMockImport = useCallback(async (data: ReportData, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => { - await saveMockData(data, meta) - setReport(data) - }, [saveMockData]) - - const handleMockClear = useCallback(async () => { - await clearMockData() - setReport(null) - setIsLoading(true) - setError(null) - const params = new URLSearchParams({ year: String(year) }) - if (periodType === 'monthly') { - params.set('month', String(month)) - } else { - params.set('quarter', String(quarter)) - } - if (compareEnabled) { - params.set('compare', 'previous') - } - try { - const res = await fetch(`/api/extensions/export/vat-monitor/report?${params}`) - if (res.ok) { - const json = await res.json() - setReport(json.data) - } - } catch { /* ignore */ } - setIsLoading(false) - }, [clearMockData, year, month, quarter, periodType, compareEnabled]) - - const errorCount = report?.warnings.filter(w => w.severity === 'error').length ?? 0 - const warningCount = report?.warnings.filter(w => w.severity === 'warning').length ?? 0 - - // Filter boxes to only show ones in display order that have data or are always shown - const displayBoxes = useMemo(() => { - if (!report) return [] - const boxMap = new Map(report.boxes.map(b => [b.boxNumber, b])) - return DISPLAY_BOX_ORDER - .map(num => boxMap.get(num)) - .filter((b): b is VatBoxData => b !== undefined) - }, [report]) - - if ((isLoading || mockLoading) && !report) { - return - } - - return ( -
- {/* ── Period Selector ─────────────────────────────────── */} -
-
- - -
- -
- - -
- -
- - {periodType === 'monthly' ? ( - - ) : ( - - )} -
- -
- - -
-
- - {/* ── Mock Data Banner ──────────────────────────────── */} - {isMockActive && ( - setImportDialogOpen(true)} - /> - )} - - {/* ── Error state ────────────────────────────────────── */} - {error && ( - - -

{error}

-
-
- )} - - {report && ( - <> - {/* ── Revenue Breakdown Cards ──────────────────────── */} -
-

Intäktsfördelning

-
- {REVENUE_CARDS.map(({ key, label, compKey }) => { - const data = report.revenueBreakdown[key] - const delta = report.comparison?.[compKey] - return ( - - -

{label}

-

- {formatSEK(data.amount)} -

-
- - {data.percentage}% - - {delta && } -
-
-
- ) - })} -
-
- - {/* ── VAT Summary + Moms Box Table ─────────────────── */} -
- {/* VAT Summary cards */} -
-

Moms

- - - - -

- {report.vatSummary.isRefund ? 'Moms att få tillbaka' : 'Moms att betala'} -

-
- - {formatSEK(Math.abs(report.vatSummary.netVat))} - - SEK -
- {report.comparison && ( -
- -
- )} -
-
-
- - {/* Momsdeklaration preview table */} -
-

- Momsdeklaration (förhandsvisning) -

- - - - - - Ruta - Beskrivning - Belopp (SEK) - - - - {displayBoxes.map(box => { - const isNetVat = box.boxNumber === '49' - const isInputVat = box.boxNumber === '48' - return ( - - - - {box.boxNumber} - - - {box.label} - - {formatSEK(box.amount)} - - - ) - })} - {displayBoxes.length === 0 && ( - - - Ingen bokföringsdata för perioden. - - - )} - -
-
-
-
-
- - {/* ── Warnings ─────────────────────────────────────── */} - {report.warnings.length > 0 && ( - 0 ? 'border-l-destructive' : 'border-l-warning' - )}> - - - - {warningsExpanded && ( -
- {report.warnings.map((w, i) => ( -
- - {w.message} -
- ))} -
- )} -
-
- )} - - {/* ── Total Revenue Footer ─────────────────────────── */} -
- - Total omsättning: {formatSEK(report.revenueBreakdown.totalRevenue)} SEK - - - {report.period.year} - {report.period.month !== undefined && `, ${MONTHS[report.period.month - 1]}`} - {report.period.quarter !== undefined && `, ${QUARTERS[report.period.quarter - 1]}`} - -
- - )} - - {/* ── Mock Data Import Dialog ───────────────────────── */} - - open={importDialogOpen} - onOpenChange={setImportDialogOpen} - csvFields={MOCK_CSV_FIELDS} - parseCsvRows={parseMockCsvRows} - validateReport={validateMockReport} - templateCsvContent={MOCK_CSV_TEMPLATE} - templateFileName="vat-monitor-template.csv" - onImport={handleMockImport} - /> -
- ) -} - -// ── Sub-components ──────────────────────────────────────────── - -function DeltaIndicator({ delta, invert = false }: { delta: PeriodDelta; invert?: boolean }) { - if (delta.changePercent === null || delta.change === 0) { - return ( - - - - ) - } - - // For most metrics, positive = green (revenue growing) - // For netVat (invert=true), positive = red (paying more VAT) - const isPositive = delta.change > 0 - const isGood = invert ? !isPositive : isPositive - - return ( - - {isPositive - ? - : - } - {delta.changePercent > 0 ? '+' : ''}{delta.changePercent}% - - ) -} diff --git a/components/extensions/general/DocumentInboxWorkspace.tsx b/components/extensions/general/DocumentInboxWorkspace.tsx index 47fd9e85..dbbd1a02 100644 --- a/components/extensions/general/DocumentInboxWorkspace.tsx +++ b/components/extensions/general/DocumentInboxWorkspace.tsx @@ -45,7 +45,7 @@ export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentPro const fetchItems = useCallback(async () => { try { - const res = await fetch('/api/extensions/invoice-inbox/inbox') + const res = await fetch('/api/extensions/ext/invoice-inbox/inbox') if (res.ok) { const { data } = await res.json() setItems(data ?? []) @@ -59,7 +59,7 @@ export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentPro const fetchSettings = useCallback(async () => { try { - const res = await fetch('/api/extensions/invoice-inbox/settings') + const res = await fetch('/api/extensions/ext/invoice-inbox/settings') if (res.ok) { const { data } = await res.json() if (data) setSettings(data) @@ -115,7 +115,7 @@ export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentPro for (let i = 0; i < 20; i++) { await new Promise((r) => setTimeout(r, 3000)) try { - const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`) + const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`) if (!res.ok) continue const { data } = await res.json() if (data && data.status !== 'processing') { @@ -141,7 +141,7 @@ export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentPro const body: Record = {} if (supplierId) body.supplier_id = supplierId - const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/confirm`, { + const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -157,7 +157,7 @@ export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentPro } async function handleReject(itemId: string) { - const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`, { + const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`, { method: 'DELETE', }) @@ -175,7 +175,7 @@ export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentPro ) setSelectedItem(null) - const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/process`, { + const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/process`, { method: 'POST', }) @@ -195,7 +195,7 @@ export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentPro } async function handleSaveSettings(updated: InvoiceInboxSettings) { - const res = await fetch('/api/extensions/invoice-inbox/settings', { + const res = await fetch('/api/extensions/ext/invoice-inbox/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated), diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 879386f7..50e1895c 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -45,7 +45,7 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp const fetchItems = useCallback(async () => { try { - const res = await fetch('/api/extensions/invoice-inbox/inbox') + const res = await fetch('/api/extensions/ext/invoice-inbox/inbox') if (res.ok) { const { data } = await res.json() setItems(data ?? []) @@ -59,7 +59,7 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp const fetchSettings = useCallback(async () => { try { - const res = await fetch('/api/extensions/invoice-inbox/settings') + const res = await fetch('/api/extensions/ext/invoice-inbox/settings') if (res.ok) { const { data } = await res.json() if (data) setSettings(data) @@ -99,7 +99,7 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp for (let i = 0; i < 20; i++) { await new Promise((r) => setTimeout(r, 3000)) try { - const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`) + const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`) if (!res.ok) continue const { data } = await res.json() if (data && data.status !== 'processing') { @@ -122,7 +122,7 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp const body: Record = {} if (supplierId) body.supplier_id = supplierId - const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/confirm`, { + const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -138,7 +138,7 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp } async function handleReject(itemId: string) { - const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`, { + const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`, { method: 'DELETE', }) @@ -156,7 +156,7 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp ) setSelectedItem(null) - const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/process`, { + const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/process`, { method: 'POST', }) @@ -172,7 +172,7 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp } async function handleSaveSettings(updated: InvoiceInboxSettings) { - const res = await fetch('/api/extensions/invoice-inbox/settings', { + const res = await fetch('/api/extensions/ext/invoice-inbox/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated), diff --git a/components/extensions/general/document-inbox/ReceiptInboxDetail.tsx b/components/extensions/general/document-inbox/ReceiptInboxDetail.tsx index 3c883518..3c69f334 100644 --- a/components/extensions/general/document-inbox/ReceiptInboxDetail.tsx +++ b/components/extensions/general/document-inbox/ReceiptInboxDetail.tsx @@ -70,7 +70,7 @@ export default function ReceiptInboxDetail({ if (!item?.linked_receipt_id) return setLoading(true) try { - const res = await fetch(`/api/extensions/receipt-ocr/${item.linked_receipt_id}`) + const res = await fetch(`/api/extensions/ext/receipt-ocr/${item.linked_receipt_id}`) if (res.ok) { const { data } = await res.json() if (data?.line_items) { @@ -129,7 +129,7 @@ export default function ReceiptInboxDetail({ } const res = await fetch( - `/api/extensions/invoice-inbox/inbox/${item.id}/confirm-receipt`, + `/api/extensions/ext/invoice-inbox/inbox/${item.id}/confirm-receipt`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/components/extensions/general/invoice-inbox/InboxUploadZone.tsx b/components/extensions/general/invoice-inbox/InboxUploadZone.tsx index 15ca5343..1354424a 100644 --- a/components/extensions/general/invoice-inbox/InboxUploadZone.tsx +++ b/components/extensions/general/invoice-inbox/InboxUploadZone.tsx @@ -70,7 +70,7 @@ export default function InboxUploadZone({ } } - const res = await fetch('/api/extensions/invoice-inbox/inbox', { + const res = await fetch('/api/extensions/ext/invoice-inbox/inbox', { method: 'POST', body: formData, }) diff --git a/components/extensions/hotel/OccupancyWorkspace.tsx b/components/extensions/hotel/OccupancyWorkspace.tsx deleted file mode 100644 index 586005c0..00000000 --- a/components/extensions/hotel/OccupancyWorkspace.tsx +++ /dev/null @@ -1,578 +0,0 @@ -'use client' - -import { useState, useMemo } from 'react' -import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' -import { useExtensionData } from '@/lib/extensions/use-extension-data' -import KPICard from '@/components/extensions/shared/KPICard' -import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter' -import DataEntryForm from '@/components/extensions/shared/DataEntryForm' -import SetupPrompt from '@/components/extensions/shared/SetupPrompt' -import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' -import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog' -import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Button } from '@/components/ui/button' -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' -import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table' -import { cn } from '@/lib/utils' -import { Pencil, Trash2, ArrowUp, ArrowDown, Minus, Settings } from 'lucide-react' - -const OOO_REASONS = ['Underhall', 'Renovering', 'Blockerat', 'Ovrigt'] as const -type OooReason = typeof OOO_REASONS[number] - -function getOccupancyColor(pct: number): string { - if (pct >= 80) return 'bg-green-500' - if (pct >= 50) return 'bg-yellow-500' - if (pct > 0) return 'bg-red-500' - return 'bg-muted' -} - -function computePreviousPeriod(start: string, end: string): { start: string; end: string } { - const startDate = new Date(start + 'T00:00:00') - const endDate = new Date(end + 'T00:00:00') - const durationMs = endDate.getTime() - startDate.getTime() - const prevEnd = new Date(startDate.getTime() - 1) - const prevStart = new Date(prevEnd.getTime() - durationMs) - return { - start: prevStart.toISOString().slice(0, 10), - end: prevEnd.toISOString().slice(0, 10), - } -} - -function DeltaArrow({ current, previous }: { current: number; previous: number }) { - const delta = Math.round((current - previous) * 100) / 100 - if (delta === 0 || (previous === 0 && current === 0)) { - return ( - - - 0 pp - - ) - } - // For occupancy: higher is better, so positive delta = green (improving) - const improving = delta > 0 - return ( - - {delta > 0 - ? - : - } - {delta > 0 ? '+' : ''}{delta} pp - - ) -} - -interface DailyEntry { - date: string - roomsOccupied: number - roomsOutOfOrder: number - reason?: OooReason -} - -export default function OccupancyWorkspace({}: WorkspaceComponentProps) { - const now = new Date() - const [dateRange, setDateRange] = useState({ - start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10), - end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10), - }) - - const prevPeriod = useMemo( - () => computePreviousPeriod(dateRange.start, dateRange.end), - [dateRange.start, dateRange.end] - ) - - const { data, save, remove, refresh, isLoading } = useExtensionData('hotel', 'occupancy') - const settings = data.find(d => d.key === 'settings')?.value as { totalRooms?: number } | undefined - const totalRooms = settings?.totalRooms ?? 0 - - const allDailyEntries = useMemo(() => - data.filter(d => d.key.startsWith('daily:')) - .map(d => ({ - date: d.key.replace('daily:', ''), - ...(d.value as { roomsOccupied: number; roomsOutOfOrder: number; reason?: OooReason }), - })) - , [data]) - - const entries = useMemo(() => - allDailyEntries - .filter(e => e.date >= dateRange.start && e.date <= dateRange.end) - .sort((a, b) => b.date.localeCompare(a.date)) - , [allDailyEntries, dateRange]) - - const prevEntries = useMemo(() => - allDailyEntries - .filter(e => e.date >= prevPeriod.start && e.date <= prevPeriod.end) - , [allDailyEntries, prevPeriod]) - - // Form state - const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10)) - const [roomsOccupied, setRoomsOccupied] = useState('') - const [roomsOutOfOrder, setRoomsOutOfOrder] = useState('') - const [oooReason, setOooReason] = useState('Underhall') - const [isSubmitting, setIsSubmitting] = useState(false) - - // Edit dialog state - const [editDialogOpen, setEditDialogOpen] = useState(false) - const [editDate, setEditDate] = useState('') - const [editOccupied, setEditOccupied] = useState('') - const [editOoo, setEditOoo] = useState('') - const [editReason, setEditReason] = useState('Underhall') - const [isSavingEdit, setIsSavingEdit] = useState(false) - - // Delete dialog state - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) - const [deleteDate, setDeleteDate] = useState('') - const [isDeleting, setIsDeleting] = useState(false) - - // Settings dialog state - const [settingsDialogOpen, setSettingsDialogOpen] = useState(false) - const [newTotalRooms, setNewTotalRooms] = useState('') - const [isSavingSettings, setIsSavingSettings] = useState(false) - - // --- Validation --- - const formOccupied = parseInt(roomsOccupied) || 0 - const formOoo = parseInt(roomsOutOfOrder) || 0 - const formExceedsTotal = totalRooms > 0 && (formOccupied + formOoo) > totalRooms - const formIsValid = roomsOccupied !== '' && !isNaN(parseInt(roomsOccupied)) && !formExceedsTotal - - const editOccupiedNum = parseInt(editOccupied) || 0 - const editOooNum = parseInt(editOoo) || 0 - const editExceedsTotal = totalRooms > 0 && (editOccupiedNum + editOooNum) > totalRooms - const editIsValid = editOccupied !== '' && !isNaN(parseInt(editOccupied)) && !editExceedsTotal - - // --- Current period KPIs --- - const totalOccupied = entries.reduce((s, e) => s + e.roomsOccupied, 0) - const totalOutOfOrder = entries.reduce((s, e) => s + e.roomsOutOfOrder, 0) - const daysInRange = entries.length - const totalAvailable = totalRooms * daysInRange - - const occupancyPct = totalAvailable > 0 - ? Math.round((totalOccupied / totalAvailable) * 10000) / 100 - : 0 - const avgOccupied = daysInRange > 0 ? Math.round(totalOccupied / daysInRange) : 0 - const avgOutOfOrder = daysInRange > 0 ? Math.round((totalOutOfOrder / daysInRange) * 10) / 10 : 0 - const avgAvailable = daysInRange > 0 - ? Math.round(((totalRooms * daysInRange - totalOccupied - totalOutOfOrder) / daysInRange) * 10) / 10 - : totalRooms - - // --- Previous period KPIs --- - const prevTotalOccupied = prevEntries.reduce((s, e) => s + e.roomsOccupied, 0) - const prevDaysInRange = prevEntries.length - const prevTotalAvailable = totalRooms * prevDaysInRange - - const prevOccupancyPct = prevTotalAvailable > 0 - ? Math.round((prevTotalOccupied / prevTotalAvailable) * 10000) / 100 - : 0 - - // Calendar heatmap for current month view - const calendarData = useMemo(() => { - const entryMap = new Map(entries.map(e => [e.date, e])) - const start = new Date(dateRange.start) - const end = new Date(dateRange.end) - const days: { date: string; occupancyPct: number; dayOfWeek: number }[] = [] - - const current = new Date(start) - while (current <= end) { - const dateStr = current.toISOString().slice(0, 10) - const entry = entryMap.get(dateStr) - const pct = entry && totalRooms > 0 - ? Math.round((entry.roomsOccupied / totalRooms) * 100) - : 0 - days.push({ date: dateStr, occupancyPct: pct, dayOfWeek: current.getDay() }) - current.setDate(current.getDate() + 1) - } - return days - }, [entries, dateRange, totalRooms]) - - // --- Handlers --- - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - const occupied = parseInt(roomsOccupied) - const outOfOrder = parseInt(roomsOutOfOrder) || 0 - if (isNaN(occupied)) return - if (totalRooms > 0 && (occupied + outOfOrder) > totalRooms) return - setIsSubmitting(true) - await save(`daily:${entryDate}`, { - roomsOccupied: occupied, - roomsOutOfOrder: outOfOrder, - reason: outOfOrder > 0 ? oooReason : undefined, - }) - setRoomsOccupied('') - setRoomsOutOfOrder('') - setOooReason('Underhall') - await refresh() - setIsSubmitting(false) - } - - const openEditDialog = (entry: DailyEntry) => { - setEditDate(entry.date) - setEditOccupied(String(entry.roomsOccupied)) - setEditOoo(String(entry.roomsOutOfOrder)) - setEditReason(entry.reason ?? 'Underhall') - setEditDialogOpen(true) - } - - const handleSaveEdit = async () => { - const occupied = parseInt(editOccupied) - const outOfOrder = parseInt(editOoo) || 0 - if (isNaN(occupied)) return - if (totalRooms > 0 && (occupied + outOfOrder) > totalRooms) return - setIsSavingEdit(true) - await save(`daily:${editDate}`, { - roomsOccupied: occupied, - roomsOutOfOrder: outOfOrder, - reason: outOfOrder > 0 ? editReason : undefined, - }) - await refresh() - setIsSavingEdit(false) - } - - const openDeleteDialog = (date: string) => { - setDeleteDate(date) - setDeleteDialogOpen(true) - } - - const handleDelete = async () => { - setIsDeleting(true) - await remove(`daily:${deleteDate}`) - setIsDeleting(false) - } - - const handleSetup = async (values: Record) => { - await save('settings', { totalRooms: parseInt(values.totalRooms) || 0 }) - } - - const openSettingsDialog = () => { - setNewTotalRooms(String(totalRooms)) - setSettingsDialogOpen(true) - } - - const handleSaveSettings = async () => { - const rooms = parseInt(newTotalRooms) - if (isNaN(rooms) || rooms <= 0) return - setIsSavingSettings(true) - await save('settings', { totalRooms: rooms }) - await refresh() - setIsSavingSettings(false) - } - - if (isLoading) return - - if (!totalRooms) { - return ( - - ) - } - - return ( -
-
- setDateRange({ start, end })} /> - -
- - {/* KPI Cards */} -
- - - - -
- - {/* Period comparison */} -
-

Periodjamforelse

-
-
-

Belaggning (nuvarande)

-
- {occupancyPct}% - -
-
-
-

Belaggning (foregaende)

- {prevOccupancyPct}% -
-
-

Foregaende period

- {prevPeriod.start} — {prevPeriod.end} -
-
-
-
-

Belagda rum (foregaende)

- - {prevDaysInRange > 0 - ? Math.round(prevTotalOccupied / prevDaysInRange) - : 0} snitt / dag - -
-
-

Dagar med data (foregaende)

- {prevDaysInRange} dagar -
-
-
- - {/* Entry form */} - -
-
- - setEntryDate(e.target.value)} /> -
-
- - setRoomsOccupied(e.target.value)} - /> -
-
- - setRoomsOutOfOrder(e.target.value)} - /> -
-
- - -
-
- {formExceedsTotal && ( -

- Belagda rum ({formOccupied}) + ur drift ({formOoo}) = {formOccupied + formOoo} overstiger totalt antal rum ({totalRooms}). -

- )} -
- - {/* Calendar heatmap */} - {calendarData.length > 0 && ( -
-

Belaggningskalender

-
-
- {['Man', 'Tis', 'Ons', 'Tor', 'Fre', 'Lor', 'Son'].map(d => ( -
{d}
- ))} -
-
- {/* Offset for first day of month */} - {calendarData.length > 0 && Array.from({ length: (calendarData[0].dayOfWeek + 6) % 7 }).map((_, i) => ( -
- ))} - {calendarData.map(day => ( -
0 ? 'text-white' : 'text-muted-foreground' - )} - title={`${day.date}: ${day.occupancyPct}%`} - > - {parseInt(day.date.slice(-2))} -
- ))} -
-
-
-
80%+ -
-
-
50-79% -
-
-
1-49% -
-
-
Ingen data -
-
-
-
- )} - - {/* Daily data table */} -
-

Daglig data

- {entries.length === 0 ? ( -

Ingen data registrerad i vald period.

- ) : ( -
- - - - Datum - Belagda - Ur drift - Orsak - Lediga - Belaggning - - - - - {entries.map(e => { - const pct = totalRooms > 0 ? Math.round((e.roomsOccupied / totalRooms) * 100) : 0 - const available = totalRooms - e.roomsOccupied - e.roomsOutOfOrder - return ( - - {e.date} - {e.roomsOccupied} / {totalRooms} - {e.roomsOutOfOrder} - - {e.roomsOutOfOrder > 0 ? (e.reason ?? '-') : '-'} - - {available} - {pct}% - -
- - -
-
-
- ) - })} -
-
-
- )} -
- - {/* Edit entry dialog */} - -
-
- - -
-
- - setEditOccupied(e.target.value)} - /> -
-
- - setEditOoo(e.target.value)} - /> -
-
- - -
- {editExceedsTotal && ( -

- Belagda rum ({editOccupiedNum}) + ur drift ({editOooNum}) = {editOccupiedNum + editOooNum} overstiger totalt antal rum ({totalRooms}). -

- )} -
-
- - {/* Confirm delete dialog */} - - - {/* Settings dialog */} - -
- - setNewTotalRooms(e.target.value)} - /> -
-
-
- ) -} diff --git a/components/extensions/hotel/RevparWorkspace.tsx b/components/extensions/hotel/RevparWorkspace.tsx deleted file mode 100644 index 5ec762ae..00000000 --- a/components/extensions/hotel/RevparWorkspace.tsx +++ /dev/null @@ -1,588 +0,0 @@ -'use client' - -import { useState, useMemo } from 'react' -import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' -import { useExtensionData } from '@/lib/extensions/use-extension-data' -import KPICard from '@/components/extensions/shared/KPICard' -import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter' -import DataEntryForm from '@/components/extensions/shared/DataEntryForm' -import SetupPrompt from '@/components/extensions/shared/SetupPrompt' -import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' -import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog' -import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Button } from '@/components/ui/button' -import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table' -import { Pencil, Trash2, ArrowUp, ArrowDown, Minus, Settings } from 'lucide-react' -import { validateMaxNumber, validatePositiveNumber } from '@/lib/extensions/validation' -import { cn } from '@/lib/utils' - -function computePreviousPeriod(start: string, end: string): { start: string; end: string } { - const startDate = new Date(start + 'T00:00:00') - const endDate = new Date(end + 'T00:00:00') - const durationMs = endDate.getTime() - startDate.getTime() - const prevEnd = new Date(startDate.getTime() - 1) - const prevStart = new Date(prevEnd.getTime() - durationMs) - return { - start: prevStart.toISOString().slice(0, 10), - end: prevEnd.toISOString().slice(0, 10), - } -} - -function DeltaArrow({ current, previous, higherIsBetter = true }: { - current: number - previous: number - higherIsBetter?: boolean -}) { - const delta = Math.round((current - previous) * 100) / 100 - if (delta === 0 || (previous === 0 && current === 0)) { - return ( - - - 0 - - ) - } - const improving = higherIsBetter ? delta > 0 : delta < 0 - return ( - - {delta > 0 - ? - : - } - {delta > 0 ? '+' : ''}{delta.toLocaleString('sv-SE')} - - ) -} - -interface DailyEntry { - date: string - roomsSold: number - roomRevenue: number -} - -function computeKPIs(entries: DailyEntry[], totalRooms: number) { - const totalRevenue = entries.reduce((s, e) => s + e.roomRevenue, 0) - const totalRoomsSold = entries.reduce((s, e) => s + e.roomsSold, 0) - const daysInRange = entries.length - const totalAvailableRooms = totalRooms * daysInRange - - const revpar = totalAvailableRooms > 0 - ? Math.round((totalRevenue / totalAvailableRooms) * 100) / 100 - : 0 - const adr = totalRoomsSold > 0 - ? Math.round((totalRevenue / totalRoomsSold) * 100) / 100 - : 0 - const occupancyPct = totalAvailableRooms > 0 - ? Math.round((totalRoomsSold / totalAvailableRooms) * 10000) / 100 - : 0 - - return { totalRevenue, totalRoomsSold, revpar, adr, occupancyPct } -} - -export default function RevparWorkspace({}: WorkspaceComponentProps) { - const now = new Date() - const [dateRange, setDateRange] = useState({ - start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10), - end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10), - }) - - const prevPeriod = useMemo( - () => computePreviousPeriod(dateRange.start, dateRange.end), - [dateRange.start, dateRange.end] - ) - - const { data, save, remove, refresh, isLoading } = useExtensionData('hotel', 'revpar') - const settings = data.find(d => d.key === 'settings')?.value as { totalRooms?: number } | undefined - const totalRooms = settings?.totalRooms ?? 0 - - // All daily entries (unfiltered by date, for period comparison) - const allEntries = useMemo(() => - data.filter(d => d.key.startsWith('daily:')) - .map(d => ({ - date: d.key.replace('daily:', ''), - ...(d.value as { roomsSold: number; roomRevenue: number }), - })) - , [data]) - - // Current period entries - const entries = useMemo(() => - allEntries - .filter(e => e.date >= dateRange.start && e.date <= dateRange.end) - .sort((a, b) => b.date.localeCompare(a.date)) - , [allEntries, dateRange]) - - // Previous period entries - const prevEntries = useMemo(() => - allEntries - .filter(e => e.date >= prevPeriod.start && e.date <= prevPeriod.end) - , [allEntries, prevPeriod]) - - // Form state - const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10)) - const [roomsSold, setRoomsSold] = useState('') - const [roomRevenue, setRoomRevenue] = useState('') - const [isSubmitting, setIsSubmitting] = useState(false) - - // Edit dialog state - const [editDialogOpen, setEditDialogOpen] = useState(false) - const [editDate, setEditDate] = useState('') - const [editRoomsSold, setEditRoomsSold] = useState('') - const [editRoomRevenue, setEditRoomRevenue] = useState('') - const [isSavingEdit, setIsSavingEdit] = useState(false) - - // Delete dialog state - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) - const [deleteDate, setDeleteDate] = useState('') - const [isDeleting, setIsDeleting] = useState(false) - - // Settings dialog state - const [settingsDialogOpen, setSettingsDialogOpen] = useState(false) - const [settingsRoomCount, setSettingsRoomCount] = useState('') - const [isSavingSettings, setIsSavingSettings] = useState(false) - - // Current period KPIs - const current = computeKPIs(entries, totalRooms) - - // Previous period KPIs - const prev = computeKPIs(prevEntries, totalRooms) - - // --- Input validation --- - const roomsSoldNum = parseInt(roomsSold) - const roomRevenueNum = parseFloat(roomRevenue) - const roomsSoldError = roomsSold !== '' - ? validateMaxNumber(roomsSold, totalRooms) - ? `Kan inte overskrida ${totalRooms} rum` - : null - : null - const roomRevenueError = roomRevenue !== '' - ? validatePositiveNumber(roomRevenue) - : null - const formValid = !isNaN(roomsSoldNum) - && roomsSoldNum >= 0 - && roomsSoldNum <= totalRooms - && !isNaN(roomRevenueNum) - && roomRevenueNum > 0 - - // Edit dialog validation - const editRoomsSoldNum = parseInt(editRoomsSold) - const editRoomRevenueNum = parseFloat(editRoomRevenue) - const editRoomsSoldError = editRoomsSold !== '' - ? validateMaxNumber(editRoomsSold, totalRooms) - ? `Kan inte overskrida ${totalRooms} rum` - : null - : null - const editRoomRevenueError = editRoomRevenue !== '' - ? validatePositiveNumber(editRoomRevenue) - : null - const editFormValid = !isNaN(editRoomsSoldNum) - && editRoomsSoldNum >= 0 - && editRoomsSoldNum <= totalRooms - && !isNaN(editRoomRevenueNum) - && editRoomRevenueNum > 0 - - // Settings validation - const settingsRoomCountNum = parseInt(settingsRoomCount) - const settingsValid = !isNaN(settingsRoomCountNum) && settingsRoomCountNum > 0 - - // Monthly trend with all three metrics - const monthlyTrend = useMemo(() => { - const map = new Map() - for (const e of entries) { - const month = e.date.slice(0, 7) - const existing = map.get(month) ?? { revenue: 0, rooms: 0, days: 0 } - existing.revenue += e.roomRevenue - existing.rooms += e.roomsSold - existing.days++ - map.set(month, existing) - } - return Array.from(map.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([month, d]) => { - const available = totalRooms * d.days - return { - month, - revpar: available > 0 ? Math.round((d.revenue / available) * 100) / 100 : 0, - adr: d.rooms > 0 ? Math.round((d.revenue / d.rooms) * 100) / 100 : 0, - occupancy: available > 0 ? Math.round((d.rooms / available) * 10000) / 100 : 0, - } - }) - }, [entries, totalRooms]) - - // --- Handlers --- - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!formValid) return - setIsSubmitting(true) - await save(`daily:${entryDate}`, { roomsSold: roomsSoldNum, roomRevenue: roomRevenueNum }) - setRoomsSold('') - setRoomRevenue('') - await refresh() - setIsSubmitting(false) - } - - const openEditDialog = (entry: DailyEntry) => { - setEditDate(entry.date) - setEditRoomsSold(String(entry.roomsSold)) - setEditRoomRevenue(String(entry.roomRevenue)) - setEditDialogOpen(true) - } - - const handleSaveEdit = async () => { - if (!editFormValid) return - setIsSavingEdit(true) - await save(`daily:${editDate}`, { - roomsSold: editRoomsSoldNum, - roomRevenue: editRoomRevenueNum, - }) - await refresh() - setIsSavingEdit(false) - } - - const openDeleteDialog = (date: string) => { - setDeleteDate(date) - setDeleteDialogOpen(true) - } - - const handleConfirmDelete = async () => { - setIsDeleting(true) - await remove(`daily:${deleteDate}`) - setIsDeleting(false) - } - - const openSettingsDialog = () => { - setSettingsRoomCount(String(totalRooms)) - setSettingsDialogOpen(true) - } - - const handleSaveSettings = async () => { - if (!settingsValid) return - setIsSavingSettings(true) - await save('settings', { totalRooms: settingsRoomCountNum }) - await refresh() - setIsSavingSettings(false) - } - - const handleSetup = async (values: Record) => { - await save('settings', { totalRooms: parseInt(values.totalRooms) || 0 }) - } - - if (isLoading) return - - if (!totalRooms) { - return ( - - ) - } - - return ( -
-
- setDateRange({ start, end })} /> - -
- - {/* KPI Cards with delta indicators */} -
- - - - -
- - {/* Period comparison */} -
-

Periodjamforelse

-
-
-

RevPAR

-
- - {current.revpar.toLocaleString('sv-SE')} kr - - -
-

- Foregaende: {prev.revpar.toLocaleString('sv-SE')} kr -

-
-
-

ADR

-
- - {current.adr.toLocaleString('sv-SE')} kr - - -
-

- Foregaende: {prev.adr.toLocaleString('sv-SE')} kr -

-
-
-

Belaggning

-
- - {current.occupancyPct}% - - -
-

- Foregaende: {prev.occupancyPct}% -

-
-
-
-

- Foregaende period: {prevPeriod.start} — {prevPeriod.end} -

-
-
- - {/* Data entry form with validation */} - -
-
- - setEntryDate(e.target.value)} - /> -
-
- - setRoomsSold(e.target.value)} - className={cn(roomsSoldError && 'border-red-500')} - /> - {roomsSoldError && ( -

{roomsSoldError}

- )} -
-
- - setRoomRevenue(e.target.value)} - className={cn(roomRevenueError && 'border-red-500')} - /> - {roomRevenueError && ( -

{roomRevenueError}

- )} -
-
-
- - {/* Monthly trend with RevPAR, ADR, Occupancy */} - {monthlyTrend.length > 0 && ( -
-

Manadstrend

-
- - - - Period - RevPAR - ADR - Belaggning - - - - {monthlyTrend.map(row => ( - - {row.month} - - {row.revpar.toLocaleString('sv-SE')} kr - - - {row.adr.toLocaleString('sv-SE')} kr - - - {row.occupancy}% - - - ))} - -
-
-
- )} - - {/* Daily data table with edit and delete */} -
-

Daglig data

- {entries.length === 0 ? ( -

Ingen data registrerad i vald period.

- ) : ( -
- - - - Datum - Rum salda - Intakt - ADR - - - - - {entries.map(e => ( - - {e.date} - - {e.roomsSold} / {totalRooms} - - - {e.roomRevenue.toLocaleString('sv-SE')} kr - - - {e.roomsSold > 0 - ? Math.round(e.roomRevenue / e.roomsSold).toLocaleString('sv-SE') - : 0} kr - - -
- - -
-
-
- ))} -
-
-
- )} -
- - {/* Edit entry dialog */} - -
-
- - -
-
- - setEditRoomsSold(e.target.value)} - className={cn(editRoomsSoldError && 'border-red-500')} - /> - {editRoomsSoldError && ( -

{editRoomsSoldError}

- )} -
-
- - setEditRoomRevenue(e.target.value)} - className={cn(editRoomRevenueError && 'border-red-500')} - /> - {editRoomRevenueError && ( -

{editRoomRevenueError}

- )} -
-
-
- - {/* Confirm delete dialog */} - - - {/* Settings dialog */} - -
- - setSettingsRoomCount(e.target.value)} - /> - {settingsRoomCount !== '' && !settingsValid && ( -

Ange ett giltigt antal rum (minst 1)

- )} -
-
-
- ) -} diff --git a/components/extensions/restaurant/EarningsPerLiterWorkspace.tsx b/components/extensions/restaurant/EarningsPerLiterWorkspace.tsx deleted file mode 100644 index 27918563..00000000 --- a/components/extensions/restaurant/EarningsPerLiterWorkspace.tsx +++ /dev/null @@ -1,817 +0,0 @@ -'use client' - -import { useState, useMemo } from 'react' -import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' -import { useAccountTotals } from '@/lib/extensions/use-account-totals' -import { useExtensionData } from '@/lib/extensions/use-extension-data' -import KPICard from '@/components/extensions/shared/KPICard' -import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter' -import MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable' -import DataEntryForm from '@/components/extensions/shared/DataEntryForm' -import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' -import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog' -import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Button } from '@/components/ui/button' -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' -import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table' -import { Trash2, Pencil, ArrowUp, ArrowDown, Minus, Settings, Plus, X } from 'lucide-react' - -const DEFAULT_CATEGORIES = ['Ol', 'Vin', 'Sprit'] - -const DEFAULT_PRICING: Record = { - Ol: 80, - Vin: 120, - Sprit: 200, -} - -interface LiterEntry { - id: string - date: string - category: string - liters: number -} - -interface Pricing { - [category: string]: number -} - -function DeltaIndicator({ current, previous }: { current: number; previous: number }) { - if (previous === 0) return - const delta = Math.round(((current - previous) / previous) * 10000) / 100 - if (delta > 0) { - return ( - - +{delta}% - - ) - } - if (delta < 0) { - return ( - - {delta}% - - ) - } - return ( - - 0% - - ) -} - -export default function EarningsPerLiterWorkspace({}: WorkspaceComponentProps) { - const now = new Date() - const [dateRange, setDateRange] = useState({ - start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10), - end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10), - }) - - const { data, save, remove, refresh, isLoading: dataLoading } = useExtensionData('restaurant', 'earnings-per-liter') - - // Settings: categories - const settings = data.find(d => d.key === 'settings')?.value as { categories?: string[] } | undefined - const categories = settings?.categories ?? DEFAULT_CATEGORIES - - // Pricing per category (kr per liter) - const pricingData = data.find(d => d.key === 'pricing')?.value as Pricing | undefined - const pricing: Pricing = useMemo(() => { - const base: Pricing = {} - for (const cat of categories) { - base[cat] = pricingData?.[cat] ?? DEFAULT_PRICING[cat] ?? 100 - } - return base - }, [categories, pricingData]) - - // Entries filtered by date range - const entries: LiterEntry[] = useMemo(() => - data.filter(d => d.key.startsWith('entry:')) - .map(d => ({ - id: d.key, - ...(d.value as { date: string; category: string; liters: number }), - })) - .filter(e => e.date >= dateRange.start && e.date <= dateRange.end) - .sort((a, b) => b.date.localeCompare(a.date)) - , [data, dateRange]) - - // Previous period entries for comparison - const prevPeriodEntries: LiterEntry[] = useMemo(() => { - const startDate = new Date(dateRange.start) - const endDate = new Date(dateRange.end) - const durationMs = endDate.getTime() - startDate.getTime() - const prevStart = new Date(startDate.getTime() - durationMs - 86400000) - const prevEnd = new Date(startDate.getTime() - 86400000) - const prevStartStr = prevStart.toISOString().slice(0, 10) - const prevEndStr = prevEnd.toISOString().slice(0, 10) - return data.filter(d => d.key.startsWith('entry:')) - .map(d => ({ - id: d.key, - ...(d.value as { date: string; category: string; liters: number }), - })) - .filter(e => e.date >= prevStartStr && e.date <= prevEndStr) - }, [data, dateRange]) - - // Form state - single entry - const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10)) - const [category, setCategory] = useState(categories[0]) - const [liters, setLiters] = useState('') - const [isSubmitting, setIsSubmitting] = useState(false) - - // Batch entry mode - const [batchMode, setBatchMode] = useState(false) - const [batchDate, setBatchDate] = useState(now.toISOString().slice(0, 10)) - const [batchLiters, setBatchLiters] = useState>({}) - const [isBatchSubmitting, setIsBatchSubmitting] = useState(false) - - // Edit state - const [editingEntry, setEditingEntry] = useState(null) - const [editDate, setEditDate] = useState('') - const [editCategory, setEditCategory] = useState('') - const [editLiters, setEditLiters] = useState('') - const [isSavingEdit, setIsSavingEdit] = useState(false) - - // Delete confirmation state - const [deletingEntry, setDeletingEntry] = useState(null) - const [isDeleting, setIsDeleting] = useState(false) - - // Category management state - const [newCategoryName, setNewCategoryName] = useState('') - const [editingPricing, setEditingPricing] = useState(false) - const [pricingInputs, setPricingInputs] = useState>({}) - - // Alcohol revenue from bookkeeping (accounts 3000-3999) - const { totalCredit: alcoholRevenue, isLoading: revenueLoading } = useAccountTotals({ - from: '3000', to: '3999', - dateFrom: dateRange.start, dateTo: dateRange.end, - }) - - // Calculations - const totalLiters = entries.reduce((s, e) => s + e.liters, 0) - const earningsPerLiter = totalLiters > 0 - ? Math.round((alcoholRevenue / totalLiters) * 100) / 100 - : 0 - - // Estimated revenue based on pricing - const estimatedRevenue = useMemo(() => { - let total = 0 - for (const e of entries) { - const price = pricing[e.category] ?? 100 - total += e.liters * price - } - return Math.round(total * 100) / 100 - }, [entries, pricing]) - - // Previous period calculations - const prevTotalLiters = prevPeriodEntries.reduce((s, e) => s + e.liters, 0) - const prevEstimatedRevenue = useMemo(() => { - let total = 0 - for (const e of prevPeriodEntries) { - const price = pricing[e.category] ?? 100 - total += e.liters * price - } - return Math.round(total * 100) / 100 - }, [prevPeriodEntries, pricing]) - const prevEarningsPerLiter = prevTotalLiters > 0 - ? Math.round((prevEstimatedRevenue / prevTotalLiters) * 100) / 100 - : 0 - - // Category breakdown with per-category revenue = liters x avg price - const categoryBreakdown = useMemo(() => { - const map = new Map() - for (const e of entries) { - map.set(e.category, (map.get(e.category) ?? 0) + e.liters) - } - return categories.map(cat => { - const catLiters = map.get(cat) ?? 0 - const avgPrice = pricing[cat] ?? 100 - const estimatedRev = Math.round(catLiters * avgPrice * 100) / 100 - const eplCategory = catLiters > 0 - ? Math.round((estimatedRev / catLiters) * 100) / 100 - : 0 - return { - category: cat, - liters: catLiters, - avgPrice, - estimatedRevenue: estimatedRev, - earningsPerLiter: eplCategory, - } - }) - }, [entries, categories, pricing]) - - // Monthly trend - const monthlyTrend = useMemo(() => { - const map = new Map() - for (const e of entries) { - const month = e.date.slice(0, 7) - map.set(month, (map.get(month) ?? 0) + e.liters) - } - return Array.from(map.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([month, liters]) => ({ month, value: liters })) - }, [entries]) - - // --- Handlers --- - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - const val = parseFloat(liters) - if (isNaN(val) || val <= 0) return - setIsSubmitting(true) - const id = crypto.randomUUID() - await save(`entry:${id}`, { date: entryDate, category, liters: val }) - setLiters('') - await refresh() - setIsSubmitting(false) - } - - const handleBatchSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setIsBatchSubmitting(true) - for (const cat of categories) { - const val = parseFloat(batchLiters[cat] ?? '') - if (!isNaN(val) && val > 0) { - const id = crypto.randomUUID() - await save(`entry:${id}`, { date: batchDate, category: cat, liters: val }) - } - } - setBatchLiters({}) - await refresh() - setIsBatchSubmitting(false) - } - - const openEdit = (entry: LiterEntry) => { - setEditingEntry(entry) - setEditDate(entry.date) - setEditCategory(entry.category) - setEditLiters(String(entry.liters)) - } - - const handleSaveEdit = async () => { - if (!editingEntry) return - const val = parseFloat(editLiters) - if (isNaN(val) || val <= 0) return - setIsSavingEdit(true) - await save(editingEntry.id, { date: editDate, category: editCategory, liters: val }) - await refresh() - setIsSavingEdit(false) - setEditingEntry(null) - } - - const handleConfirmDelete = async () => { - if (!deletingEntry) return - setIsDeleting(true) - await remove(deletingEntry.id) - setIsDeleting(false) - setDeletingEntry(null) - } - - // Category management - const handleAddCategory = async () => { - const name = newCategoryName.trim() - if (!name || categories.includes(name)) return - const updated = [...categories, name] - await save('settings', { ...settings, categories: updated }) - setNewCategoryName('') - await refresh() - } - - const handleRemoveCategory = async (cat: string) => { - const updated = categories.filter(c => c !== cat) - if (updated.length === 0) return - await save('settings', { ...settings, categories: updated }) - // Update pricing to remove the category - if (pricingData) { - const updatedPricing = { ...pricingData } - delete updatedPricing[cat] - await save('pricing', updatedPricing) - } - await refresh() - } - - const handleRenameCategory = async (oldName: string, newName: string) => { - if (!newName.trim() || oldName === newName.trim()) return - const trimmed = newName.trim() - const updated = categories.map(c => c === oldName ? trimmed : c) - await save('settings', { ...settings, categories: updated }) - // Update pricing key - if (pricingData) { - const updatedPricing = { ...pricingData } - if (updatedPricing[oldName] !== undefined) { - updatedPricing[trimmed] = updatedPricing[oldName] - delete updatedPricing[oldName] - } - await save('pricing', updatedPricing) - } - await refresh() - } - - const handleSavePricing = async () => { - const updated: Pricing = {} - for (const cat of categories) { - const val = parseFloat(pricingInputs[cat] ?? '') - updated[cat] = !isNaN(val) && val > 0 ? Math.round(val * 100) / 100 : (pricing[cat] ?? 100) - } - await save('pricing', updated) - setEditingPricing(false) - await refresh() - } - - const startEditPricing = () => { - const inputs: Record = {} - for (const cat of categories) { - inputs[cat] = String(pricing[cat] ?? 100) - } - setPricingInputs(inputs) - setEditingPricing(true) - } - - if (dataLoading || revenueLoading) return - - return ( -
- - - Registrera - Oversikt - Installningar - - - {/* ---- REGISTER TAB ---- */} - - setDateRange({ start, end })} /> - - {/* KPI row */} -
- 0 ? { - value: Math.round(((earningsPerLiter - prevEarningsPerLiter) / prevEarningsPerLiter) * 10000) / 100, - label: 'mot foreg. period', - } : undefined} - /> - 0 ? { - value: Math.round(((totalLiters - prevTotalLiters) / prevTotalLiters) * 10000) / 100, - label: 'mot foreg. period', - } : undefined} - /> - - -
- - {/* Revenue comparison */} - {estimatedRevenue > 0 && alcoholRevenue > 0 && ( -
-
-
-

Uppskattad vs bokford intakt

-

- Differens baserat pa snittkr/liter per kategori -

-
-
-

- {(Math.round((estimatedRevenue - alcoholRevenue) * 100) / 100).toLocaleString('sv-SE')} kr -

- -
-
-
- )} - - {/* Single entry form / batch mode toggle */} -
- -
- - {batchMode ? ( - -
-
- - setBatchDate(e.target.value)} - className="max-w-xs" - /> -
-
- {categories.map(cat => ( -
- - setBatchLiters(prev => ({ ...prev, [cat]: e.target.value }))} - /> -
- ))} -
-
-
- ) : ( - -
-
- - setEntryDate(e.target.value)} /> -
-
- - -
-
- - setLiters(e.target.value)} /> -
-
-
- )} - - {/* Entry history */} -
-

Senaste registreringar

- {entries.length === 0 ? ( -

Inga registreringar i vald period.

- ) : ( -
- - - - Datum - Kategori - Liter - Uppsk. intakt - - - - - {entries.slice(0, 20).map(e => { - const entryRevenue = Math.round(e.liters * (pricing[e.category] ?? 100) * 100) / 100 - return ( - - {e.date} - {e.category} - {e.liters.toLocaleString('sv-SE')} l - {entryRevenue.toLocaleString('sv-SE')} kr - -
- - -
-
-
- ) - })} -
-
-
- )} -
-
- - {/* ---- OVERVIEW TAB ---- */} - - setDateRange({ start, end })} /> - - {/* Period comparison KPIs */} -
-
-

Intakt/liter (nuvarande)

-

{earningsPerLiter.toLocaleString('sv-SE')} kr/l

- {prevEarningsPerLiter > 0 && ( -
- Foreg: {prevEarningsPerLiter.toLocaleString('sv-SE')} kr/l - -
- )} -
-
-

Liter (nuvarande)

-

{(Math.round(totalLiters * 100) / 100).toLocaleString('sv-SE')} l

- {prevTotalLiters > 0 && ( -
- Foreg: {(Math.round(prevTotalLiters * 100) / 100).toLocaleString('sv-SE')} l - -
- )} -
-
-

Uppsk. intakt (nuvarande)

-

{estimatedRevenue.toLocaleString('sv-SE')} kr

- {prevEstimatedRevenue > 0 && ( -
- Foreg: {prevEstimatedRevenue.toLocaleString('sv-SE')} kr - -
- )} -
-
- - {/* Category breakdown */} - {categoryBreakdown.some(c => c.liters > 0) && ( -
-

Per kategori

-
- - - - Kategori - Liter - Snittpris/l - Uppsk. intakt - Kr/liter - - - - {categoryBreakdown.map(c => ( - - {c.category} - {c.liters.toLocaleString('sv-SE')} l - {c.avgPrice.toLocaleString('sv-SE')} kr - {c.estimatedRevenue.toLocaleString('sv-SE')} kr - {c.earningsPerLiter.toLocaleString('sv-SE')} kr/l - - ))} - - Totalt - {(Math.round(totalLiters * 100) / 100).toLocaleString('sv-SE')} l - - {estimatedRevenue.toLocaleString('sv-SE')} kr - {earningsPerLiter.toLocaleString('sv-SE')} kr/l - - -
-
-
- )} - - {/* Monthly trend */} - {monthlyTrend.length > 0 && ( -
-

Manadstrend

- -
- )} -
- - {/* ---- SETTINGS TAB ---- */} - - {/* Category management */} -
-
-

Kategorier

-

Lagg till, ta bort eller byt namn pa dryckkategorier.

-
- -
- setNewCategoryName(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleAddCategory()} - className="max-w-xs" - /> - -
- - {categories.length > 0 && ( -
- - - - Namn - - - - - {categories.map(cat => ( - 1} - onRename={(newName) => handleRenameCategory(cat, newName)} - onRemove={() => handleRemoveCategory(cat)} - /> - ))} - -
-
- )} -
- - {/* Pricing settings */} -
-
-
-

Snittpris per liter

-

- Anvands for att berakna uppskattad intakt per kategori. -

-
- {!editingPricing && ( - - )} -
- - {editingPricing ? ( -
-
- {categories.map(cat => ( -
- - setPricingInputs(prev => ({ ...prev, [cat]: e.target.value }))} - /> -
- ))} -
-
- - -
-
- ) : ( -
- - - - Kategori - Pris (kr/l) - - - - {categories.map(cat => ( - - {cat} - {(pricing[cat] ?? 100).toLocaleString('sv-SE')} kr - - ))} - -
-
- )} -
-
-
- - {/* Edit entry dialog */} - { if (!open) setEditingEntry(null) }} - title="Redigera registrering" - description="Andra datum, kategori eller antal liter." - onSave={handleSaveEdit} - isSaving={isSavingEdit} - > -
-
- - setEditDate(e.target.value)} /> -
-
- - -
-
- - setEditLiters(e.target.value)} /> -
-
-
- - {/* Delete confirmation dialog */} - { if (!open) setDeletingEntry(null) }} - title="Ta bort registrering" - description={deletingEntry ? `Vill du ta bort ${deletingEntry.liters} l ${deletingEntry.category} fran ${deletingEntry.date}?` : ''} - onConfirm={handleConfirmDelete} - isDeleting={isDeleting} - /> -
- ) -} - -// Inline sub-component for category row with rename support -function CategoryRow({ - name, - canRemove, - onRename, - onRemove, -}: { - name: string - canRemove: boolean - onRename: (newName: string) => Promise - onRemove: () => Promise -}) { - const [isRenaming, setIsRenaming] = useState(false) - const [newName, setNewName] = useState(name) - - const handleRename = async () => { - await onRename(newName) - setIsRenaming(false) - } - - return ( - - - {isRenaming ? ( -
- setNewName(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleRename()} - className="h-8 max-w-[200px]" - autoFocus - /> - - -
- ) : ( - {name} - )} -
- -
- {!isRenaming && ( - - )} - {canRemove && !isRenaming && ( - - )} -
-
-
- ) -} diff --git a/components/extensions/restaurant/FoodCostWorkspace.tsx b/components/extensions/restaurant/FoodCostWorkspace.tsx deleted file mode 100644 index 1ae12642..00000000 --- a/components/extensions/restaurant/FoodCostWorkspace.tsx +++ /dev/null @@ -1,552 +0,0 @@ -'use client' - -import { useState, useMemo, useCallback } from 'react' -import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' -import { useAccountTotals } from '@/lib/extensions/use-account-totals' -import { useExtensionData } from '@/lib/extensions/use-extension-data' -import KPICard from '@/components/extensions/shared/KPICard' -import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter' -import MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable' -import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' -import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog' -import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' -import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table' -import { ArrowUp, ArrowDown, Minus } from 'lucide-react' -import { cn } from '@/lib/utils' - -const FOOD_CATEGORIES = ['Kott', 'Fisk', 'Gronsaker', 'Mejeri', 'Drycker', 'Ovrigt'] as const -type FoodCategory = typeof FOOD_CATEGORIES[number] - -function computePreviousPeriod(start: string, end: string): { start: string; end: string } { - const startDate = new Date(start + 'T00:00:00') - const endDate = new Date(end + 'T00:00:00') - const durationMs = endDate.getTime() - startDate.getTime() - const prevEnd = new Date(startDate.getTime() - 1) - const prevStart = new Date(prevEnd.getTime() - durationMs) - return { - start: prevStart.toISOString().slice(0, 10), - end: prevEnd.toISOString().slice(0, 10), - } -} - -function DeltaArrow({ current, previous }: { current: number; previous: number }) { - const delta = Math.round((current - previous) * 100) / 100 - if (delta === 0 || (previous === 0 && current === 0)) { - return ( - - - 0 pp - - ) - } - // For food cost: lower is better, so negative delta = green (improving) - const improving = delta < 0 - return ( - - {delta < 0 - ? - : - } - {delta > 0 ? '+' : ''}{delta} pp - - ) -} - -export default function FoodCostWorkspace({}: WorkspaceComponentProps) { - const now = new Date() - const [dateRange, setDateRange] = useState({ - start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10), - end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10), - }) - - const prevPeriod = useMemo( - () => computePreviousPeriod(dateRange.start, dateRange.end), - [dateRange.start, dateRange.end] - ) - - // Yearly range for monthly trend - const yearStart = `${now.getFullYear()}-01-01` - const yearEnd = `${now.getFullYear()}-12-31` - - const { data: extData, save, remove, isLoading: settingsLoading } = useExtensionData('restaurant', 'food-cost') - const settings = extData.find(d => d.key === 'settings')?.value as { targetPct?: number } | undefined - - const [targetPctInput, setTargetPct] = useState(null) - const [editingTarget, setEditingTarget] = useState(false) - const targetPct = targetPctInput ?? (settings?.targetPct != null ? String(settings.targetPct) : '') - - // Category assignments from extension_data (key = "category:{accountNumber}") - const categoryMap = useMemo(() => { - const map: Record = {} - for (const d of extData) { - if (d.key.startsWith('category:')) { - const account = d.key.replace('category:', '') - map[account] = (d.value as { category: FoodCategory }).category - } - } - return map - }, [extData]) - - // Notes from extension_data (key = "note:YYYY-MM") - const currentMonth = dateRange.start.slice(0, 7) - const currentNote = extData.find(d => d.key === `note:${currentMonth}`)?.value as { text: string } | undefined - const [noteText, setNoteText] = useState('') - const [editingNote, setEditingNote] = useState(false) - const [savingNote, setSavingNote] = useState(false) - const [deleteNoteOpen, setDeleteNoteOpen] = useState(false) - const [deletingNote, setDeletingNote] = useState(false) - - // Target edit dialog state - const [editTargetDialogOpen, setEditTargetDialogOpen] = useState(false) - const [newTargetInput, setNewTargetInput] = useState('') - const [savingTarget, setSavingTarget] = useState(false) - - // --- Current period account totals --- - const { totals: purchaseTotals, isLoading: purchasesLoading } = useAccountTotals({ - from: '4000', to: '4999', - dateFrom: dateRange.start, dateTo: dateRange.end, - }) - const { totals: revenueTotals, isLoading: revenueLoading } = useAccountTotals({ - from: '3000', to: '3999', - dateFrom: dateRange.start, dateTo: dateRange.end, - }) - - // --- Previous period account totals --- - const { totals: prevPurchaseTotals, isLoading: prevPurchasesLoading } = useAccountTotals({ - from: '4000', to: '4999', - dateFrom: prevPeriod.start, dateTo: prevPeriod.end, - }) - const { totals: prevRevenueTotals, isLoading: prevRevenueLoading } = useAccountTotals({ - from: '3000', to: '3999', - dateFrom: prevPeriod.start, dateTo: prevPeriod.end, - }) - - // Monthly trend data - const { monthly: purchaseMonthly } = useAccountTotals({ - from: '4000', to: '4999', - dateFrom: yearStart, dateTo: yearEnd, - groupBy: 'month', - }) - const { monthly: revenueMonthly } = useAccountTotals({ - from: '3000', to: '3999', - dateFrom: yearStart, dateTo: yearEnd, - groupBy: 'month', - }) - - // Current period calculations - const totalPurchases = purchaseTotals.reduce((sum, t) => sum + t.debit, 0) - const totalRevenue = revenueTotals.reduce((sum, t) => sum + t.credit, 0) - const foodCostPct = totalRevenue > 0 - ? Math.round((totalPurchases / totalRevenue) * 10000) / 100 - : 0 - - // Previous period calculations - const prevTotalPurchases = prevPurchaseTotals.reduce((sum, t) => sum + t.debit, 0) - const prevTotalRevenue = prevRevenueTotals.reduce((sum, t) => sum + t.credit, 0) - const prevFoodCostPct = prevTotalRevenue > 0 - ? Math.round((prevTotalPurchases / prevTotalRevenue) * 10000) / 100 - : 0 - - const target = settings?.targetPct ?? 30 - - // Monthly trend rows - const monthlyTrend = useMemo(() => { - const months = new Set([ - ...purchaseMonthly.map(m => m.month), - ...revenueMonthly.map(m => m.month), - ]) - return Array.from(months).sort().map(month => { - const purch = purchaseMonthly.filter(m => m.month === month).reduce((s, m) => s + m.debit, 0) - const rev = revenueMonthly.filter(m => m.month === month).reduce((s, m) => s + m.credit, 0) - const pct = rev > 0 ? Math.round((purch / rev) * 10000) / 100 : 0 - return { month, value: pct } - }) - }, [purchaseMonthly, revenueMonthly]) - - // Category breakdown - const categoryBreakdown = useMemo(() => { - const groups: Record = {} - for (const cat of FOOD_CATEGORIES) { - groups[cat] = { category: cat, total: 0, accounts: [] } - } - let uncategorizedTotal = 0 - const uncategorizedAccounts: string[] = [] - - for (const t of purchaseTotals) { - const cat = categoryMap[t.account_number] - if (cat && groups[cat]) { - groups[cat].total += t.debit - groups[cat].accounts.push(t.account_number) - } else { - uncategorizedTotal += t.debit - uncategorizedAccounts.push(t.account_number) - } - } - - const result = FOOD_CATEGORIES - .map(cat => groups[cat]) - .filter(g => g.total > 0 || g.accounts.length > 0) - - if (uncategorizedTotal > 0) { - result.push({ category: 'Ovrigt' as FoodCategory, total: uncategorizedTotal, accounts: uncategorizedAccounts }) - } - - return result - }, [purchaseTotals, categoryMap]) - - // --- Handlers --- - - const saveTarget = async () => { - const val = parseFloat(targetPct) - if (!isNaN(val)) { - // Save target history before changing - const oldTarget = settings?.targetPct - if (oldTarget != null && oldTarget !== val) { - await save(`target-history:${Date.now()}`, { - previousTarget: oldTarget, - newTarget: val, - changedAt: new Date().toISOString(), - }) - } - await save('settings', { targetPct: val }) - setEditingTarget(false) - } - } - - const handleSaveTargetDialog = async () => { - setSavingTarget(true) - const val = parseFloat(newTargetInput) - if (!isNaN(val)) { - const oldTarget = settings?.targetPct - if (oldTarget != null && oldTarget !== val) { - await save(`target-history:${Date.now()}`, { - previousTarget: oldTarget, - newTarget: val, - changedAt: new Date().toISOString(), - }) - } - await save('settings', { targetPct: val }) - } - setSavingTarget(false) - } - - const handleCategoryChange = useCallback(async (accountNumber: string, category: string) => { - if (category === '__none__') { - await remove(`category:${accountNumber}`) - } else { - await save(`category:${accountNumber}`, { category }) - } - }, [save, remove]) - - const handleSaveNote = async () => { - setSavingNote(true) - await save(`note:${currentMonth}`, { text: noteText }) - setEditingNote(false) - setSavingNote(false) - } - - const handleDeleteNote = async () => { - setDeletingNote(true) - await remove(`note:${currentMonth}`) - setNoteText('') - setEditingNote(false) - setDeletingNote(false) - } - - const startEditNote = () => { - setNoteText(currentNote?.text ?? '') - setEditingNote(true) - } - - const isLoading = purchasesLoading || revenueLoading || prevPurchasesLoading || prevRevenueLoading || settingsLoading - - if (isLoading) return - - return ( -
- setDateRange({ start, end })} /> - - {/* KPI Cards with period comparison */} -
- - - -
- - {/* Period comparison */} -
-

Periodjamforelse

-
-
-

Food Cost % (nuvarande)

-
- {foodCostPct}% - -
-
-
-

Food Cost % (foregaende)

- {prevFoodCostPct}% -
-
-

Foregaende period

- {prevPeriod.start} — {prevPeriod.end} -
-
-
-
-

Varuinkop (foregaende)

- {prevTotalPurchases.toLocaleString('sv-SE')} kr -
-
-

Intakter (foregaende)

- {prevTotalRevenue.toLocaleString('sv-SE')} kr -
-
-
- - {/* Target setting */} -
-
-
-

Malvarde

-

- Riktvarde for food cost (vanligtvis 25-35%) -

-
- {editingTarget ? ( -
- setTargetPct(e.target.value)} - className="w-20 h-8 text-sm" - /> - - - -
- ) : ( - - )} -
-
- - {/* Edit target dialog (saves history) */} - -
- - setNewTargetInput(e.target.value)} - className="w-32" - /> -
-
- - {/* Notes per period */} -
-
-
-

Anteckningar for {currentMonth}

-

- Notera avvikelser och forklaringar for perioden -

-
- {!editingNote && ( - - )} -
- {editingNote ? ( -
-