refactor: consolidate extension system to general-only with manifest-driven architecture
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
39e407644d
commit
03b569d708
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <dir> # 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 <dir> # 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/<sector>/<name>/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/<name>/manifest.json` (see Manifest Format below)
|
||||
2. Create `extensions/general/<name>/index.ts` exporting an `Extension` object
|
||||
3. Optionally create `extensions/general/<name>/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<string, (...args: any[]) => Promise<any>>
|
||||
|
||||
// Lifecycle hooks
|
||||
onInstall?(ctx: ExtensionContext): Promise<void>
|
||||
@@ -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<void> // 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<MySettings>() // Get 'settings' key (default)
|
||||
await ctx.settings.get<MyConfig>('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<SendEmailResult>
|
||||
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
|
||||
|
||||
@@ -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<TransactionWithInvoice[]>([])
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -38,6 +38,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
const document = await linkToJournalEntry(
|
||||
supabase,
|
||||
user.id,
|
||||
id,
|
||||
body.journal_entry_id,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> = {}
|
||||
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 })
|
||||
}
|
||||
@@ -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<string, CategorizationSuggestion> = {}
|
||||
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<string, CategorizationSuggestion> = {}
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<string, { count: number; resetTime: number }>()
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<string, { count: number; resetTime: number }>()
|
||||
|
||||
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' } }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<ReceivableInvoice>(({ 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<GLLine[]> {
|
||||
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<string, string>()
|
||||
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
|
||||
}
|
||||
@@ -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<ECSalesListInvoice>(({ 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<ECSalesListInvoice>(({ 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<GLAccountTotal[]> {
|
||||
// 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<string, number>()
|
||||
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,
|
||||
}))
|
||||
}
|
||||
@@ -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<IntrastatInvoice>(({ 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<string, unknown> }) => ({
|
||||
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<string, unknown> | 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<IntrastatInvoice>(({ 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<string, unknown> }) => ({
|
||||
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<string, unknown> | 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<number> {
|
||||
// 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
|
||||
}
|
||||
@@ -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<VatMonitorInvoice>(({ 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<GLLine[]> {
|
||||
// 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)
|
||||
}
|
||||
@@ -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<string, string> | null {
|
||||
const patternParts = pattern.split('/').filter(Boolean)
|
||||
const requestParts = requestPath.split('/').filter(Boolean)
|
||||
|
||||
if (patternParts.length !== requestParts.length) return null
|
||||
|
||||
const params: Record<string, string> = {}
|
||||
|
||||
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<string, string> = {}
|
||||
|
||||
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
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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 } })
|
||||
}
|
||||
@@ -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<string, unknown> }), 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<string, unknown>,
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {
|
||||
status: 'ready',
|
||||
extracted_data: extraction as unknown as Record<string, unknown>,
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {}
|
||||
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 })
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<Record<string, unknown>> = []
|
||||
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<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
file: File
|
||||
): Promise<{ inboxItem: Record<string, unknown>; 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<void> {
|
||||
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<string, unknown> = {
|
||||
status: 'ready',
|
||||
extracted_data: extraction as unknown as Record<string, unknown>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<string, unknown> = {}
|
||||
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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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<string, unknown> = {
|
||||
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 })
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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<string, unknown> = {}
|
||||
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 })
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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<string, unknown> = {}
|
||||
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 })
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -172,6 +172,7 @@ export async function POST(request: Request) {
|
||||
|
||||
// Execute the import
|
||||
const result = await executeSIEImport(
|
||||
supabase,
|
||||
user.id,
|
||||
parsed,
|
||||
mappings,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string, unknown>).accounting_method === 'accrual' || !(company as Record<string, unknown>).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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -378,6 +378,7 @@ async function createCreditNote(
|
||||
if (completeCreditNote && accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createCreditNoteJournalEntry(
|
||||
supabase,
|
||||
userId,
|
||||
completeCreditNote as Invoice,
|
||||
entityType
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -101,6 +101,7 @@ export async function GET(request: Request) {
|
||||
|
||||
try {
|
||||
const declaration = await calculateVatDeclaration(
|
||||
supabase,
|
||||
user.id,
|
||||
periodType,
|
||||
year,
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,7 @@ async function ensureFiscalPeriod(
|
||||
userId: string,
|
||||
date: string
|
||||
): Promise<string | null> {
|
||||
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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:',
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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<Project, 'id'>) }))
|
||||
.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<CostEntry, 'id'>) }))
|
||||
.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<RevenueEntry, 'id'>) }))
|
||||
.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<string | null>(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<string | null>(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 <ExtensionLoadingSkeleton />
|
||||
|
||||
// --- Render helper for budget alert banner ---
|
||||
const renderBudgetAlert = (p: (typeof projectStats)[number]) => {
|
||||
if (p.budget <= 0) return null
|
||||
if (p.budgetStatus === 'danger') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-400">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span>Kostnaden overskrider budgeten ({p.budgetUsed}% anvant)</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (p.budgetStatus === 'warning') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 px-3 py-2 text-sm text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span>Budgetvarning: {p.budgetUsed}% av budgeten anvand</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// --- Render helper for category breakdown ---
|
||||
const renderCategoryBreakdown = (categoryTotals: { category: string; total: number; pct: number }[]) => {
|
||||
if (categoryTotals.length === 0) return null
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Kostnadsfordelning per kategori</h4>
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kategori</TableHead>
|
||||
<TableHead className="text-right">Belopp</TableHead>
|
||||
<TableHead className="text-right">Andel</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{categoryTotals.map(ct => (
|
||||
<TableRow key={ct.category}>
|
||||
<TableCell className="font-medium">{ct.category}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{ct.total.toLocaleString('sv-SE')} kr
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{ct.pct}%</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Render project card for the Projects tab ---
|
||||
const renderProjectCard = (p: (typeof projectStats)[number]) => {
|
||||
const isExpanded = expandedProject === p.id
|
||||
return (
|
||||
<Card key={p.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer flex-1"
|
||||
onClick={() => setExpandedProject(isExpanded ? null : p.id)}
|
||||
>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{p.name}
|
||||
<Badge variant={p.status === 'active' ? 'default' : 'secondary'}>
|
||||
{p.status === 'active' ? 'Aktiv' : 'Avslutad'}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditProject({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
budget: String(p.budget),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteTarget({ type: 'project', id: p.id, label: p.name })
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<div
|
||||
className="cursor-pointer p-1"
|
||||
onClick={() => setExpandedProject(isExpanded ? null : p.id)}
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isExpanded && (
|
||||
<CardContent className="space-y-4">
|
||||
{/* Budget alert banner */}
|
||||
{renderBudgetAlert(p)}
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Kostnad</p>
|
||||
<p className="font-semibold tabular-nums">{p.totalCost.toLocaleString('sv-SE')} kr</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Intakt</p>
|
||||
<p className="font-semibold tabular-nums">{p.totalRevenue.toLocaleString('sv-SE')} kr</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Marginal</p>
|
||||
<p className="font-semibold tabular-nums">{p.margin}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Budget progress */}
|
||||
{p.budget > 0 && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground mb-1">
|
||||
<span>Budget anvand</span>
|
||||
<span>{p.budgetUsed}% av {p.budget.toLocaleString('sv-SE')} kr</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.min(p.budgetUsed, 100)}
|
||||
className={cn('h-2', getProgressColor(p.budgetStatus))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category breakdown */}
|
||||
{renderCategoryBreakdown(p.categoryTotals)}
|
||||
|
||||
{/* Cost entries */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Kostnader</h4>
|
||||
<div className="flex gap-2 mb-2 flex-wrap">
|
||||
<Input placeholder="Beskrivning" value={costDesc} onChange={e => setCostDesc(e.target.value)} className="max-w-xs" />
|
||||
<Input type="number" placeholder="Belopp" value={costAmount} onChange={e => setCostAmount(e.target.value)} className="w-28" />
|
||||
<Select value={costCategory} onValueChange={setCostCategory}>
|
||||
<SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{COST_CATEGORIES.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" onClick={() => handleAddCost(p.id)} disabled={isSubmitting}>Lagg till</Button>
|
||||
</div>
|
||||
{p.costs.length > 0 && (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead>Kategori</TableHead>
|
||||
<TableHead className="text-right">Belopp</TableHead>
|
||||
<TableHead className="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{p.costs.map(c => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>{c.date}</TableCell>
|
||||
<TableCell>{c.description}</TableCell>
|
||||
<TableCell>{c.category}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.amount.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditEntry({
|
||||
type: 'cost',
|
||||
id: c.id,
|
||||
projectId: c.projectId,
|
||||
description: c.description,
|
||||
amount: String(c.amount),
|
||||
date: c.date,
|
||||
category: c.category,
|
||||
})}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget({
|
||||
type: 'cost',
|
||||
id: c.id,
|
||||
label: c.description || 'kostnad',
|
||||
})}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Revenue entries */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Intakter</h4>
|
||||
<div className="flex gap-2 mb-2 flex-wrap">
|
||||
<Input placeholder="Beskrivning" value={revDesc} onChange={e => setRevDesc(e.target.value)} className="max-w-xs" />
|
||||
<Input type="number" placeholder="Belopp" value={revAmount} onChange={e => setRevAmount(e.target.value)} className="w-28" />
|
||||
<Button size="sm" onClick={() => handleAddRevenue(p.id)} disabled={isSubmitting}>Lagg till</Button>
|
||||
</div>
|
||||
{p.revenues.length > 0 && (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead className="text-right">Belopp</TableHead>
|
||||
<TableHead className="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{p.revenues.map(r => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{r.date}</TableCell>
|
||||
<TableCell>{r.description}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{r.amount.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditEntry({
|
||||
type: 'revenue',
|
||||
id: r.id,
|
||||
projectId: r.projectId,
|
||||
description: r.description,
|
||||
amount: String(r.amount),
|
||||
date: r.date,
|
||||
})}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget({
|
||||
type: 'revenue',
|
||||
id: r.id,
|
||||
label: r.description || 'intakt',
|
||||
})}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Complete project button (active only) */}
|
||||
{p.status === 'active' && (
|
||||
<div className="pt-2 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCompleteProjectId(p.id)}
|
||||
className="text-green-700 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950/30"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
Avsluta projekt
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Oversikt</TabsTrigger>
|
||||
<TabsTrigger value="projects">Projekt</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6 mt-4">
|
||||
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
|
||||
<KPICard label="Aktiva projekt" value={activeProjects.length} />
|
||||
<KPICard label="Total intakt" value={totalRevenue.toLocaleString('sv-SE')} suffix="kr" />
|
||||
<KPICard label="Total kostnad" value={totalCosts.toLocaleString('sv-SE')} suffix="kr" />
|
||||
<KPICard label="Snittmarginal" value={avgMargin} suffix="%" />
|
||||
</div>
|
||||
|
||||
{/* Active projects */}
|
||||
{activeProjects.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Aktiva projekt</h3>
|
||||
{activeProjects.map(p => (
|
||||
<Card key={p.id}>
|
||||
<CardContent className="pt-4">
|
||||
{/* Budget alert */}
|
||||
{renderBudgetAlert(p)}
|
||||
|
||||
<div className="flex items-center justify-between mb-2 mt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-sm">{p.name}</p>
|
||||
<Badge variant="default">Aktiv</Badge>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<span className="text-muted-foreground">Marginal: </span>
|
||||
<span className="font-medium tabular-nums">{p.margin}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground mb-2">
|
||||
<span>Kostnad: {p.totalCost.toLocaleString('sv-SE')} kr</span>
|
||||
<span>Intakt: {p.totalRevenue.toLocaleString('sv-SE')} kr</span>
|
||||
{p.budget > 0 && <span>Budget: {p.budget.toLocaleString('sv-SE')} kr</span>}
|
||||
</div>
|
||||
{p.budget > 0 && (
|
||||
<Progress
|
||||
value={Math.min(p.budgetUsed, 100)}
|
||||
className={cn('h-2', getProgressColor(p.budgetStatus))}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed projects */}
|
||||
{completedProjects.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Avslutade projekt</h3>
|
||||
{completedProjects.map(p => (
|
||||
<Card key={p.id} className="border-muted">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-sm">{p.name}</p>
|
||||
<Badge variant="secondary">Avslutad</Badge>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={cn(
|
||||
'text-lg font-bold tabular-nums',
|
||||
p.margin >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
)}>
|
||||
{p.margin}% marginal
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>Kostnad: {p.totalCost.toLocaleString('sv-SE')} kr</span>
|
||||
<span>Intakt: {p.totalRevenue.toLocaleString('sv-SE')} kr</span>
|
||||
<span>Resultat: {(Math.round((p.totalRevenue - p.totalCost) * 100) / 100).toLocaleString('sv-SE')} kr</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="projects" className="space-y-6 mt-4">
|
||||
{!showNewProject ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setShowNewProject(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Nytt projekt
|
||||
</Button>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Input placeholder="Projektnamn" value={newProjectName} onChange={e => setNewProjectName(e.target.value)} className="max-w-xs" />
|
||||
<Input type="number" placeholder="Budget (kr)" value={newProjectBudget} onChange={e => setNewProjectBudget(e.target.value)} className="max-w-xs" />
|
||||
<Button size="sm" onClick={handleAddProject} disabled={!newProjectName.trim()}>Skapa</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setShowNewProject(false)}>Avbryt</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeProjects.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Aktiva projekt</h3>
|
||||
{activeProjects.map(p => renderProjectCard(p))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{completedProjects.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Avslutade projekt</h3>
|
||||
{completedProjects.map(p => renderProjectCard(p))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => { 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 */}
|
||||
<EditEntryDialog
|
||||
open={editEntry !== null}
|
||||
onOpenChange={(open) => { if (!open) setEditEntry(null) }}
|
||||
title={editEntry?.type === 'cost' ? 'Redigera kostnad' : 'Redigera intakt'}
|
||||
description="Andra uppgifterna och klicka Spara."
|
||||
onSave={handleSaveEditEntry}
|
||||
isSaving={isSavingEdit}
|
||||
>
|
||||
{editEntry && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-desc">Beskrivning</Label>
|
||||
<Input
|
||||
id="edit-desc"
|
||||
value={editEntry.description}
|
||||
onChange={e => setEditEntry({ ...editEntry, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-amount">Belopp (kr)</Label>
|
||||
<Input
|
||||
id="edit-amount"
|
||||
type="number"
|
||||
value={editEntry.amount}
|
||||
onChange={e => setEditEntry({ ...editEntry, amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-date">Datum</Label>
|
||||
<Input
|
||||
id="edit-date"
|
||||
type="date"
|
||||
value={editEntry.date}
|
||||
onChange={e => setEditEntry({ ...editEntry, date: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{editEntry.type === 'cost' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Kategori</Label>
|
||||
<Select
|
||||
value={editEntry.category || COST_CATEGORIES[0]}
|
||||
onValueChange={val => setEditEntry({ ...editEntry, category: val })}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{COST_CATEGORIES.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Edit project dialog */}
|
||||
<EditEntryDialog
|
||||
open={editProject !== null}
|
||||
onOpenChange={(open) => { if (!open) setEditProject(null) }}
|
||||
title="Redigera projekt"
|
||||
description="Andra projektnamn och budget."
|
||||
onSave={handleSaveEditProject}
|
||||
isSaving={isSavingProject}
|
||||
>
|
||||
{editProject && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-proj-name">Projektnamn</Label>
|
||||
<Input
|
||||
id="edit-proj-name"
|
||||
value={editProject.name}
|
||||
onChange={e => setEditProject({ ...editProject, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-proj-budget">Budget (kr)</Label>
|
||||
<Input
|
||||
id="edit-proj-budget"
|
||||
type="number"
|
||||
value={editProject.budget}
|
||||
onChange={e => setEditProject({ ...editProject, budget: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Complete project confirmation dialog */}
|
||||
<ConfirmDeleteDialog
|
||||
open={completeProjectId !== null}
|
||||
onOpenChange={(open) => { if (!open) setCompleteProjectId(null) }}
|
||||
title="Avsluta projekt"
|
||||
description={`Vill du markera projektet som avslutat? Projektet flyttas till "Avslutade" och kan inte ateraktiveras.`}
|
||||
onConfirm={handleCompleteProject}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<Customer[]>(() =>
|
||||
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<Job[]>(() =>
|
||||
data.filter(d => d.key.startsWith('job:'))
|
||||
.map(d => ({ id: d.key.replace('job:', ''), ...(d.value as Omit<Job, 'id'>) }))
|
||||
.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<Customer | null>(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<Job | null>(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<string | null>(null)
|
||||
const [isDeletingJob, setIsDeletingJob] = useState(false)
|
||||
|
||||
// Per-customer used quota for selected year (only completed jobs count)
|
||||
const customerYearlyUsed = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
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 <ExtensionLoadingSkeleton />
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<Tabs defaultValue="calculator">
|
||||
<TabsList>
|
||||
<TabsTrigger value="calculator">Kalkylator</TabsTrigger>
|
||||
<TabsTrigger value="customers">Kunder</TabsTrigger>
|
||||
<TabsTrigger value="jobs">Jobb</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="calculator" className="space-y-6 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Ar:</Label>
|
||||
<Select value={selectedYear} onValueChange={setSelectedYear}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map(y => (
|
||||
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{customers.length === 0 ? (
|
||||
<div className="rounded-xl border p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Lagg till kunder under fliken "Kunder" for att borja berakna ROT-avdrag.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DataEntryForm
|
||||
title="Berakna ROT-avdrag"
|
||||
onSubmit={handleSubmitJob}
|
||||
submitLabel="Spara jobb"
|
||||
isSubmitting={isSubmitting}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Kund</Label>
|
||||
<Select value={customerId} onValueChange={setCustomerId}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Beskrivning</Label>
|
||||
<Input placeholder="T.ex. Badrumsrenovering" value={description} onChange={e => setDescription(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Totalt belopp (inkl. moms)</Label>
|
||||
<Input type="number" min="0" placeholder="0" value={total} onChange={e => setTotal(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Materialkostnad</Label>
|
||||
<Input type="number" min="0" placeholder="0" value={material} onChange={e => setMaterial(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalNum > 0 && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Arbetskostnad:</span>
|
||||
<span className="text-right tabular-nums">{labor.toLocaleString('sv-SE')} kr</span>
|
||||
<span className="text-muted-foreground">ROT-avdrag (30%):</span>
|
||||
<span className="text-right tabular-nums font-medium text-green-600">-{rotDeduction.toLocaleString('sv-SE')} kr</span>
|
||||
<span className="text-muted-foreground">Kunden betalar:</span>
|
||||
<span className="text-right tabular-nums font-semibold">{customerPays.toLocaleString('sv-SE')} kr</span>
|
||||
<span className="text-muted-foreground">Kvarvarande kvot:</span>
|
||||
<span className="text-right tabular-nums">{Math.max(remainingQuota - rotDeduction, 0).toLocaleString('sv-SE')} kr</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</DataEntryForm>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="customers" className="space-y-6 mt-4">
|
||||
<div className="flex gap-2 flex-wrap items-start">
|
||||
<Input placeholder="Kundnamn" value={newCustomerName} onChange={e => setNewCustomerName(e.target.value)} className="max-w-xs" />
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
placeholder="Personnummer (YYYYMMDD-XXXX)"
|
||||
value={newCustomerPnr}
|
||||
onChange={e => setNewCustomerPnr(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
{newCustomerPnrError && (
|
||||
<p className="text-xs text-red-600">{newCustomerPnrError}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" onClick={handleAddCustomer} disabled={!canAddCustomer}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Lagg till
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{customers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Inga kunder tillagda annu.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{customers.map(cust => {
|
||||
const used = customerYearlyUsed.get(cust.id) ?? 0
|
||||
const pct = Math.min(Math.round((used / MAX_ROT_YEARLY) * 100), 100)
|
||||
return (
|
||||
<Card key={cust.id}>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<p className="font-medium text-sm">{cust.name}</p>
|
||||
{cust.personalNumber && (
|
||||
<p className="text-xs text-muted-foreground">{cust.personalNumber}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm tabular-nums">
|
||||
{used.toLocaleString('sv-SE')} / {MAX_ROT_YEARLY.toLocaleString('sv-SE')} kr
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditCustomer(cust)}>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={pct} className="h-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="jobs" className="space-y-6 mt-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Ar:</Label>
|
||||
<Select value={selectedYear} onValueChange={setSelectedYear}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map(y => (
|
||||
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{completedJobCount > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleExportCsv}>
|
||||
<Download className="h-4 w-4 mr-1" /> Exportera CSV
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<KPICard label="Antal jobb" value={jobs.length} />
|
||||
<KPICard label="Total ROT" value={totalRot.toLocaleString('sv-SE')} suffix="kr" />
|
||||
<KPICard label="Total omsattning" value={totalRevenue.toLocaleString('sv-SE')} suffix="kr" />
|
||||
</div>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Inga jobb registrerade for {selectedYear}.</p>
|
||||
) : (
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Kund</TableHead>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead className="text-right">Totalt</TableHead>
|
||||
<TableHead className="text-right">ROT-avdrag</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-28"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.map(job => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell>{job.date}</TableCell>
|
||||
<TableCell className="font-medium">{job.customerName}</TableCell>
|
||||
<TableCell>{job.description}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{job.total.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-green-600">{job.rotDeduction.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={job.status === 'completed' ? 'default' : 'secondary'}>
|
||||
{job.status === 'completed' ? 'Klar' : 'Utkast'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
{job.status === 'draft' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleMarkCompleted(job)} title="Markera som klar">
|
||||
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditJob(job)} title="Redigera">
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeletingJobId(job.id)} title="Ta bort">
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Edit Customer Dialog */}
|
||||
<EditEntryDialog
|
||||
open={editingCustomer !== null}
|
||||
onOpenChange={open => { if (!open) setEditingCustomer(null) }}
|
||||
title="Redigera kund"
|
||||
description="Uppdatera kunduppgifter."
|
||||
onSave={handleSaveCustomer}
|
||||
isSaving={isSavingCustomer}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Namn</Label>
|
||||
<Input value={editCustomerName} onChange={e => setEditCustomerName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Personnummer</Label>
|
||||
<Input
|
||||
placeholder="YYYYMMDD-XXXX"
|
||||
value={editCustomerPnr}
|
||||
onChange={e => setEditCustomerPnr(e.target.value)}
|
||||
/>
|
||||
{editCustomerPnrError && (
|
||||
<p className="text-xs text-red-600">{editCustomerPnrError}</p>
|
||||
)}
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Edit Job Dialog */}
|
||||
<EditEntryDialog
|
||||
open={editingJob !== null}
|
||||
onOpenChange={open => { if (!open) setEditingJob(null) }}
|
||||
title="Redigera jobb"
|
||||
description="Uppdatera jobbdetaljer. ROT-avdrag beraknas om automatiskt."
|
||||
onSave={handleSaveJob}
|
||||
isSaving={isSavingJob}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Kund</Label>
|
||||
<Select value={editJobCustomerId} onValueChange={setEditJobCustomerId}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Beskrivning</Label>
|
||||
<Input value={editJobDescription} onChange={e => setEditJobDescription(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Totalt belopp (inkl. moms)</Label>
|
||||
<Input type="number" min="0" value={editJobTotal} onChange={e => setEditJobTotal(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Materialkostnad</Label>
|
||||
<Input type="number" min="0" value={editJobMaterial} onChange={e => setEditJobMaterial(e.target.value)} />
|
||||
</div>
|
||||
{(() => {
|
||||
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 ? (
|
||||
<div className="rounded-lg border p-3 bg-muted/50 text-sm space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Arbetskostnad:</span>
|
||||
<span className="tabular-nums">{editLabor.toLocaleString('sv-SE')} kr</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">ROT-avdrag (30%):</span>
|
||||
<span className="tabular-nums font-medium text-green-600">-{editRot.toLocaleString('sv-SE')} kr</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Delete Job Confirmation */}
|
||||
<ConfirmDeleteDialog
|
||||
open={deletingJobId !== null}
|
||||
onOpenChange={open => { 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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||
<Minus className="h-3 w-3" />
|
||||
<span>0%</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (previous === 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-green-600">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
<span>Ny</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const pctChange = Math.round(((current - previous) / previous) * 1000) / 10
|
||||
if (pctChange === 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||
<Minus className="h-3 w-3" />
|
||||
<span>0%</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const improving = pctChange > 0
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-0.5 text-xs',
|
||||
improving ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{improving
|
||||
? <ArrowUp className="h-3 w-3" />
|
||||
: <ArrowDown className="h-3 w-3" />
|
||||
}
|
||||
<span>{pctChange > 0 ? '+' : ''}{pctChange}%</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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<RevenueEntry, 'id'>),
|
||||
}))
|
||||
, [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<SortMode>('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<RevenueEntry | null>(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<string | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Rename channel dialog state
|
||||
const [renameDialogOpen, setRenameDialogOpen] = useState(false)
|
||||
const [renamingChannel, setRenamingChannel] = useState<string | null>(null)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [isSavingRename, setIsSavingRename] = useState(false)
|
||||
|
||||
// Color picker state
|
||||
const [colorPickerChannel, setColorPickerChannel] = useState<string | null>(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<string, { revenue: number; orders: number }>()
|
||||
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<string, { revenue: number; orders: number }>()
|
||||
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<string, number>()
|
||||
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<string, Map<string, { revenue: number; orders: number }>>()
|
||||
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<string, { revenue: number; orders: number }>,
|
||||
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<string, string>) => {
|
||||
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 <ExtensionLoadingSkeleton />
|
||||
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<SetupPrompt
|
||||
title="Konfigurera kanaler"
|
||||
description="Ange dina forsaljningskanaler (kommaseparerade, t.ex. Webshop, Amazon, Fysisk butik)."
|
||||
fields={[{ key: 'channels', label: 'Kanaler', type: 'text', placeholder: 'Webshop, Amazon, Fysisk butik' }]}
|
||||
onSave={handleSetup}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
|
||||
<KPICard
|
||||
label="Total intakt"
|
||||
value={formatCurrency(totalRevenue)}
|
||||
suffix="kr"
|
||||
trend={prevYearTotalRevenue > 0 ? {
|
||||
value: Math.round(((totalRevenue - prevYearTotalRevenue) / prevYearTotalRevenue) * 1000) / 10,
|
||||
label: 'mot fg ar',
|
||||
} : undefined}
|
||||
/>
|
||||
<KPICard label="Basta kanal" value={bestChannel} />
|
||||
<KPICard
|
||||
label="Genomsnittligt ordervarde"
|
||||
value={overallAOV > 0 ? overallAOV.toLocaleString('sv-SE') : '-'}
|
||||
suffix={overallAOV > 0 ? 'kr' : undefined}
|
||||
/>
|
||||
<KPICard label="Antal kanaler" value={channels.length} />
|
||||
</div>
|
||||
|
||||
{/* Channel management */}
|
||||
<div className="rounded-xl border p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">Kanaler</h3>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{channels.map(ch => (
|
||||
<div key={ch.name} className="relative flex items-center gap-1.5 rounded-md border px-2 py-1 text-sm">
|
||||
{/* Color swatch - clickable for color picker */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-3 h-3 rounded-full border border-black/10 cursor-pointer hover:ring-2 hover:ring-offset-1 hover:ring-primary/30"
|
||||
style={{ backgroundColor: ch.color }}
|
||||
onClick={() => setColorPickerChannel(
|
||||
colorPickerChannel === ch.name ? null : ch.name
|
||||
)}
|
||||
title="Byt farg"
|
||||
/>
|
||||
<span>{ch.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={() => handleStartRename(ch.name)}
|
||||
title="Byt namn"
|
||||
>
|
||||
<Pencil className="h-3 w-3 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={() => handleRemoveChannel(ch.name)}
|
||||
title="Ta bort kanal"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-muted-foreground" />
|
||||
</Button>
|
||||
|
||||
{/* Color picker dropdown */}
|
||||
{colorPickerChannel === ch.name && (
|
||||
<div className="absolute top-full left-0 mt-1 z-10 rounded-md border bg-popover p-2 shadow-md">
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{COLOR_PRESETS.map(color => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-6 h-6 rounded-full border-2 cursor-pointer hover:scale-110 transition-transform',
|
||||
ch.color === color ? 'border-foreground' : 'border-transparent'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => handleChangeChannelColor(ch.name, color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Ny kanal"
|
||||
value={newChannelName}
|
||||
onChange={e => setNewChannelName(e.target.value)}
|
||||
className="max-w-xs"
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddChannel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleAddChannel} disabled={!newChannelName.trim()}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Lagg till
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entry form */}
|
||||
<DataEntryForm
|
||||
title="Registrera manadsdata"
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Registrera"
|
||||
isSubmitting={isSubmitting}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Manad</Label>
|
||||
<Input type="month" value={entryMonth} onChange={e => setEntryMonth(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Kanal</Label>
|
||||
<Select value={entryChannel} onValueChange={setEntryChannel}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{channels.map(c => <SelectItem key={c.name} value={c.name}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Intakt (kr)</Label>
|
||||
<Input type="number" min="0" placeholder="0" value={entryRevenue} onChange={e => setEntryRevenue(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Antal ordrar</Label>
|
||||
<Input type="number" min="0" placeholder="0" value={entryOrders} onChange={e => setEntryOrders(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</DataEntryForm>
|
||||
|
||||
{/* Duplicate confirmation dialog */}
|
||||
<Dialog open={duplicateDialogOpen} onOpenChange={setDuplicateDialogOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Post finns redan</DialogTitle>
|
||||
<DialogDescription>
|
||||
Det finns redan en post for {pendingEntry?.channel} i {pendingEntry?.month}.
|
||||
Vill du uppdatera den befintliga posten eller skapa en ny?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-row">
|
||||
<Button variant="outline" onClick={() => setDuplicateDialogOpen(false)} disabled={isSubmitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleDuplicateCreateNew} disabled={isSubmitting}>
|
||||
Skapa ny
|
||||
</Button>
|
||||
<Button onClick={handleDuplicateUpdate} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Sparar...' : 'Uppdatera befintlig'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Channel comparison bar chart */}
|
||||
{sortedChannelTotals.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold">Kanaljamforelse</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Sortera:</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={sortMode === 'revenue' ? 'default' : 'outline'}
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => setSortMode('revenue')}
|
||||
>
|
||||
Intakt
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={sortMode === 'growth' ? 'default' : 'outline'}
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => setSortMode('growth')}
|
||||
>
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
Tillvaxt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border p-4 space-y-3">
|
||||
{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 (
|
||||
<div key={ct.channel} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">{ct.channel}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
AOV: {aov > 0 ? aov.toLocaleString('sv-SE') + ' kr' : '-'}
|
||||
</span>
|
||||
<GrowthIndicator current={ct.revenue} previous={prevRev} />
|
||||
<span className="tabular-nums">{formatCurrency(ct.revenue)} kr</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6 w-full rounded bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded transition-all"
|
||||
style={{
|
||||
width: `${barWidth}%`,
|
||||
backgroundColor: channelConfig?.color ?? '#3b82f6',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entries table with edit/delete */}
|
||||
{entries.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Registrerade poster</h3>
|
||||
<div className="rounded-xl border overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Manad</TableHead>
|
||||
<TableHead>Kanal</TableHead>
|
||||
<TableHead className="text-right">Intakt</TableHead>
|
||||
<TableHead className="text-right">Ordrar</TableHead>
|
||||
<TableHead className="text-right">AOV</TableHead>
|
||||
<TableHead className="text-right w-[80px]">Atgarder</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map(entry => {
|
||||
const channelConfig = channels.find(c => c.name === entry.channel)
|
||||
return (
|
||||
<TableRow
|
||||
key={entry.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleStartEdit(entry)}
|
||||
>
|
||||
<TableCell className="font-medium">{entry.month}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: channelConfig?.color ?? '#3b82f6' }}
|
||||
/>
|
||||
{entry.channel}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatCurrency(entry.revenue)} kr
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{entry.orderCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatAOV(entry.revenue, entry.orderCount)} {entry.orderCount > 0 ? 'kr' : ''}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1" onClick={e => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => handleStartEdit(entry)}
|
||||
title="Redigera"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => handleStartDelete(entry.id)}
|
||||
title="Ta bort"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly comparison table */}
|
||||
{monthlyComparison.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Manadsjamforelse</h3>
|
||||
<div className="rounded-xl border overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Manad</TableHead>
|
||||
{channels.map(ch => (
|
||||
<TableHead key={ch.name} className="text-right">{ch.name}</TableHead>
|
||||
))}
|
||||
<TableHead className="text-right font-semibold">Total</TableHead>
|
||||
<TableHead className="text-right">AOV</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{monthlyComparison.map(row => (
|
||||
<TableRow key={row.month}>
|
||||
<TableCell className="font-medium">{row.month}</TableCell>
|
||||
{channels.map(ch => {
|
||||
const chData = row.channels[ch.name]
|
||||
return (
|
||||
<TableCell key={ch.name} className="text-right tabular-nums">
|
||||
{chData ? formatCurrency(chData.revenue) : '0'}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
<TableCell className="text-right tabular-nums font-semibold">
|
||||
{formatCurrency(row.total)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.totalOrders > 0
|
||||
? Math.round(row.total / row.totalOrders).toLocaleString('sv-SE') + ' kr'
|
||||
: '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Period comparison: previous year */}
|
||||
{(prevYearEntries.length > 0 || channelTotals.length > 0) && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Arsjamforelse per kanal</h3>
|
||||
<div className="rounded-xl border overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kanal</TableHead>
|
||||
<TableHead className="text-right">Nuvarande period</TableHead>
|
||||
<TableHead className="text-right">Foregaende ar</TableHead>
|
||||
<TableHead className="text-right">Tillvaxt</TableHead>
|
||||
<TableHead className="text-right">AOV (nu)</TableHead>
|
||||
<TableHead className="text-right">AOV (fg ar)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedChannelTotals.map(ct => {
|
||||
const prevData = prevYearChannelTotals.get(ct.channel)
|
||||
const prevRev = prevData?.revenue ?? 0
|
||||
const prevOrd = prevData?.orders ?? 0
|
||||
return (
|
||||
<TableRow key={ct.channel}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: channels.find(c => c.name === ct.channel)?.color ?? '#3b82f6' }}
|
||||
/>
|
||||
{ct.channel}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatCurrency(ct.revenue)} kr
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{prevRev > 0 ? formatCurrency(prevRev) + ' kr' : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<GrowthIndicator current={ct.revenue} previous={prevRev} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatAOV(ct.revenue, ct.orders)} {ct.orders > 0 ? 'kr' : ''}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatAOV(prevRev, prevOrd)} {prevOrd > 0 ? 'kr' : ''}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{/* Totals row */}
|
||||
<TableRow className="border-t-2 font-semibold">
|
||||
<TableCell>Totalt</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatCurrency(totalRevenue)} kr
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{prevYearTotalRevenue > 0 ? formatCurrency(prevYearTotalRevenue) + ' kr' : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<GrowthIndicator current={totalRevenue} previous={prevYearTotalRevenue} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{overallAOV > 0 ? overallAOV.toLocaleString('sv-SE') + ' kr' : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{(() => {
|
||||
const prevTotalOrders = prevYearEntries.reduce((s, e) => s + e.orderCount, 0)
|
||||
return prevTotalOrders > 0
|
||||
? Math.round(prevYearTotalRevenue / prevTotalOrders).toLocaleString('sv-SE') + ' kr'
|
||||
: '-'
|
||||
})()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit entry dialog */}
|
||||
<EditEntryDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
title="Redigera post"
|
||||
description={editingEntry ? `${editingEntry.channel} - ${editingEntry.month}` : undefined}
|
||||
onSave={handleSaveEdit}
|
||||
isSaving={isSavingEdit}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-month">Manad</Label>
|
||||
<Input
|
||||
id="edit-month"
|
||||
type="month"
|
||||
value={editMonth}
|
||||
onChange={e => setEditMonth(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-channel">Kanal</Label>
|
||||
<Select value={editChannel} onValueChange={setEditChannel}>
|
||||
<SelectTrigger id="edit-channel"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{channels.map(c => (
|
||||
<SelectItem key={c.name} value={c.name}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-revenue">Intakt (kr)</Label>
|
||||
<Input
|
||||
id="edit-revenue"
|
||||
type="number"
|
||||
min="0"
|
||||
value={editRevenue}
|
||||
onChange={e => setEditRevenue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-orders">Antal ordrar</Label>
|
||||
<Input
|
||||
id="edit-orders"
|
||||
type="number"
|
||||
min="0"
|
||||
value={editOrders}
|
||||
onChange={e => setEditOrders(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title="Ta bort post"
|
||||
description="Ar du saker pa att du vill ta bort denna intaktspost? Atgarden kan inte angras."
|
||||
onConfirm={handleConfirmDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
{/* Rename channel dialog */}
|
||||
<EditEntryDialog
|
||||
open={renameDialogOpen}
|
||||
onOpenChange={setRenameDialogOpen}
|
||||
title="Byt namn pa kanal"
|
||||
description={`Nuvarande namn: ${renamingChannel ?? ''}. Alla registrerade poster uppdateras automatiskt.`}
|
||||
onSave={handleRenameChannel}
|
||||
isSaving={isSavingRename}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rename-channel">Nytt namn</Label>
|
||||
<Input
|
||||
id="rename-channel"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
placeholder="Kanalnamn"
|
||||
/>
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<ShopifyOrder, 'id'>) }))
|
||||
.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<ImportRecord, 'id'>) }))
|
||||
.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<ShopifyOrder | null>(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<string | null>(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<string>()
|
||||
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<string>()
|
||||
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<string, string>[]) => {
|
||||
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<string, { revenue: number; count: number }>()
|
||||
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<string, { taxes: number; subtotal: number }>()
|
||||
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<string, { count: number; total: number }>()
|
||||
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<string, number>()
|
||||
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 <ExtensionLoadingSkeleton />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Edit order dialog */}
|
||||
<EditEntryDialog
|
||||
open={editOrder !== null}
|
||||
onOpenChange={open => { if (!open) setEditOrder(null) }}
|
||||
title="Redigera order"
|
||||
description="Andra uppgifterna for denna order."
|
||||
onSave={handleSaveEdit}
|
||||
isSaving={isSavingEdit}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Order</Label>
|
||||
<Input value={editName} onChange={e => setEditName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Datum</Label>
|
||||
<Input type="date" value={editDate} onChange={e => setEditDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Total</Label>
|
||||
<Input type="number" step="0.01" min="0" value={editTotal} onChange={e => setEditTotal(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Subtotal</Label>
|
||||
<Input type="number" step="0.01" min="0" value={editSubtotal} onChange={e => setEditSubtotal(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Frakt</Label>
|
||||
<Input type="number" step="0.01" min="0" value={editShipping} onChange={e => setEditShipping(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Moms</Label>
|
||||
<Input type="number" step="0.01" min="0" value={editTaxes} onChange={e => setEditTaxes(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Betalmetod</Label>
|
||||
<Input value={editPaymentMethod} onChange={e => setEditPaymentMethod(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Leveransstatus</Label>
|
||||
<Input value={editFulfillmentStatus} onChange={e => setEditFulfillmentStatus(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Delete order dialog */}
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteOrderId !== null}
|
||||
onOpenChange={open => { 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}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="import">
|
||||
<TabsList>
|
||||
<TabsTrigger value="import">Import</TabsTrigger>
|
||||
<TabsTrigger value="orders">Ordrar</TabsTrigger>
|
||||
<TabsTrigger value="stats">Statistik</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Import tab */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<TabsContent value="import" className="space-y-6 mt-4">
|
||||
<CsvImportWizard
|
||||
targetFields={TARGET_FIELDS}
|
||||
defaultMappings={DEFAULT_MAPPINGS}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
|
||||
{/* Manual order entry */}
|
||||
<DataEntryForm
|
||||
title="Lagg till order manuellt"
|
||||
onSubmit={handleManualSubmit}
|
||||
submitLabel="Lagg till"
|
||||
isSubmitting={isSubmittingManual}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-name">Order *</Label>
|
||||
<Input
|
||||
id="manual-name"
|
||||
value={manualName}
|
||||
onChange={e => setManualName(e.target.value)}
|
||||
placeholder="t.ex. #1001"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-date">Datum *</Label>
|
||||
<Input
|
||||
id="manual-date"
|
||||
type="date"
|
||||
value={manualDate}
|
||||
onChange={e => setManualDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-total">Total</Label>
|
||||
<Input
|
||||
id="manual-total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={manualTotal}
|
||||
onChange={e => setManualTotal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-subtotal">Subtotal</Label>
|
||||
<Input
|
||||
id="manual-subtotal"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={manualSubtotal}
|
||||
onChange={e => setManualSubtotal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-shipping">Frakt</Label>
|
||||
<Input
|
||||
id="manual-shipping"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={manualShipping}
|
||||
onChange={e => setManualShipping(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-taxes">Moms</Label>
|
||||
<Input
|
||||
id="manual-taxes"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={manualTaxes}
|
||||
onChange={e => setManualTaxes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-payment">Betalmetod</Label>
|
||||
<Input
|
||||
id="manual-payment"
|
||||
value={manualPaymentMethod}
|
||||
onChange={e => setManualPaymentMethod(e.target.value)}
|
||||
placeholder="t.ex. Stripe"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-fulfillment">Leveransstatus</Label>
|
||||
<Input
|
||||
id="manual-fulfillment"
|
||||
value={manualFulfillmentStatus}
|
||||
onChange={e => setManualFulfillmentStatus(e.target.value)}
|
||||
placeholder="t.ex. fulfilled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DataEntryForm>
|
||||
|
||||
{imports.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Importhistorik</h3>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead className="text-right">Ordrar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{imports.map(imp => (
|
||||
<TableRow key={imp.id}>
|
||||
<TableCell>{imp.date}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{imp.rowCount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Orders tab */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<TabsContent value="orders" className="space-y-6 mt-4">
|
||||
{/* Filters */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Sok</Label>
|
||||
<Input
|
||||
placeholder="Sok ordrar..."
|
||||
value={searchQuery}
|
||||
onChange={e => { setSearchQuery(e.target.value); resetPage() }}
|
||||
className="w-48"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Fran datum</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={e => { setDateFrom(e.target.value); resetPage() }}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Till datum</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={e => { setDateTo(e.target.value); resetPage() }}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Betalmetod</Label>
|
||||
<Select value={paymentFilter} onValueChange={v => { setPaymentFilter(v); resetPage() }}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">Alla</SelectItem>
|
||||
{paymentMethods.map(m => (
|
||||
<SelectItem key={m} value={m}>{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Leveransstatus</Label>
|
||||
<Select value={fulfillmentFilter} onValueChange={v => { setFulfillmentFilter(v); resetPage() }}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">Alla</SelectItem>
|
||||
{fulfillmentStatuses.map(s => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeFilterCount > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{activeFilterCount} aktiva filter</Badge>
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={clearFilters}>
|
||||
Rensa filter
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredOrders.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Inga ordrar hittades.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Order</TableHead>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead className="text-right">Total</TableHead>
|
||||
<TableHead className="text-right">Frakt</TableHead>
|
||||
<TableHead className="text-right">Moms</TableHead>
|
||||
<TableHead>Betalning</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedOrders.map(o => (
|
||||
<TableRow key={o.id}>
|
||||
<TableCell className="font-medium">{o.name}</TableCell>
|
||||
<TableCell>{o.createdAt}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{o.total.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{o.shipping.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{o.taxes.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell>{o.paymentMethod}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={o.fulfillmentStatus === 'fulfilled' ? 'default' : 'secondary'}>
|
||||
{o.fulfillmentStatus || 'Okant'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditOrder(o)}>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteOrderId(o.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredOrders.length} ordrar totalt
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={safePage <= 1}
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Foregaende
|
||||
</Button>
|
||||
<span className="text-sm tabular-nums">
|
||||
Sida {safePage} av {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={safePage >= totalPages}
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
Nasta
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Stats tab */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<TabsContent value="stats" className="space-y-6 mt-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<KPICard label="Antal ordrar" value={orders.length} />
|
||||
<KPICard label="Total intakt" value={totalRevenue.toLocaleString('sv-SE')} suffix="kr" />
|
||||
<KPICard label="AOV" value={aov.toLocaleString('sv-SE')} suffix="kr" />
|
||||
</div>
|
||||
|
||||
{/* VAT analytics */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Momsanalys</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||
<KPICard
|
||||
label="Total moms"
|
||||
value={(Math.round(totalTaxes * 100) / 100).toLocaleString('sv-SE')}
|
||||
suffix="kr"
|
||||
/>
|
||||
<KPICard
|
||||
label="Genomsnittlig momssats"
|
||||
value={avgVatRate}
|
||||
suffix="%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{monthlyVat.length > 0 && (
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Manad</TableHead>
|
||||
<TableHead className="text-right">Subtotal</TableHead>
|
||||
<TableHead className="text-right">Moms</TableHead>
|
||||
<TableHead className="text-right">Momssats</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{monthlyVat.map(m => (
|
||||
<TableRow key={m.month}>
|
||||
<TableCell className="font-medium">{m.month}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{m.subtotal.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{m.taxes.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{m.rate}%</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{monthlyTrend.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Intakt per manad</h3>
|
||||
<MonthlyTrendTable rows={monthlyTrend} valueLabel="Intakt" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paymentBreakdown.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Per betalmetod</h3>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Betalmetod</TableHead>
|
||||
<TableHead className="text-right">Ordrar</TableHead>
|
||||
<TableHead className="text-right">Total</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paymentBreakdown.map(p => (
|
||||
<TableRow key={p.method}>
|
||||
<TableCell className="font-medium">{p.method}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{p.count}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{p.total.toLocaleString('sv-SE')} kr</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fulfillmentBreakdown.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Per leveransstatus</h3>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Ordrar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fulfillmentBreakdown.map(f => (
|
||||
<TableRow key={f.status}>
|
||||
<TableCell className="font-medium">{f.status}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{f.count}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<string, string>[]): 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<string, CurrencyExposure>()
|
||||
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<string, unknown>
|
||||
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<ReportData>('export', 'currency-receivables')
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
||||
|
||||
const [year, setYear] = useState(currentYear())
|
||||
const [report, setReport] = useState<ReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const [sortField, setSortField] = useState<SortField>('unrealizedGainLoss')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('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 <ExtensionLoadingSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Header ─────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">År (realiserade)</label>
|
||||
<Select value={String(year)} onValueChange={v => setYear(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[100px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map(y => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button variant="outline" size="sm" onClick={() => setImportDialogOpen(true)}>
|
||||
<FlaskConical className="h-4 w-4 mr-1.5" />
|
||||
Importera testdata
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-1.5', refreshing && 'animate-spin')} />
|
||||
{refreshing ? 'Uppdaterar...' : 'Uppdatera kurser'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Mock Data Banner ──────────────────────────────── */}
|
||||
{isMockActive && (
|
||||
<MockDataBanner
|
||||
importedAt={importedAt}
|
||||
onClear={handleMockClear}
|
||||
onReplace={() => setImportDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{/* ── Exchange Rates ──────────────────────────── */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<p className="text-sm font-medium">Växelkurser</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{report.referenceDate}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{report.exchangeRates.map(r => (
|
||||
<div key={r.currency} className="flex items-baseline gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">{r.currency}:</span>
|
||||
<span className="text-sm font-mono tabular-nums">{formatAmount(r.rate, 4)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Exposure Cards ─────────────────────────── */}
|
||||
{report.exposureByCurrency.length > 0 ? (
|
||||
<div className={cn(
|
||||
'grid gap-4',
|
||||
report.exposureByCurrency.length === 1 ? 'grid-cols-1 sm:grid-cols-2' :
|
||||
report.exposureByCurrency.length === 2 ? 'grid-cols-1 sm:grid-cols-2' :
|
||||
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'
|
||||
)}>
|
||||
{report.exposureByCurrency.map(exp => (
|
||||
<ExposureCard key={exp.currency} exposure={exp} />
|
||||
))}
|
||||
|
||||
{/* Total card */}
|
||||
<Card className="border-2">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Totalt</span>
|
||||
<Badge variant="outline">{report.totals.receivableCount} fakturor</Badge>
|
||||
</div>
|
||||
<p className="text-2xl font-semibold tabular-nums">
|
||||
{formatSEK(report.totals.currentSekValue)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">SEK (aktuell kurs)</p>
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<FXIndicator label="Orealiserat" amount={report.totals.totalUnrealizedGainLoss} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
Inga öppna fordringar i utländsk valuta.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Receivables Table ──────────────────────── */}
|
||||
{sortedReceivables.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Öppna fordringar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Faktura</TableHead>
|
||||
<SortableHead field="customerName" label="Kund" current={sortField} dir={sortDir} onSort={toggleSort} />
|
||||
<TableHead>Valuta</TableHead>
|
||||
<SortableHead field="foreignAmount" label="Belopp" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right" />
|
||||
<TableHead className="text-right">Bokfört (SEK)</TableHead>
|
||||
<TableHead className="text-right">Aktuellt (SEK)</TableHead>
|
||||
<SortableHead field="unrealizedGainLoss" label="Orealiserat" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right" />
|
||||
<SortableHead field="daysOutstanding" label="Dagar" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedReceivables.map(r => (
|
||||
<TableRow key={r.invoiceId}>
|
||||
<TableCell className="font-mono text-sm">{r.invoiceNumber}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<div>
|
||||
<span>{r.customerName}</span>
|
||||
{r.customerCountry && (
|
||||
<Badge variant="outline" className="ml-1.5 text-xs">{r.customerCountry}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">{r.currency}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{currencySymbol(r.currency)}{formatAmount(r.foreignAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(r.bookedSekAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(r.currentSekAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<FXBadge amount={r.unrealizedGainLoss} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm tabular-nums">
|
||||
<span className={cn(
|
||||
r.daysOutstanding > 30 ? 'text-destructive font-medium' :
|
||||
r.daysOutstanding > 14 ? 'text-warning-foreground' : ''
|
||||
)}>
|
||||
{r.daysOutstanding}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Realized FX Trend ──────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">
|
||||
Realiserade kursdifferenser {year}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeTrend.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
Inga realiserade kursdifferenser för {year}.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Månad</TableHead>
|
||||
<TableHead className="text-right">Vinst (3960)</TableHead>
|
||||
<TableHead className="text-right">Förlust (7960)</TableHead>
|
||||
<TableHead className="text-right">Netto</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{activeTrend.map(t => (
|
||||
<TableRow key={t.month}>
|
||||
<TableCell className="text-sm">{monthLabel(t.month)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums text-green-600">
|
||||
{t.realizedGains > 0 ? `+${formatSEK(t.realizedGains)}` : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums text-red-600">
|
||||
{t.realizedLosses > 0 ? `-${formatSEK(t.realizedLosses)}` : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
<FXBadge amount={t.netRealized} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* Totals row */}
|
||||
<TableRow className="border-t-2 font-medium">
|
||||
<TableCell className="text-sm">Totalt {year}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums text-green-600">
|
||||
+{formatSEK(report.realizedGainLoss.gains)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums text-red-600">
|
||||
-{formatSEK(report.realizedGainLoss.losses)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<FXBadge amount={report.realizedGainLoss.net} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Revaluation Preview ────────────────────── */}
|
||||
{report.receivables.length > 0 && (
|
||||
<Card className="border-l-4 border-l-blue-500/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-blue-500 mt-0.5 shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Omvärdering vid periodbokslut</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Om bokslut görs idag: netto orealiserad{' '}
|
||||
<span className={cn(
|
||||
'font-medium',
|
||||
report.revalPreview.totalUnrealizedGainLoss >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{report.revalPreview.totalUnrealizedGainLoss >= 0 ? 'vinst' : 'förlust'}{' '}
|
||||
{report.revalPreview.totalUnrealizedGainLoss >= 0 ? '+' : ''}
|
||||
{formatSEK(report.revalPreview.totalUnrealizedGainLoss)} SEK
|
||||
</span>
|
||||
</p>
|
||||
{report.revalPreview.gains > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Konto 3969 (orealiserad kursvinst): {formatSEK(report.revalPreview.gains)} kr
|
||||
</p>
|
||||
)}
|
||||
{report.revalPreview.losses > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Konto 7969 (orealiserad kursförlust): {formatSEK(report.revalPreview.losses)} kr
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Bokföringsposterna skapas inte av detta tillägg. Använd värdena ovan som underlag vid periodbokslut.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Mock Data Import Dialog ───────────────────────── */}
|
||||
<MockDataImportDialog<ReportData>
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
csvFields={MOCK_CSV_FIELDS}
|
||||
parseCsvRows={parseMockCsvRows}
|
||||
validateReport={validateMockReport}
|
||||
templateCsvContent={MOCK_CSV_TEMPLATE}
|
||||
templateFileName="currency-receivables-template.csv"
|
||||
onImport={handleMockImport}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────
|
||||
|
||||
function ExposureCard({ exposure }: { exposure: CurrencyExposure }) {
|
||||
const sym = currencySymbol(exposure.currency)
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Badge variant="outline" className="text-sm font-medium">{exposure.currency}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{exposure.invoiceCount} fakturor</span>
|
||||
</div>
|
||||
<p className="text-lg font-mono tabular-nums">
|
||||
{sym}{formatAmount(exposure.totalForeignAmount)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground tabular-nums">
|
||||
{formatSEK(exposure.currentSekValue)} SEK
|
||||
</p>
|
||||
<div className="mt-3 pt-3 border-t space-y-1">
|
||||
<FXIndicator label="Orealiserat" amount={exposure.unrealizedGainLoss} />
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Bokförd kurs: {formatAmount(exposure.averageBookedRate, 4)}</span>
|
||||
<span>Aktuell: {formatAmount(exposure.currentRate, 4)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FXIndicator({ label, amount }: { label: string; amount: number }) {
|
||||
const isGain = amount >= 0
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<div className={cn(
|
||||
'flex items-center gap-1 text-sm font-medium tabular-nums',
|
||||
isGain ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{isGain ? <TrendingUp className="h-3.5 w-3.5" /> : <TrendingDown className="h-3.5 w-3.5" />}
|
||||
<span>{isGain ? '+' : ''}{formatSEK(amount)} kr</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FXBadge({ amount }: { amount: number }) {
|
||||
if (amount === 0) return <span className="text-sm text-muted-foreground">—</span>
|
||||
const isGain = amount > 0
|
||||
return (
|
||||
<span className={cn(
|
||||
'text-sm font-mono tabular-nums font-medium',
|
||||
isGain ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{isGain ? '+' : ''}{formatSEK(amount)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<TableHead className={className}>
|
||||
<button
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
onClick={() => onSort(field)}
|
||||
>
|
||||
{label}
|
||||
<ArrowUpDown className={cn('h-3 w-3', isActive ? 'text-foreground' : 'text-muted-foreground/50')} />
|
||||
{isActive && <span className="text-xs">{dir === 'asc' ? '↑' : '↓'}</span>}
|
||||
</button>
|
||||
</TableHead>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string>[]): 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<string, unknown>
|
||||
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<ReportData>('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<ReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Table sort
|
||||
const [sortField, setSortField] = useState<SortField>('country')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('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 <ExtensionLoadingSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Period Selector ─────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">År</label>
|
||||
<Select value={String(year)} onValueChange={v => setYear(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map(y => (
|
||||
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Periodtyp</label>
|
||||
<Select value={periodType} onValueChange={v => setPeriodType(v as 'monthly' | 'quarterly')}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly">Månad</SelectItem>
|
||||
<SelectItem value="quarterly">Kvartal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{periodType === 'monthly' ? 'Månad' : 'Kvartal'}
|
||||
</label>
|
||||
{periodType === 'monthly' ? (
|
||||
<Select value={String(month)} onValueChange={v => setMonth(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTHS.map((name, i) => (
|
||||
<SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Select value={String(quarter)} onValueChange={v => setQuarter(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{QUARTERS.map((name, i) => (
|
||||
<SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download + Import buttons */}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
>
|
||||
<FlaskConical className="h-4 w-4 mr-1.5" />
|
||||
Importera testdata
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownload('csv')}
|
||||
disabled={downloading !== null || !report || report.lines.length === 0}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4 mr-1.5" />
|
||||
{downloading === 'csv' ? 'Laddar...' : 'CSV'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownload('xml')}
|
||||
disabled={downloading !== null || !report || report.lines.length === 0}
|
||||
>
|
||||
<FileCode className="h-4 w-4 mr-1.5" />
|
||||
{downloading === 'xml' ? 'Laddar...' : 'SKV XML'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Mock Data Banner ──────────────────────────────── */}
|
||||
{isMockActive && (
|
||||
<MockDataBanner
|
||||
importedAt={importedAt}
|
||||
onClear={handleMockClear}
|
||||
onReplace={() => setImportDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Error state ────────────────────────────────────── */}
|
||||
{error && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{/* ── KPI Cards ────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KPICard
|
||||
label="Varuförsäljning EU"
|
||||
value={formatSEK(report.totals.goods)}
|
||||
suffix="SEK"
|
||||
/>
|
||||
<KPICard
|
||||
label="Tjänsteförsäljning EU"
|
||||
value={formatSEK(report.totals.services)}
|
||||
suffix="SEK"
|
||||
/>
|
||||
<KPICard
|
||||
label="Trepartshandel"
|
||||
value={formatSEK(report.totals.triangulation)}
|
||||
suffix="SEK"
|
||||
/>
|
||||
<KPICard
|
||||
label="Kunder"
|
||||
value={report.customerCount}
|
||||
suffix={`(${report.invoiceCount} fakturor)`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Deadline + Cross-Check Row ────────────────────── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Deadline */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Inlämningsdeadline</p>
|
||||
<p className="text-lg font-semibold mt-0.5">
|
||||
{formatDeadlineDate(report.deadline)}
|
||||
</p>
|
||||
<p className={cn(
|
||||
'text-sm mt-1',
|
||||
report.daysUntilDeadline <= 7 ? 'text-destructive font-medium' :
|
||||
report.daysUntilDeadline <= 14 ? 'text-warning-foreground' :
|
||||
'text-muted-foreground'
|
||||
)}>
|
||||
{report.daysUntilDeadline > 0
|
||||
? `${report.daysUntilDeadline} dagar kvar`
|
||||
: report.daysUntilDeadline === 0
|
||||
? 'Deadline idag!'
|
||||
: `${Math.abs(report.daysUntilDeadline)} dagar försenad`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cross-check */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm font-medium mb-3">Avstämning mot huvudbok</p>
|
||||
{report.crossCheck ? (
|
||||
<div className="space-y-2">
|
||||
<CrossCheckRow
|
||||
label="Ruta 35 — varor"
|
||||
reportTotal={report.crossCheck.box35ReportTotal}
|
||||
glTotal={report.crossCheck.box35GLTotal}
|
||||
match={report.crossCheck.box35Match}
|
||||
/>
|
||||
<CrossCheckRow
|
||||
label="Ruta 39 — tjänster"
|
||||
reportTotal={report.crossCheck.box39ReportTotal}
|
||||
glTotal={report.crossCheck.box39GLTotal}
|
||||
match={report.crossCheck.box39Match}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ingen bokföringsdata tillgänglig för perioden.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── Warnings ─────────────────────────────────────── */}
|
||||
{report.warnings.length > 0 && (
|
||||
<Card className={cn(
|
||||
'border-l-4',
|
||||
errorCount > 0 ? 'border-l-destructive' : 'border-l-warning'
|
||||
)}>
|
||||
<CardContent className="pt-6">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full text-left"
|
||||
onClick={() => setWarningsExpanded(!warningsExpanded)}
|
||||
>
|
||||
<AlertTriangle className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
errorCount > 0 ? 'text-destructive' : 'text-warning-foreground'
|
||||
)} />
|
||||
<span className="text-sm font-medium flex-1">
|
||||
{errorCount > 0 && (
|
||||
<span className="text-destructive">{errorCount} fel</span>
|
||||
)}
|
||||
{errorCount > 0 && warningCount > 0 && ', '}
|
||||
{warningCount > 0 && (
|
||||
<span className="text-warning-foreground">{warningCount} varningar</span>
|
||||
)}
|
||||
</span>
|
||||
{warningsExpanded
|
||||
? <ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
: <ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
}
|
||||
</button>
|
||||
|
||||
{warningsExpanded && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{report.warnings.map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'flex items-start gap-2 text-sm py-2 px-3 rounded-md',
|
||||
w.severity === 'error'
|
||||
? 'bg-destructive/5 text-destructive'
|
||||
: 'bg-warning/10 text-warning-foreground'
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{w.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Customer Table ────────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Kunder per land
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedLines.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
Inga EU-försäljningar hittades för vald period.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<SortableHead field="country" current={sortField} dir={sortDir} onSort={toggleSort}>
|
||||
Land
|
||||
</SortableHead>
|
||||
<SortableHead field="vatNumber" current={sortField} dir={sortDir} onSort={toggleSort}>
|
||||
VAT-nummer
|
||||
</SortableHead>
|
||||
<TableHead className="text-left">Kund</TableHead>
|
||||
<SortableHead field="goods" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Package className="h-3.5 w-3.5" />
|
||||
Varor (ruta 35)
|
||||
</span>
|
||||
</SortableHead>
|
||||
<SortableHead field="services" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Briefcase className="h-3.5 w-3.5" />
|
||||
Tjänster (ruta 39)
|
||||
</span>
|
||||
</SortableHead>
|
||||
<SortableHead field="invoices" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right">
|
||||
Fakturor
|
||||
</SortableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedLines.map(line => (
|
||||
<TableRow key={line.customerVatNumber}>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{line.customerCountry}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{line.customerVatNumber}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{line.customerName}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{line.goodsAmount !== 0 ? formatSEK(line.goodsAmount) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{line.servicesAmount !== 0 ? formatSEK(line.servicesAmount) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm tabular-nums">
|
||||
{line.invoiceCount}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{/* Totals row */}
|
||||
<TableRow className="border-t-2 font-medium">
|
||||
<TableCell colSpan={3} className="text-sm">
|
||||
Summa ({sortedLines.length} kunder)
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(report.totals.goods)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(report.totals.services)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm tabular-nums">
|
||||
{report.invoiceCount}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Filing Info Footer ────────────────────────────── */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground px-1">
|
||||
<span>
|
||||
Uppgiftslämnare: {report.reporterName} ({report.reporterVatNumber})
|
||||
</span>
|
||||
<span>
|
||||
Redovisningsperiod: {report.period.year}
|
||||
{report.period.month !== undefined && `, ${MONTHS[report.period.month - 1]}`}
|
||||
{report.period.quarter !== undefined && `, ${QUARTERS[report.period.quarter - 1]}`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Mock Data Import Dialog ───────────────────────── */}
|
||||
<MockDataImportDialog<ReportData>
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
csvFields={MOCK_CSV_FIELDS}
|
||||
parseCsvRows={parseMockCsvRows}
|
||||
validateReport={validateMockReport}
|
||||
templateCsvContent={MOCK_CSV_TEMPLATE}
|
||||
templateFileName="eu-sales-list-template.csv"
|
||||
onImport={handleMockImport}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{match ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 text-destructive shrink-0" />
|
||||
)}
|
||||
<span className="flex-1">{label}</span>
|
||||
<span className="font-mono tabular-nums text-muted-foreground">
|
||||
{formatSEK(reportTotal)} SEK
|
||||
</span>
|
||||
{!match && (
|
||||
<span className="font-mono tabular-nums text-destructive text-xs">
|
||||
(diff: {diff > 0 ? '+' : ''}{formatSEK(diff)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<TableHead className={cn('cursor-pointer select-none', className)} onClick={() => onSort(field)}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{children}
|
||||
{isActive && (
|
||||
dir === 'asc'
|
||||
? <ChevronUp className="h-3 w-3" />
|
||||
: <ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
</TableHead>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string>[]): 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<string, unknown>
|
||||
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<ReportData>('export', 'intrastat')
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
||||
|
||||
const [year, setYear] = useState(currentYear())
|
||||
const [month, setMonth] = useState(currentMonth())
|
||||
|
||||
const [report, setReport] = useState<ReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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<string | null>(null)
|
||||
const [productForm, setProductForm] = useState<ProductForm>(EMPTY_PRODUCT)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(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 <ExtensionLoadingSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Period Selector ─────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">År</label>
|
||||
<Select value={String(year)} onValueChange={v => setYear(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[100px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map(y => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Månad</label>
|
||||
<Select value={String(month)} onValueChange={v => setMonth(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTHS.map((name, i) => <SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
>
|
||||
<FlaskConical className="h-4 w-4 mr-1.5" />
|
||||
Importera testdata
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading || !report || report.lines.length === 0}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4 mr-1.5" />
|
||||
{downloading ? 'Laddar...' : 'IDEP.web CSV'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Mock Data Banner ──────────────────────────────── */}
|
||||
{isMockActive && (
|
||||
<MockDataBanner
|
||||
importedAt={importedAt}
|
||||
onClear={handleMockClear}
|
||||
onReplace={() => setImportDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{/* ── Threshold Progress ───────────────────────────── */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium">Tröskelvärde Intrastat (utförsel)</p>
|
||||
<Badge variant={report.thresholdStatus.isObligated ? 'destructive' : 'outline'}>
|
||||
{report.thresholdStatus.isObligated ? 'Obligatorisk rapportering' : 'Frivillig rapportering'}
|
||||
</Badge>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.min(report.thresholdStatus.percentageUsed, 100)}
|
||||
className="h-3"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Ackumulerat (12 mån): {formatSEK(report.thresholdStatus.cumulativeValue)} SEK
|
||||
</span>
|
||||
<span>
|
||||
{report.thresholdStatus.percentageUsed}% av {formatSEK(report.thresholdStatus.threshold)} SEK
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── KPI Row ──────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">Fakturerat värde</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{formatSEK(report.totals.invoicedValue)}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">SEK</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">Nettovikt</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{report.totals.netMass.toLocaleString('sv-SE')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">kg</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">Deklarationsrader</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{report.totals.lineCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{report.invoiceCount} fakturor</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── Product Registry ─────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Produktregister
|
||||
</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={openNewProduct}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Lägg till
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">
|
||||
Inga produkter registrerade. Lägg till produkter med CN-kod och vikt för att generera Intrastat-deklarationer.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Produkt</TableHead>
|
||||
<TableHead>CN-kod</TableHead>
|
||||
<TableHead className="text-right">Vikt (kg)</TableHead>
|
||||
<TableHead>Ursprung</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products.map(p => (
|
||||
<TableRow key={p.productId}>
|
||||
<TableCell className="text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{p.description || p.productId}</span>
|
||||
{p.productId !== p.description && (
|
||||
<span className="text-xs text-muted-foreground ml-1">({p.productId})</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{p.cn_code ? (
|
||||
<Badge variant="outline" className="font-mono text-xs">{p.cn_code}</Badge>
|
||||
) : (
|
||||
<span className="text-destructive text-xs flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" /> Saknas
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{p.net_weight_kg !== null
|
||||
? String(p.net_weight_kg)
|
||||
: <span className="text-muted-foreground">—</span>
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{p.country_of_origin}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditProduct(p.productId)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(p.productId)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Declaration Table ─────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">
|
||||
Deklaration {MONTHS[month - 1]} {year}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{report.lines.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">
|
||||
Inga EU-varuförsäljningar hittades för perioden.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>CN-kod</TableHead>
|
||||
<TableHead>Land</TableHead>
|
||||
<TableHead>Urspr.</TableHead>
|
||||
<TableHead className="text-right">Värde (SEK)</TableHead>
|
||||
<TableHead className="text-right">Vikt (kg)</TableHead>
|
||||
<TableHead>Partner-VAT</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{report.lines.map((line, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={line.cnCode === '00000000' ? 'destructive' : 'outline'}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{line.cnCode}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">{line.partnerCountry}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{line.countryOfOrigin}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(line.invoicedValue)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{line.netMass > 0 ? line.netMass.toLocaleString('sv-SE') : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{line.partnerVatId || '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="border-t-2 font-medium">
|
||||
<TableCell colSpan={3} className="text-sm">Summa</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(report.totals.invoicedValue)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{report.totals.netMass.toLocaleString('sv-SE')}
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Warnings ─────────────────────────────────────── */}
|
||||
{report.warnings.length > 0 && (
|
||||
<Card className={cn('border-l-4', errorCount > 0 ? 'border-l-destructive' : 'border-l-warning')}>
|
||||
<CardContent className="pt-6">
|
||||
<button className="flex items-center gap-2 w-full text-left" onClick={() => setWarningsExpanded(!warningsExpanded)}>
|
||||
<AlertTriangle className={cn('h-4 w-4 shrink-0', errorCount > 0 ? 'text-destructive' : 'text-warning-foreground')} />
|
||||
<span className="text-sm font-medium flex-1">
|
||||
{errorCount > 0 && <span className="text-destructive">{errorCount} fel</span>}
|
||||
{errorCount > 0 && warningCount > 0 && ', '}
|
||||
{warningCount > 0 && <span className="text-warning-foreground">{warningCount} varningar</span>}
|
||||
</span>
|
||||
{warningsExpanded ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{warningsExpanded && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{report.warnings.map((w, i) => (
|
||||
<div key={i} className={cn('flex items-start gap-2 text-sm py-2 px-3 rounded-md', w.severity === 'error' ? 'bg-destructive/5 text-destructive' : 'bg-warning/10 text-warning-foreground')}>
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{w.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Deadline Footer ───────────────────────────────── */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
Deadline: 10:e arbetsdagen efter redovisningsperiodens slut
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Mock Data Import Dialog ───────────────────────── */}
|
||||
<MockDataImportDialog<ReportData>
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
csvFields={MOCK_CSV_FIELDS}
|
||||
parseCsvRows={parseMockCsvRows}
|
||||
validateReport={validateMockReport}
|
||||
templateCsvContent={MOCK_CSV_TEMPLATE}
|
||||
templateFileName="intrastat-template.csv"
|
||||
onImport={handleMockImport}
|
||||
/>
|
||||
|
||||
{/* ── Product Dialog ────────────────────────────────────── */}
|
||||
<Dialog open={productDialogOpen} onOpenChange={setProductDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingProduct ? 'Redigera produkt' : 'Lägg till produkt'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{!editingProduct && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="productId">Produkt-ID (SKU)</Label>
|
||||
<Input
|
||||
id="productId"
|
||||
value={productForm.productId}
|
||||
onChange={e => setProductForm(f => ({ ...f, productId: e.target.value }))}
|
||||
placeholder="T.ex. STALBALK-M8"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Beskrivning</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={productForm.description}
|
||||
onChange={e => setProductForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="T.ex. Stålbalk M8 200mm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cnCode">CN-kod (8 siffror)</Label>
|
||||
<Input
|
||||
id="cnCode"
|
||||
value={productForm.cnCode}
|
||||
onChange={e => setProductForm(f => ({ ...f, cnCode: e.target.value.replace(/\D/g, '').slice(0, 8) }))}
|
||||
placeholder="T.ex. 72163100"
|
||||
maxLength={8}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="weight">Nettovikt per enhet (kg)</Label>
|
||||
<Input
|
||||
id="weight"
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={productForm.netWeightKg}
|
||||
onChange={e => setProductForm(f => ({ ...f, netWeightKg: e.target.value }))}
|
||||
placeholder="45.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="origin">Ursprungsland</Label>
|
||||
<Input
|
||||
id="origin"
|
||||
value={productForm.countryOfOrigin}
|
||||
onChange={e => setProductForm(f => ({ ...f, countryOfOrigin: e.target.value.toUpperCase().slice(0, 2) }))}
|
||||
placeholder="SE"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setProductDialogOpen(false)}>Avbryt</Button>
|
||||
<Button
|
||||
onClick={saveProduct}
|
||||
disabled={!editingProduct && !productForm.productId.trim()}
|
||||
>
|
||||
{editingProduct ? 'Spara' : 'Lägg till'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Delete Confirmation ───────────────────────────────── */}
|
||||
<Dialog open={deleteConfirm !== null} onOpenChange={() => setDeleteConfirm(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ta bort produkt?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Är du säker på att du vill ta bort produkten “{deleteConfirm}”? Denna åtgärd kan inte ångras.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Avbryt</Button>
|
||||
<Button variant="destructive" onClick={() => deleteConfirm && deleteProduct(deleteConfirm)}>
|
||||
Ta bort
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user