From 381156ade7c9fcaacc27d2b45d2eb139a58082a2 Mon Sep 17 00:00:00 2001 From: Nicolaj Hartmann Date: Fri, 30 Jan 2026 22:20:03 +0100 Subject: [PATCH] Add frontend components, API mutations, and project config Frontend: - API mutations for accounts, bank connections, customers, invoices - Document processing API - Shared components (PageHeader, EmptyState, etc.) - Pages: Admin, Fakturaer, Kunder, Ordrer, Produkter, etc. - Hooks and stores Config: - CLAUDE.md project instructions - Beads issue tracking config - Git attributes Co-Authored-By: Claude Opus 4.5 --- .beads/.gitignore | 44 + .beads/README.md | 81 ++ .beads/config.yaml | 62 + .beads/interactions.jsonl | 0 .beads/metadata.json | 4 + .gitattributes | 3 + CLAUDE.md | 123 ++ frontend/src/api/documentProcessing.ts | 173 +++ .../src/api/mutations/accountMutations.ts | 83 ++ .../api/mutations/bankConnectionMutations.ts | 295 +++++ .../src/api/mutations/companyMutations.ts | 257 +++++ .../src/api/mutations/customerMutations.ts | 222 ++++ frontend/src/api/mutations/draftMutations.ts | 203 ++++ .../src/api/mutations/fiscalYearMutations.ts | 214 ++++ .../src/api/mutations/invoiceMutations.ts | 610 ++++++++++ frontend/src/api/mutations/orderMutations.ts | 443 +++++++ .../src/api/mutations/productMutations.ts | 211 ++++ frontend/src/api/mutations/saftMutations.ts | 71 ++ frontend/src/api/queries/accountQueries.ts | 235 ++++ .../src/api/queries/bankConnectionQueries.ts | 182 +++ .../src/api/queries/bankTransactionQueries.ts | 274 +++++ frontend/src/api/queries/companyQueries.ts | 152 +++ frontend/src/api/queries/customerQueries.ts | 174 +++ frontend/src/api/queries/draftQueries.ts | 114 ++ frontend/src/api/queries/fiscalYearQueries.ts | 137 +++ frontend/src/api/queries/invoiceQueries.ts | 397 +++++++ frontend/src/api/queries/orderQueries.ts | 369 ++++++ frontend/src/api/queries/productQueries.ts | 150 +++ frontend/src/api/queries/vatQueries.ts | 81 ++ frontend/src/components/auth/CompanyGuard.tsx | 114 ++ .../DocumentUploadModal.tsx | 624 ++++++++++ .../components/company/UserAccessManager.tsx | 345 ++++++ .../kassekladde/BalanceImpactPanel.tsx | 259 +++++ .../settings/BankConnectionsTab.tsx | 698 +++++++++++ frontend/src/components/shared/AmountText.tsx | 212 ++++ .../components/shared/AttachmentUpload.tsx | 292 +++++ .../components/shared/ConfirmationModal.tsx | 177 +++ .../components/shared/DemoDataDisclaimer.tsx | 35 + frontend/src/components/shared/EmptyState.tsx | 241 ++++ .../src/components/shared/ErrorBoundary.tsx | 150 +++ .../components/shared/FullPageDropZone.tsx | 172 +++ .../src/components/shared/HotkeyProvider.tsx | 145 +++ .../src/components/shared/ISODatePicker.tsx | 123 ++ frontend/src/components/shared/PageHeader.tsx | 113 ++ .../src/components/shared/PeriodFilter.tsx | 223 ++++ .../src/components/shared/ShortcutTooltip.tsx | 140 +++ .../components/shared/ShortcutsHelpModal.tsx | 135 +++ .../src/components/shared/SkeletonLoader.tsx | 141 +++ .../src/components/shared/StatisticCard.tsx | 143 +++ .../src/components/shared/StatusBadge.tsx | 207 ++++ frontend/src/components/shared/index.ts | 56 + frontend/src/hooks/useAutoSave.ts | 126 ++ frontend/src/hooks/usePageHotkeys.ts | 130 +++ frontend/src/hooks/usePeriod.ts | 3 +- frontend/src/hooks/useResponsiveModal.ts | 93 ++ frontend/src/lib/errorHandling.ts | 225 ++++ frontend/src/lib/periods.ts | 2 - frontend/src/pages/Admin.tsx | 243 ++++ frontend/src/pages/CompanySetupWizard.tsx | 501 ++++++++ frontend/src/pages/Eksport.tsx | 209 ++++ frontend/src/pages/Fakturaer.tsx | 1023 +++++++++++++++++ frontend/src/pages/Kreditnotaer.tsx | 947 +++++++++++++++ frontend/src/pages/Kunder.tsx | 661 +++++++++++ frontend/src/pages/Ordrer.tsx | 996 ++++++++++++++++ frontend/src/pages/Produkter.tsx | 575 +++++++++ frontend/src/pages/UserSettings.tsx | 472 ++++++++ frontend/src/stores/hotkeyStore.ts | 118 ++ frontend/src/styles/designTokens.ts | 374 ++++++ frontend/src/types/order.ts | 74 ++ frontend/src/types/product.ts | 20 + frontend/tsconfig.tsbuildinfo | 2 +- 71 files changed, 16898 insertions(+), 5 deletions(-) create mode 100644 .beads/.gitignore create mode 100644 .beads/README.md create mode 100644 .beads/config.yaml create mode 100644 .beads/interactions.jsonl create mode 100644 .beads/metadata.json create mode 100644 .gitattributes create mode 100644 CLAUDE.md create mode 100644 frontend/src/api/documentProcessing.ts create mode 100644 frontend/src/api/mutations/accountMutations.ts create mode 100644 frontend/src/api/mutations/bankConnectionMutations.ts create mode 100644 frontend/src/api/mutations/companyMutations.ts create mode 100644 frontend/src/api/mutations/customerMutations.ts create mode 100644 frontend/src/api/mutations/draftMutations.ts create mode 100644 frontend/src/api/mutations/fiscalYearMutations.ts create mode 100644 frontend/src/api/mutations/invoiceMutations.ts create mode 100644 frontend/src/api/mutations/orderMutations.ts create mode 100644 frontend/src/api/mutations/productMutations.ts create mode 100644 frontend/src/api/mutations/saftMutations.ts create mode 100644 frontend/src/api/queries/accountQueries.ts create mode 100644 frontend/src/api/queries/bankConnectionQueries.ts create mode 100644 frontend/src/api/queries/bankTransactionQueries.ts create mode 100644 frontend/src/api/queries/companyQueries.ts create mode 100644 frontend/src/api/queries/customerQueries.ts create mode 100644 frontend/src/api/queries/draftQueries.ts create mode 100644 frontend/src/api/queries/fiscalYearQueries.ts create mode 100644 frontend/src/api/queries/invoiceQueries.ts create mode 100644 frontend/src/api/queries/orderQueries.ts create mode 100644 frontend/src/api/queries/productQueries.ts create mode 100644 frontend/src/api/queries/vatQueries.ts create mode 100644 frontend/src/components/auth/CompanyGuard.tsx create mode 100644 frontend/src/components/bank-reconciliation/DocumentUploadModal.tsx create mode 100644 frontend/src/components/company/UserAccessManager.tsx create mode 100644 frontend/src/components/kassekladde/BalanceImpactPanel.tsx create mode 100644 frontend/src/components/settings/BankConnectionsTab.tsx create mode 100644 frontend/src/components/shared/AmountText.tsx create mode 100644 frontend/src/components/shared/AttachmentUpload.tsx create mode 100644 frontend/src/components/shared/ConfirmationModal.tsx create mode 100644 frontend/src/components/shared/DemoDataDisclaimer.tsx create mode 100644 frontend/src/components/shared/EmptyState.tsx create mode 100644 frontend/src/components/shared/ErrorBoundary.tsx create mode 100644 frontend/src/components/shared/FullPageDropZone.tsx create mode 100644 frontend/src/components/shared/HotkeyProvider.tsx create mode 100644 frontend/src/components/shared/ISODatePicker.tsx create mode 100644 frontend/src/components/shared/PageHeader.tsx create mode 100644 frontend/src/components/shared/PeriodFilter.tsx create mode 100644 frontend/src/components/shared/ShortcutTooltip.tsx create mode 100644 frontend/src/components/shared/ShortcutsHelpModal.tsx create mode 100644 frontend/src/components/shared/SkeletonLoader.tsx create mode 100644 frontend/src/components/shared/StatisticCard.tsx create mode 100644 frontend/src/components/shared/StatusBadge.tsx create mode 100644 frontend/src/components/shared/index.ts create mode 100644 frontend/src/hooks/useAutoSave.ts create mode 100644 frontend/src/hooks/usePageHotkeys.ts create mode 100644 frontend/src/hooks/useResponsiveModal.ts create mode 100644 frontend/src/lib/errorHandling.ts create mode 100644 frontend/src/pages/Admin.tsx create mode 100644 frontend/src/pages/CompanySetupWizard.tsx create mode 100644 frontend/src/pages/Eksport.tsx create mode 100644 frontend/src/pages/Fakturaer.tsx create mode 100644 frontend/src/pages/Kreditnotaer.tsx create mode 100644 frontend/src/pages/Kunder.tsx create mode 100644 frontend/src/pages/Ordrer.tsx create mode 100644 frontend/src/pages/Produkter.tsx create mode 100644 frontend/src/pages/UserSettings.tsx create mode 100644 frontend/src/stores/hotkeyStore.ts create mode 100644 frontend/src/styles/designTokens.ts create mode 100644 frontend/src/types/order.ts create mode 100644 frontend/src/types/product.ts diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 0000000..d27a1db --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,44 @@ +# SQLite databases +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm + +# Daemon runtime files +daemon.lock +daemon.log +daemon.pid +bd.sock +sync-state.json +last-touched + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version + +# Legacy database files +db.sqlite +bd.db + +# Worktree redirect file (contains relative path to main repo's .beads/) +# Must not be committed as paths would be wrong in other clones +redirect + +# Merge artifacts (temporary files from 3-way merge) +beads.base.jsonl +beads.base.meta.json +beads.left.jsonl +beads.left.meta.json +beads.right.jsonl +beads.right.meta.json + +# Sync state (local-only, per-machine) +# These files are machine-specific and should not be shared across clones +.sync.lock +sync_base.jsonl + +# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. +# They would override fork protection in .git/info/exclude, allowing +# contributors to accidentally commit upstream issue databases. +# The JSONL files (issues.jsonl, interactions.jsonl) and config files +# are tracked by git by default since no pattern above ignores them. diff --git a/.beads/README.md b/.beads/README.md new file mode 100644 index 0000000..50f281f --- /dev/null +++ b/.beads/README.md @@ -0,0 +1,81 @@ +# Beads - AI-Native Issue Tracking + +Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. + +## What is Beads? + +Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. + +**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) + +## Quick Start + +### Essential Commands + +```bash +# Create new issues +bd create "Add user authentication" + +# View all issues +bd list + +# View issue details +bd show + +# Update issue status +bd update --status in_progress +bd update --status done + +# Sync with git remote +bd sync +``` + +### Working with Issues + +Issues in Beads are: +- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code +- **AI-friendly**: CLI-first design works perfectly with AI coding agents +- **Branch-aware**: Issues can follow your branch workflow +- **Always in sync**: Auto-syncs with your commits + +## Why Beads? + +✨ **AI-Native Design** +- Built specifically for AI-assisted development workflows +- CLI-first interface works seamlessly with AI coding agents +- No context switching to web UIs + +🚀 **Developer Focused** +- Issues live in your repo, right next to your code +- Works offline, syncs when you push +- Fast, lightweight, and stays out of your way + +🔧 **Git Integration** +- Automatic sync with git commits +- Branch-aware issue tracking +- Intelligent JSONL merge resolution + +## Get Started with Beads + +Try Beads in your own projects: + +```bash +# Install Beads +curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash + +# Initialize in your repo +bd init + +# Create your first issue +bd create "Try out Beads" +``` + +## Learn More + +- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) +- **Quick Start Guide**: Run `bd quickstart` +- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) + +--- + +*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 0000000..f242785 --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,62 @@ +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +# issue-prefix: "" + +# Use no-db mode: load from JSONL, no SQLite, write back after each command +# When true, bd will use .beads/issues.jsonl as the source of truth +# instead of SQLite database +# no-db: false + +# Disable daemon for RPC communication (forces direct database access) +# no-daemon: false + +# Disable auto-flush of database to JSONL after mutations +# no-auto-flush: false + +# Disable auto-import from JSONL when it's newer than database +# no-auto-import: false + +# Enable JSON output by default +# json: false + +# Default actor for audit trails (overridden by BD_ACTOR or --actor) +# actor: "" + +# Path to database (overridden by BEADS_DB or --db) +# db: "" + +# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) +# auto-start-daemon: true + +# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) +# flush-debounce: "5s" + +# Git branch for beads commits (bd sync will commit to this branch) +# IMPORTANT: Set this for team projects so all clones use the same sync branch. +# This setting persists across clones (unlike database config which is gitignored). +# Can also use BEADS_SYNC_BRANCH env var for local override. +# If not set, bd sync will require you to run 'bd config set sync.branch '. +# sync-branch: "beads-sync" + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct JSONL +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# Integration settings (access with 'bd config get/set') +# These are stored in the database, not in this file: +# - jira.url +# - jira.project +# - linear.url +# - linear.api-key +# - github.org +# - github.repo diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 0000000..c787975 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,4 @@ +{ + "database": "beads.db", + "jsonl_export": "issues.jsonl" +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..807d598 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ + +# Use bd merge for beads JSONL files +.beads/issues.jsonl merge=beads diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9ef090f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,123 @@ + +## Session Start + +Always run `bd ready` at the start of each session to check for available work from Beads. Delegate work to the appropriate team specialist. + +If work is available, Find the team-lead agent and ask him to assign and claim it with `bd update --status in_progress` and begin working. + +## Session End + +Follow the "Landing the Plane" protocol below - work is NOT complete until `git push` succeeds. + +This project uses **bd** (beads) for issue tracking and **Claude Code Swarm Mode** for multi-agent coordination. + +--- + +## Entry Point: Creating Tasks + +All work starts with `bd create`. The Team Leader monitors tasks and coordinates the team. + +```bash +# Create a new task - THIS IS THE ENTRY POINT +bd create --title "Implement feature X" --description "Details here..." + +# Other useful commands +bd ready # Find available work +bd show # View issue details +bd update --status in_progress # Claim work +bd close # Complete work +bd sync # Sync with git +``` + +--- + +## Team Structure (Swarm Mode) + +When working on tasks, the **Team Leader** spawns specialists EVERY TIME. + +| Role | Agent Name | Responsibility | +|------|------------|----------------| +| **Team Leader** | `team-lead` | Coordinates work, monitors `bd ready`, assigns tasks, reviews PRs | +| **Frontend Developer** | `frontend` | React/TypeScript, UI components, styling, state management | +| **Backend Developer** | `backend` | .NET/C#, GraphQL, EventFlow, database migrations | +| **Tester** | `tester` | Integration tests, unit tests, E2E tests, quality assurance | +| **Code Reviewer** | `reviewer` | Code quality, patterns, security review, best practices | +| **Accounting Expert** | `sme-accounting` | Danish accounting rules, VAT, bookkeeping, compliance | +| **Usability Expert** | `sme-usability` | UX patterns, accessibility, user workflows, design feedback | + +### Workflow + +1. **User creates task**: `bd create --title "..." --description "..."` +2. **Team Lead picks up task**: Runs `bd ready`, claims with `bd update --status in_progress` +3. **Team Lead analyzes and delegates**: Spawns specialists via Claude Swarm Mode +4. **Specialists work in parallel**: Each handles their domain +5. **Reviewer ensures quality**: Code review before merge +6. **Tester validates**: Runs tests, confirms functionality +7. **Team Lead closes task**: `bd close ` and pushes to remote + +### Spawning Teammates (for Team Lead) + +``` +Task tool with: + - subagent_type: "general-purpose" + - prompt: Include role context (e.g., "As the frontend developer, implement...") + - description: Short task summary +``` + +### Communication Rules (CRITICAL) + +- Use Task tool to spawn specialist agents for parallel work +- Provide complete context in the prompt - agents don't share memory +- Use TaskCreate/TaskUpdate for tracking work with dependencies (`blockedBy` / `blocks`) + +--- + +## Domain Knowledge + +### Project: Books (Danish Accounting System) + +**Backend** (`/backend/Books.Api/`): +- .NET 8, EventFlow CQRS, HotChocolate GraphQL +- PostgreSQL with event sourcing +- Danish accounting compliance (SAF-T, VAT) + +**Frontend** (`/frontend/`): +- React 18, TypeScript, Vite +- Mantine UI, Zustand state management +- Apollo Client for GraphQL + +**Key Concepts**: +- Fiscal years (regnskabsår) +- Chart of accounts (kontoplan) +- Journal entries (kassekladde) +- VAT reporting (momsindberetning) +- Bank reconciliation (bankafstemning) + +--- + +## Landing the Plane (Session Completion) + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd sync + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + diff --git a/frontend/src/api/documentProcessing.ts b/frontend/src/api/documentProcessing.ts new file mode 100644 index 0000000..d65c602 --- /dev/null +++ b/frontend/src/api/documentProcessing.ts @@ -0,0 +1,173 @@ +import { useCompanyStore } from '@/stores/companyStore'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000'; + +// Helper to get active company ID from store +function getActiveCompanyId(): string | null { + return useCompanyStore.getState().activeCompany?.id ?? null; +} + +/** + * Result of document processing from AI Bookkeeper. + */ +export interface DocumentProcessingResult { + draftId?: string; + attachmentId?: string; + isDuplicate: boolean; + message?: string; + extraction?: ExtractionResult; + accountSuggestion?: AccountSuggestionResult; + bankTransactionMatch?: BankTransactionMatchResult; + suggestedLines?: SuggestedJournalLine[]; +} + +export interface ExtractionResult { + vendor?: string; + vendorCvr?: string; + amount?: number; + amountExVat?: number; + vatAmount?: number; + date?: string; + dueDate?: string; + invoiceNumber?: string; + documentType?: string; + currency?: string; + paymentReference?: string; + lineItems?: ExtractedLineItem[]; +} + +export interface ExtractedLineItem { + description?: string; + quantity?: number; + unitPrice?: number; + amount?: number; + vatRate?: number; +} + +export interface AccountSuggestionResult { + mappedAccountId?: string; + mappedAccountNumber?: string; + mappedAccountName?: string; + confidence: number; +} + +export interface BankTransactionMatchResult { + transactionId?: string; + amount: number; + date?: string; + description?: string; + counterparty?: string; +} + +export interface SuggestedJournalLine { + accountId?: string; + accountNumber?: string; + accountName?: string; + debitAmount: number; + creditAmount: number; + vatCode?: string; +} + +export interface DocumentProcessingError { + code: string; + message: string; +} + +/** + * Process a document using AI Bookkeeper. + * Uploads the file, analyzes it, creates a draft, and optionally matches to a bank transaction. + * + * @param file The file to process + * @param companyId Optional company ID (defaults to active company) + * @returns Processing result including extraction, account suggestion, and bank match + */ +export async function processDocument( + file: File, + companyId?: string +): Promise { + const effectiveCompanyId = companyId || getActiveCompanyId(); + + if (!effectiveCompanyId) { + throw new Error('No company selected'); + } + + const formData = new FormData(); + formData.append('document', file); + + const response = await fetch( + `${API_BASE_URL}/api/documents/process?companyId=${encodeURIComponent(effectiveCompanyId)}`, + { + method: 'POST', + body: formData, + credentials: 'include', + } + ); + + if (!response.ok) { + // Try to parse error response + try { + const errorData = (await response.json()) as DocumentProcessingError; + throw new DocumentProcessingApiError( + errorData.code || 'UNKNOWN_ERROR', + errorData.message || 'Der opstod en fejl ved behandling af dokumentet' + ); + } catch (e) { + if (e instanceof DocumentProcessingApiError) { + throw e; + } + // Fallback error handling + if (response.status === 401) { + throw new DocumentProcessingApiError('NOT_AUTHENTICATED', 'Du skal vaere logget ind'); + } + if (response.status === 403) { + throw new DocumentProcessingApiError('FORBIDDEN', 'Du har ikke adgang til denne virksomhed'); + } + if (response.status === 413) { + throw new DocumentProcessingApiError('FILE_TOO_LARGE', 'Filen er for stor (maks 10MB)'); + } + if (response.status === 503) { + throw new DocumentProcessingApiError('AI_UNAVAILABLE', 'AI-tjenesten er midlertidigt utilgaengelig'); + } + throw new DocumentProcessingApiError('UNKNOWN_ERROR', `Serverfejl: ${response.status}`); + } + } + + return (await response.json()) as DocumentProcessingResult; +} + +/** + * Custom error class for document processing errors. + */ +export class DocumentProcessingApiError extends Error { + public readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'DocumentProcessingApiError'; + this.code = code; + } +} + +/** + * Check if a file is valid for document processing. + */ +export function isValidDocumentFile(file: File): { valid: boolean; error?: string } { + const allowedTypes = ['application/pdf', 'image/png', 'image/jpeg', 'image/jpg']; + const maxSize = 10 * 1024 * 1024; // 10MB + + if (!allowedTypes.includes(file.type)) { + return { + valid: false, + error: 'Kun PDF og billeder (PNG, JPG) er tilladt', + }; + } + + if (file.size > maxSize) { + return { + valid: false, + error: 'Filen er for stor (maks 10MB)', + }; + } + + return { valid: true }; +} diff --git a/frontend/src/api/mutations/accountMutations.ts b/frontend/src/api/mutations/accountMutations.ts new file mode 100644 index 0000000..2fb5a23 --- /dev/null +++ b/frontend/src/api/mutations/accountMutations.ts @@ -0,0 +1,83 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; +import type { AccountType } from '@/types/accounting'; + +// GraphQL Mutations +const CREATE_ACCOUNT_MUTATION = gql` + mutation CreateAccount($input: CreateAccountInput!) { + createAccount(input: $input) { + id + companyId + accountNumber + name + accountType + parentId + description + vatCodeId + isActive + isSystemAccount + standardAccountNumber + createdAt + updatedAt + } + } +`; + +// Input types +export interface CreateAccountInput { + companyId: string; + accountNumber: string; + name: string; + accountType: AccountType; + parentId?: string; + description?: string; + vatCodeId?: string; + isSystemAccount?: boolean; + /** Erhvervsstyrelsens standardkontonummer for SAF-T rapportering */ + standardAccountNumber?: string; +} + +// Response types +interface AccountResponse { + id: string; + companyId: string; + accountNumber: string; + name: string; + accountType: string; + parentId?: string; + description?: string; + vatCodeId?: string; + isActive: boolean; + isSystemAccount: boolean; + createdAt: string; + updatedAt: string; +} + +interface CreateAccountResponse { + createAccount: AccountResponse; +} + +/** + * Hook to create a new account + */ +export function useCreateAccount() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: CreateAccountInput) => { + // Convert accountType to GraphQL enum format (uppercase) + const graphqlInput = { + ...input, + accountType: input.accountType.toUpperCase(), + }; + const data = await fetchGraphQL(CREATE_ACCOUNT_MUTATION, { input: graphqlInput }); + return data.createAccount; + }, + onSuccess: (_, variables) => { + // Invalidate accounts for the company + queryClient.invalidateQueries({ queryKey: createQueryKey('accounts', { companyId: variables.companyId }) }); + queryClient.invalidateQueries({ queryKey: createQueryKey('activeAccounts', { companyId: variables.companyId }) }); + }, + }); +} diff --git a/frontend/src/api/mutations/bankConnectionMutations.ts b/frontend/src/api/mutations/bankConnectionMutations.ts new file mode 100644 index 0000000..74de3ae --- /dev/null +++ b/frontend/src/api/mutations/bankConnectionMutations.ts @@ -0,0 +1,295 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; +import { formatDateTimeISO } from '@/lib/formatters'; +import type { BankConnection } from '../queries/bankConnectionQueries'; + +// Types +export interface StartBankConnectionInput { + companyId: string; + aspspName: string; + redirectUrl: string; + psuType?: string; +} + +export interface StartBankConnectionResult { + connectionId: string; + authorizationUrl: string; +} + +export interface CompleteBankConnectionInput { + connectionId: string; + authorizationCode: string; +} + +export interface LinkBankAccountInput { + connectionId: string; + bankAccountId: string; + linkedAccountId: string; + importFromDate?: string; +} + +export interface ReconnectBankConnectionInput { + connectionId: string; + redirectUrl: string; + psuType?: string; +} + +// GraphQL Mutations +const START_BANK_CONNECTION_MUTATION = gql` + mutation StartBankConnection($input: StartBankConnectionInput!) { + startBankConnection(input: $input) { + connectionId + authorizationUrl + } + } +`; + +const COMPLETE_BANK_CONNECTION_MUTATION = gql` + mutation CompleteBankConnection($input: CompleteBankConnectionInput!) { + completeBankConnection(input: $input) { + id + companyId + aspspName + status + validUntil + isActive + accounts { + accountId + iban + currency + name + } + } + } +`; + +const DISCONNECT_BANK_CONNECTION_MUTATION = gql` + mutation DisconnectBankConnection($id: ID!) { + disconnectBankConnection(id: $id) { + id + status + isActive + } + } +`; + +const LINK_BANK_ACCOUNT_MUTATION = gql` + mutation LinkBankAccount($input: LinkBankAccountInput!) { + linkBankAccount(input: $input) { + id + companyId + accounts { + accountId + iban + currency + name + linkedAccountId + linkedAccount { + id + accountNumber + name + } + importFromDate + } + } + } +`; + +const RECONNECT_BANK_CONNECTION_MUTATION = gql` + mutation ReconnectBankConnection($input: ReconnectBankConnectionInput!) { + reconnectBankConnection(input: $input) { + connectionId + authorizationUrl + } + } +`; + +const ARCHIVE_BANK_CONNECTION_MUTATION = gql` + mutation ArchiveBankConnection($id: ID!) { + archiveBankConnection(id: $id) { + id + status + } + } +`; + +// Response types +interface StartBankConnectionResponse { + startBankConnection: StartBankConnectionResult; +} + +interface CompleteBankConnectionResponse { + completeBankConnection: BankConnection; +} + +interface DisconnectBankConnectionResponse { + disconnectBankConnection: BankConnection; +} + +interface LinkBankAccountResponse { + linkBankAccount: BankConnection; +} + +interface ReconnectBankConnectionResponse { + reconnectBankConnection: StartBankConnectionResult; +} + +interface ArchiveBankConnectionResponse { + archiveBankConnection: BankConnection; +} + +/** + * Hook to start a bank connection (initiates OAuth flow) + */ +export function useStartBankConnection() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: StartBankConnectionInput) => { + const data = await fetchGraphQL( + START_BANK_CONNECTION_MUTATION, + { input } + ); + return data.startBankConnection; + }, + onSuccess: (_, variables) => { + // Invalidate bank connections for the company + queryClient.invalidateQueries({ + queryKey: createQueryKey('bankConnections', { companyId: variables.companyId }), + }); + }, + }); +} + +/** + * Hook to complete a bank connection after OAuth callback + */ +export function useCompleteBankConnection() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: CompleteBankConnectionInput) => { + const data = await fetchGraphQL( + COMPLETE_BANK_CONNECTION_MUTATION, + { input } + ); + return data.completeBankConnection; + }, + onSuccess: (data) => { + // Invalidate bank connections for the company + queryClient.invalidateQueries({ + queryKey: createQueryKey('bankConnections', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('activeBankConnections', { companyId: data.companyId }), + }); + }, + }); +} + +/** + * Hook to disconnect a bank connection + */ +export function useDisconnectBankConnection() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, companyId }: { id: string; companyId: string }) => { + const data = await fetchGraphQL( + DISCONNECT_BANK_CONNECTION_MUTATION, + { id } + ); + return { ...data.disconnectBankConnection, companyId }; + }, + onSuccess: (data) => { + // Invalidate bank connections for the company + queryClient.invalidateQueries({ + queryKey: createQueryKey('bankConnections', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('activeBankConnections', { companyId: data.companyId }), + }); + }, + }); +} + +/** + * Hook to link a bank account to a chart of accounts account + */ +export function useLinkBankAccount() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ input, companyId }: { input: LinkBankAccountInput; companyId: string }) => { + // Convert date to datetime for GraphQL DateTime type + const graphqlInput = { + ...input, + importFromDate: input.importFromDate ? formatDateTimeISO(input.importFromDate) : undefined, + }; + const data = await fetchGraphQL( + LINK_BANK_ACCOUNT_MUTATION, + { input: graphqlInput } + ); + return { ...data.linkBankAccount, companyId }; + }, + onSuccess: (data) => { + // Invalidate bank connections for the company + queryClient.invalidateQueries({ + queryKey: createQueryKey('bankConnections', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('activeBankConnections', { companyId: data.companyId }), + }); + }, + }); +} + +/** + * Hook to reconnect a disconnected/failed/expired bank connection + */ +export function useReconnectBankConnection() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ input, companyId }: { input: ReconnectBankConnectionInput; companyId: string }) => { + const data = await fetchGraphQL( + RECONNECT_BANK_CONNECTION_MUTATION, + { input } + ); + return { ...data.reconnectBankConnection, companyId }; + }, + onSuccess: (_, variables) => { + // Invalidate bank connections for the company + queryClient.invalidateQueries({ + queryKey: createQueryKey('bankConnections', { companyId: variables.companyId }), + }); + }, + }); +} + +/** + * Hook to archive a bank connection (hide from UI) + */ +export function useArchiveBankConnection() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, companyId }: { id: string; companyId: string }) => { + const data = await fetchGraphQL( + ARCHIVE_BANK_CONNECTION_MUTATION, + { id } + ); + return { ...data.archiveBankConnection, companyId }; + }, + onSuccess: (data) => { + // Invalidate bank connections for the company + queryClient.invalidateQueries({ + queryKey: createQueryKey('bankConnections', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('activeBankConnections', { companyId: data.companyId }), + }); + }, + }); +} diff --git a/frontend/src/api/mutations/companyMutations.ts b/frontend/src/api/mutations/companyMutations.ts new file mode 100644 index 0000000..f51a066 --- /dev/null +++ b/frontend/src/api/mutations/companyMutations.ts @@ -0,0 +1,257 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; +import type { Company, CompanyRole, UserCompanyAccess } from '@/types/accounting'; + +// GraphQL Mutations - using gql tag for proper parsing with graphql-request v7 +const CREATE_COMPANY_MUTATION = gql` + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { + id + name + cvr + country + currency + fiscalYearStartMonth + } + } +`; + +const UPDATE_COMPANY_MUTATION = gql` + mutation UpdateCompany($id: ID!, $input: UpdateCompanyInput!) { + updateCompany(id: $id, input: $input) { + id + name + cvr + address + city + postalCode + country + currency + } + } +`; + +const GRANT_USER_ACCESS_MUTATION = gql` + mutation GrantUserAccess($input: GrantUserAccessInput!) { + grantUserAccess(input: $input) { + id + userId + companyId + role + grantedBy + grantedAt + isActive + } + } +`; + +const CHANGE_USER_ROLE_MUTATION = gql` + mutation ChangeUserRole($input: ChangeUserRoleInput!) { + changeUserRole(input: $input) { + id + userId + companyId + role + } + } +`; + +const REVOKE_USER_ACCESS_MUTATION = gql` + mutation RevokeUserAccess($input: RevokeUserAccessInput!) { + revokeUserAccess(input: $input) { + id + userId + companyId + isActive + } + } +`; + +const UPDATE_COMPANY_BANK_DETAILS_MUTATION = gql` + mutation UpdateCompanyBankDetails($id: ID!, $input: UpdateCompanyBankDetailsInput!) { + updateCompanyBankDetails(id: $id, input: $input) { + id + name + bankName + bankRegNo + bankAccountNo + bankIban + bankBic + } + } +`; + +// Input types +interface CreateCompanyInput { + name: string; + cvr?: string; + country?: string; + currency?: string; + fiscalYearStartMonth?: number; + vatRegistered?: boolean; + vatPeriodFrequency?: 'MONTHLY' | 'QUARTERLY' | 'HALFYEARLY'; +} + +interface UpdateCompanyInput { + name?: string; + cvr?: string; + address?: string; + city?: string; + postalCode?: string; +} + +interface GrantUserAccessInput { + userId: string; + companyId: string; + role: CompanyRole; +} + +interface ChangeUserRoleInput { + userId: string; + companyId: string; + newRole: CompanyRole; +} + +interface RevokeUserAccessInput { + userId: string; + companyId: string; +} + +interface UpdateCompanyBankDetailsInput { + bankName?: string; + bankRegNo?: string; + bankAccountNo?: string; + bankIban?: string; + bankBic?: string; +} + +// Response types +interface CreateCompanyResponse { + createCompany: Company; +} + +interface UpdateCompanyResponse { + updateCompany: Company; +} + +interface GrantUserAccessResponse { + grantUserAccess: UserCompanyAccess; +} + +interface ChangeUserRoleResponse { + changeUserRole: UserCompanyAccess; +} + +interface RevokeUserAccessResponse { + revokeUserAccess: UserCompanyAccess; +} + +interface UpdateCompanyBankDetailsResponse { + updateCompanyBankDetails: Company; +} + +/** + * Hook to create a new company + */ +export function useCreateCompany() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: CreateCompanyInput) => { + const data = await fetchGraphQL(CREATE_COMPANY_MUTATION, { input }); + return data.createCompany; + }, + onSuccess: () => { + // Invalidate myCompanies to refetch the list + queryClient.invalidateQueries({ queryKey: createQueryKey('myCompanies') }); + }, + }); +} + +/** + * Hook to update a company + */ +export function useUpdateCompany() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, input }: { id: string; input: UpdateCompanyInput }) => { + const data = await fetchGraphQL(UPDATE_COMPANY_MUTATION, { id, input }); + return data.updateCompany; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: createQueryKey('company', { id: variables.id }) }); + queryClient.invalidateQueries({ queryKey: createQueryKey('myCompanies') }); + }, + }); +} + +/** + * Hook to grant a user access to a company + */ +export function useGrantUserAccess() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: GrantUserAccessInput) => { + const data = await fetchGraphQL(GRANT_USER_ACCESS_MUTATION, { input }); + return data.grantUserAccess; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: createQueryKey('companyUsers', { companyId: variables.companyId }) }); + }, + }); +} + +/** + * Hook to change a user's role + */ +export function useChangeUserRole() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: ChangeUserRoleInput) => { + const data = await fetchGraphQL(CHANGE_USER_ROLE_MUTATION, { input }); + return data.changeUserRole; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: createQueryKey('companyUsers', { companyId: variables.companyId }) }); + }, + }); +} + +/** + * Hook to revoke a user's access + */ +export function useRevokeUserAccess() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: RevokeUserAccessInput) => { + const data = await fetchGraphQL(REVOKE_USER_ACCESS_MUTATION, { input }); + return data.revokeUserAccess; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: createQueryKey('companyUsers', { companyId: variables.companyId }) }); + }, + }); +} + +/** + * Hook to update company bank details for invoices + */ +export function useUpdateCompanyBankDetails() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, input }: { id: string; input: UpdateCompanyBankDetailsInput }) => { + const data = await fetchGraphQL(UPDATE_COMPANY_BANK_DETAILS_MUTATION, { id, input }); + return data.updateCompanyBankDetails; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: createQueryKey('company', { id: variables.id }) }); + queryClient.invalidateQueries({ queryKey: createQueryKey('myCompanies') }); + }, + }); +} diff --git a/frontend/src/api/mutations/customerMutations.ts b/frontend/src/api/mutations/customerMutations.ts new file mode 100644 index 0000000..cea4762 --- /dev/null +++ b/frontend/src/api/mutations/customerMutations.ts @@ -0,0 +1,222 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; +import type { Customer } from '../queries/customerQueries'; + +// GraphQL Mutations +const CREATE_CUSTOMER_MUTATION = gql` + mutation CreateCustomer($input: CreateCustomerInput!) { + createCustomer(input: $input) { + id + companyId + customerNumber + customerType + name + cvr + address + postalCode + city + country + email + phone + paymentTermsDays + defaultRevenueAccountId + subLedgerAccountId + isActive + createdAt + updatedAt + } + } +`; + +const UPDATE_CUSTOMER_MUTATION = gql` + mutation UpdateCustomer($input: UpdateCustomerInput!) { + updateCustomer(input: $input) { + id + companyId + customerNumber + customerType + name + cvr + address + postalCode + city + country + email + phone + paymentTermsDays + defaultRevenueAccountId + subLedgerAccountId + isActive + createdAt + updatedAt + } + } +`; + +const DEACTIVATE_CUSTOMER_MUTATION = gql` + mutation DeactivateCustomer($id: ID!) { + deactivateCustomer(id: $id) { + id + companyId + isActive + updatedAt + } + } +`; + +const REACTIVATE_CUSTOMER_MUTATION = gql` + mutation ReactivateCustomer($id: ID!) { + reactivateCustomer(id: $id) { + id + companyId + isActive + updatedAt + } + } +`; + +// Input types +export interface CreateCustomerInput { + companyId: string; + customerType: 'BUSINESS' | 'PRIVATE'; + name: string; + cvr?: string; + address?: string; + postalCode?: string; + city?: string; + country?: string; + email?: string; + phone?: string; + paymentTermsDays?: number; + defaultRevenueAccountId?: string; +} + +export interface UpdateCustomerInput { + id: string; + name?: string; + cvr?: string; + address?: string; + postalCode?: string; + city?: string; + country?: string; + email?: string; + phone?: string; + paymentTermsDays?: number; + defaultRevenueAccountId?: string; +} + +// Response types +interface CreateCustomerResponse { + createCustomer: Customer; +} + +interface UpdateCustomerResponse { + updateCustomer: Customer; +} + +interface DeactivateCustomerResponse { + deactivateCustomer: Customer; +} + +interface ReactivateCustomerResponse { + reactivateCustomer: Customer; +} + +/** + * Hook to create a new customer. + */ +export function useCreateCustomer() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: CreateCustomerInput) => { + const data = await fetchGraphQL(CREATE_CUSTOMER_MUTATION, { input }); + return data.createCustomer; + }, + onSuccess: (data) => { + // Invalidate customers list for this company + queryClient.invalidateQueries({ + queryKey: createQueryKey('customers', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('activeCustomers', { companyId: data.companyId }), + }); + }, + }); +} + +/** + * Hook to update an existing customer. + */ +export function useUpdateCustomer() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: UpdateCustomerInput) => { + const data = await fetchGraphQL(UPDATE_CUSTOMER_MUTATION, { input }); + return data.updateCustomer; + }, + onSuccess: (data) => { + // Update the specific customer in cache + queryClient.setQueryData(createQueryKey('customer', { id: data.id }), data); + // Invalidate lists + queryClient.invalidateQueries({ + queryKey: createQueryKey('customers', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('activeCustomers', { companyId: data.companyId }), + }); + }, + }); +} + +/** + * Hook to deactivate a customer. + */ +export function useDeactivateCustomer() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const data = await fetchGraphQL(DEACTIVATE_CUSTOMER_MUTATION, { id }); + return data.deactivateCustomer; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('customers', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('activeCustomers', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('customer', { id: data.id }), + }); + }, + }); +} + +/** + * Hook to reactivate a customer. + */ +export function useReactivateCustomer() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const data = await fetchGraphQL(REACTIVATE_CUSTOMER_MUTATION, { id }); + return data.reactivateCustomer; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('customers', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('activeCustomers', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('customer', { id: data.id }), + }); + }, + }); +} diff --git a/frontend/src/api/mutations/draftMutations.ts b/frontend/src/api/mutations/draftMutations.ts new file mode 100644 index 0000000..cdb24d0 --- /dev/null +++ b/frontend/src/api/mutations/draftMutations.ts @@ -0,0 +1,203 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; +import type { + JournalEntryDraft, + CreateJournalEntryDraftInput, + UpdateJournalEntryDraftInput, +} from '@/types/accounting'; + +// GraphQL Mutations +const CREATE_JOURNAL_ENTRY_DRAFT_MUTATION = gql` + mutation CreateJournalEntryDraft($input: CreateJournalEntryDraftInput!) { + createJournalEntryDraft(input: $input) { + id + companyId + name + voucherNumber + status + createdBy + createdAt + updatedAt + } + } +`; + +const UPDATE_JOURNAL_ENTRY_DRAFT_MUTATION = gql` + mutation UpdateJournalEntryDraft($input: UpdateJournalEntryDraftInput!) { + updateJournalEntryDraft(input: $input) { + id + companyId + name + voucherNumber + documentDate + description + fiscalYearId + lines { + lineNumber + accountId + debitAmount + creditAmount + description + vatCode + } + attachmentIds + status + updatedAt + } + } +`; + +const POST_JOURNAL_ENTRY_DRAFT_MUTATION = gql` + mutation PostJournalEntryDraft($id: ID!) { + postJournalEntryDraft(id: $id) { + id + companyId + status + transactionId + updatedAt + } + } +`; + +const DISCARD_JOURNAL_ENTRY_DRAFT_MUTATION = gql` + mutation DiscardJournalEntryDraft($id: ID!) { + discardJournalEntryDraft(id: $id) { + id + companyId + status + updatedAt + } + } +`; + +// Response types +interface CreateDraftResponse { + createJournalEntryDraft: JournalEntryDraft; +} + +interface UpdateDraftResponse { + updateJournalEntryDraft: JournalEntryDraft; +} + +interface PostDraftResponse { + postJournalEntryDraft: JournalEntryDraft; +} + +interface DiscardDraftResponse { + discardJournalEntryDraft: JournalEntryDraft; +} + +/** + * Hook to create a new journal entry draft. + */ +export function useCreateJournalEntryDraft() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: CreateJournalEntryDraftInput) => { + const data = await fetchGraphQL( + CREATE_JOURNAL_ENTRY_DRAFT_MUTATION, + { input } + ); + return data.createJournalEntryDraft; + }, + onSuccess: (data) => { + // Invalidate drafts list for this company + queryClient.invalidateQueries({ + queryKey: createQueryKey('journalEntryDrafts', { companyId: data.companyId }), + }); + }, + }); +} + +/** + * Hook to update a journal entry draft (auto-save). + * Returns the mutation without automatic cache invalidation for better performance. + */ +export function useUpdateJournalEntryDraft() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: UpdateJournalEntryDraftInput) => { + const data = await fetchGraphQL( + UPDATE_JOURNAL_ENTRY_DRAFT_MUTATION, + { input } + ); + return data.updateJournalEntryDraft; + }, + onSuccess: (data) => { + // Update the specific draft in cache + queryClient.setQueryData( + createQueryKey('journalEntryDraft', { id: data.id }), + data + ); + // Also update it in the list if it exists + queryClient.setQueriesData( + { queryKey: createQueryKey('journalEntryDrafts', { companyId: data.companyId }) }, + (oldData) => { + if (!oldData) return oldData; + return oldData.map((draft) => (draft.id === data.id ? data : draft)); + } + ); + }, + }); +} + +/** + * Hook to post a journal entry draft to the ledger. + */ +export function usePostJournalEntryDraft() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const data = await fetchGraphQL( + POST_JOURNAL_ENTRY_DRAFT_MUTATION, + { id } + ); + return data.postJournalEntryDraft; + }, + onSuccess: (data) => { + // Invalidate drafts list to remove the posted draft + queryClient.invalidateQueries({ + queryKey: createQueryKey('journalEntryDrafts', { companyId: data.companyId }), + }); + // Invalidate single draft cache + queryClient.invalidateQueries({ + queryKey: createQueryKey('journalEntryDraft', { id: data.id }), + }); + // Invalidate bank transactions as the matched transaction status may have changed + queryClient.invalidateQueries({ + queryKey: createQueryKey('bankTransactions', { companyId: data.companyId }), + }); + }, + }); +} + +/** + * Hook to discard a journal entry draft. + */ +export function useDiscardJournalEntryDraft() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const data = await fetchGraphQL( + DISCARD_JOURNAL_ENTRY_DRAFT_MUTATION, + { id } + ); + return data.discardJournalEntryDraft; + }, + onSuccess: (data) => { + // Invalidate drafts list to remove the discarded draft + queryClient.invalidateQueries({ + queryKey: createQueryKey('journalEntryDrafts', { companyId: data.companyId }), + }); + // Invalidate single draft cache + queryClient.invalidateQueries({ + queryKey: createQueryKey('journalEntryDraft', { id: data.id }), + }); + }, + }); +} diff --git a/frontend/src/api/mutations/fiscalYearMutations.ts b/frontend/src/api/mutations/fiscalYearMutations.ts new file mode 100644 index 0000000..704999a --- /dev/null +++ b/frontend/src/api/mutations/fiscalYearMutations.ts @@ -0,0 +1,214 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; +import type { FiscalYear } from '@/types/periods'; + +// GraphQL Mutations +const CREATE_FISCAL_YEAR_MUTATION = gql` + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { + id + companyId + name + startDate + endDate + status + openingBalancePosted + closingDate + closedBy + createdAt + updatedAt + } + } +`; + +const CLOSE_FISCAL_YEAR_MUTATION = gql` + mutation CloseFiscalYear($id: ID!) { + closeFiscalYear(id: $id) { + id + status + closingDate + closedBy + updatedAt + } + } +`; + +const REOPEN_FISCAL_YEAR_MUTATION = gql` + mutation ReopenFiscalYear($id: ID!) { + reopenFiscalYear(id: $id) { + id + status + reopenedDate + reopenedBy + updatedAt + } + } +`; + +const LOCK_FISCAL_YEAR_MUTATION = gql` + mutation LockFiscalYear($id: ID!) { + lockFiscalYear(id: $id) { + id + status + lockedDate + lockedBy + updatedAt + } + } +`; + +// Input types +export interface CreateFiscalYearInput { + companyId: string; + name: string; + startDate: string; // ISO date string + endDate: string; // ISO date string + isFirstFiscalYear?: boolean; + isReorganization?: boolean; +} + +// Response types +interface FiscalYearResponse { + id: string; + companyId: string; + name: string; + startDate: string; + endDate: string; + status: string; + openingBalancePosted: boolean; + closingDate?: string; + closedBy?: string; + reopenedDate?: string; + reopenedBy?: string; + lockedDate?: string; + lockedBy?: string; + createdAt: string; + updatedAt: string; +} + +interface CreateFiscalYearResponse { + createFiscalYear: FiscalYearResponse; +} + +interface CloseFiscalYearResponse { + closeFiscalYear: FiscalYearResponse; +} + +interface ReopenFiscalYearResponse { + reopenFiscalYear: FiscalYearResponse; +} + +interface LockFiscalYearResponse { + lockFiscalYear: FiscalYearResponse; +} + +// Transform response to frontend type +function transformFiscalYear(fy: FiscalYearResponse): FiscalYear { + return { + id: fy.id, + companyId: fy.companyId, + name: fy.name, + startDate: fy.startDate, + endDate: fy.endDate, + status: fy.status.toLowerCase() as 'open' | 'closed' | 'locked', + openingBalancePosted: fy.openingBalancePosted, + closingDate: fy.closingDate, + closedBy: fy.closedBy, + reopenedDate: fy.reopenedDate, + reopenedBy: fy.reopenedBy, + lockedDate: fy.lockedDate, + lockedBy: fy.lockedBy, + createdAt: fy.createdAt, + updatedAt: fy.updatedAt, + }; +} + +/** + * Hook to create a new fiscal year. + */ +export function useCreateFiscalYear() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: CreateFiscalYearInput) => { + const data = await fetchGraphQL( + CREATE_FISCAL_YEAR_MUTATION, + { input } + ); + return transformFiscalYear(data.createFiscalYear); + }, + onSuccess: (newFiscalYear) => { + // Invalidate fiscal years query to refetch + queryClient.invalidateQueries({ + queryKey: createQueryKey('fiscalYears', { companyId: newFiscalYear.companyId }), + }); + }, + }); +} + +/** + * Hook to close a fiscal year. + */ +export function useCloseFiscalYear() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const data = await fetchGraphQL( + CLOSE_FISCAL_YEAR_MUTATION, + { id } + ); + return transformFiscalYear(data.closeFiscalYear); + }, + onSuccess: (updatedFiscalYear) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('fiscalYears', { companyId: updatedFiscalYear.companyId }), + }); + }, + }); +} + +/** + * Hook to reopen a closed fiscal year. + */ +export function useReopenFiscalYear() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const data = await fetchGraphQL( + REOPEN_FISCAL_YEAR_MUTATION, + { id } + ); + return transformFiscalYear(data.reopenFiscalYear); + }, + onSuccess: (updatedFiscalYear) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('fiscalYears', { companyId: updatedFiscalYear.companyId }), + }); + }, + }); +} + +/** + * Hook to permanently lock a fiscal year. + */ +export function useLockFiscalYear() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const data = await fetchGraphQL( + LOCK_FISCAL_YEAR_MUTATION, + { id } + ); + return transformFiscalYear(data.lockFiscalYear); + }, + onSuccess: (updatedFiscalYear) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('fiscalYears', { companyId: updatedFiscalYear.companyId }), + }); + }, + }); +} diff --git a/frontend/src/api/mutations/invoiceMutations.ts b/frontend/src/api/mutations/invoiceMutations.ts new file mode 100644 index 0000000..7bb94bc --- /dev/null +++ b/frontend/src/api/mutations/invoiceMutations.ts @@ -0,0 +1,610 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; +import type { Invoice } from '../queries/invoiceQueries'; + +// GraphQL Mutations +const CREATE_INVOICE_MUTATION = gql` + mutation CreateInvoice($input: CreateInvoiceInput!) { + createInvoice(input: $input) { + id + companyId + customerId + customerName + invoiceNumber + invoiceDate + dueDate + status + amountExVat + amountVat + amountTotal + amountPaid + amountRemaining + paymentTermsDays + notes + reference + createdAt + updatedAt + lines { + lineNumber + description + quantity + unit + unitPrice + discountPercent + vatCode + accountId + amountExVat + amountVat + amountTotal + } + } + } +`; + +const ADD_INVOICE_LINE_MUTATION = gql` + mutation AddInvoiceLine($input: AddInvoiceLineInput!) { + addInvoiceLine(input: $input) { + id + companyId + customerId + amountExVat + amountVat + amountTotal + updatedAt + lines { + lineNumber + description + quantity + unit + unitPrice + discountPercent + vatCode + accountId + amountExVat + amountVat + amountTotal + } + } + } +`; + +const UPDATE_INVOICE_LINE_MUTATION = gql` + mutation UpdateInvoiceLine($input: UpdateInvoiceLineInput!) { + updateInvoiceLine(input: $input) { + id + companyId + customerId + amountExVat + amountVat + amountTotal + updatedAt + lines { + lineNumber + description + quantity + unit + unitPrice + discountPercent + vatCode + accountId + amountExVat + amountVat + amountTotal + } + } + } +`; + +const REMOVE_INVOICE_LINE_MUTATION = gql` + mutation RemoveInvoiceLine($invoiceId: ID!, $lineNumber: Int!) { + removeInvoiceLine(invoiceId: $invoiceId, lineNumber: $lineNumber) { + id + companyId + customerId + amountExVat + amountVat + amountTotal + updatedAt + lines { + lineNumber + description + quantity + unit + unitPrice + discountPercent + vatCode + accountId + amountExVat + amountVat + amountTotal + } + } + } +`; + +const SEND_INVOICE_MUTATION = gql` + mutation SendInvoice($id: ID!) { + sendInvoice(id: $id) { + id + companyId + customerId + status + ledgerTransactionId + sentAt + updatedAt + } + } +`; + +const RECEIVE_PAYMENT_MUTATION = gql` + mutation ReceivePayment($input: ReceivePaymentInput!) { + receivePayment(input: $input) { + id + companyId + customerId + status + amountPaid + amountRemaining + paidAt + updatedAt + } + } +`; + +const VOID_INVOICE_MUTATION = gql` + mutation VoidInvoice($input: VoidInvoiceInput!) { + voidInvoice(input: $input) { + id + companyId + customerId + status + voidedAt + voidedReason + voidedBy + updatedAt + } + } +`; + +// Input types +export interface CreateInvoiceInput { + companyId: string; + fiscalYearId: string; + customerId: string; + invoiceDate?: string; + dueDate?: string; + vatCode?: string; + notes?: string; + reference?: string; + // Note: paymentTermsDays is NOT an input field - it's derived from customer's default payment terms +} + +export interface AddInvoiceLineInput { + invoiceId: string; + description: string; + quantity: number; + unitPrice: number; + unit?: string; + discountPercent?: number; + vatCode?: string; + accountId?: string; +} + +export interface UpdateInvoiceLineInput { + invoiceId: string; + lineNumber: number; + description: string; // Required by backend + quantity: number; // Required by backend + unitPrice: number; // Required by backend + unit?: string; + discountPercent?: number; + vatCode?: string; + accountId?: string; +} + +export interface ReceivePaymentInput { + invoiceId: string; + amount: number; + bankAccountId: string; + bankTransactionId?: string; + paymentReference?: string; + paymentDate?: string; +} + +export interface VoidInvoiceInput { + invoiceId: string; + reason: string; +} + +// Response types +interface CreateInvoiceResponse { + createInvoice: Invoice; +} + +interface AddInvoiceLineResponse { + addInvoiceLine: Invoice; +} + +interface UpdateInvoiceLineResponse { + updateInvoiceLine: Invoice; +} + +interface RemoveInvoiceLineResponse { + removeInvoiceLine: Invoice; +} + +interface SendInvoiceResponse { + sendInvoice: Invoice; +} + +interface ReceivePaymentResponse { + receivePayment: Invoice; +} + +interface VoidInvoiceResponse { + voidInvoice: Invoice; +} + +/** + * Hook to create a new invoice (draft). + */ +export function useCreateInvoice() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: CreateInvoiceInput) => { + const data = await fetchGraphQL(CREATE_INVOICE_MUTATION, { input }); + return data.createInvoice; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoices', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoicesByCustomer', { customerId: data.customerId }), + }); + }, + }); +} + +/** + * Hook to add a line to an invoice. + */ +export function useAddInvoiceLine() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: AddInvoiceLineInput) => { + const data = await fetchGraphQL(ADD_INVOICE_LINE_MUTATION, { input }); + return data.addInvoiceLine; + }, + onSuccess: (data) => { + queryClient.setQueryData(createQueryKey('invoice', { id: data.id }), data); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoices', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoicesByCustomer', { customerId: data.customerId }), + }); + }, + }); +} + +/** + * Hook to update an invoice line. + */ +export function useUpdateInvoiceLine() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: UpdateInvoiceLineInput) => { + const data = await fetchGraphQL(UPDATE_INVOICE_LINE_MUTATION, { input }); + return data.updateInvoiceLine; + }, + onSuccess: (data) => { + queryClient.setQueryData(createQueryKey('invoice', { id: data.id }), data); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoices', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoicesByCustomer', { customerId: data.customerId }), + }); + }, + }); +} + +/** + * Hook to remove an invoice line. + */ +export function useRemoveInvoiceLine() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ invoiceId, lineNumber }: { invoiceId: string; lineNumber: number }) => { + const data = await fetchGraphQL(REMOVE_INVOICE_LINE_MUTATION, { + invoiceId, + lineNumber, + }); + return data.removeInvoiceLine; + }, + onSuccess: (data) => { + queryClient.setQueryData(createQueryKey('invoice', { id: data.id }), data); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoices', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoicesByCustomer', { customerId: data.customerId }), + }); + }, + }); +} + +/** + * Hook to send an invoice (posts to ledger). + */ +export function useSendInvoice() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const data = await fetchGraphQL(SEND_INVOICE_MUTATION, { id }); + return data.sendInvoice; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoice', { id: data.id }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoices', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoicesByCustomer', { customerId: data.customerId }), + }); + }, + }); +} + +/** + * Hook to receive a payment for an invoice. + */ +export function useReceivePayment() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: ReceivePaymentInput) => { + const data = await fetchGraphQL(RECEIVE_PAYMENT_MUTATION, { input }); + return data.receivePayment; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoice', { id: data.id }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoices', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoicesByCustomer', { customerId: data.customerId }), + }); + // Payment affects customer balance + queryClient.invalidateQueries({ + queryKey: createQueryKey('customerBalance', { customerId: data.customerId }), + }); + }, + }); +} + +/** + * Hook to void an invoice. + */ +export function useVoidInvoice() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: VoidInvoiceInput) => { + const data = await fetchGraphQL(VOID_INVOICE_MUTATION, { input }); + return data.voidInvoice; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoice', { id: data.id }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoices', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoicesByCustomer', { customerId: data.customerId }), + }); + // Voiding affects customer balance + queryClient.invalidateQueries({ + queryKey: createQueryKey('customerBalance', { customerId: data.customerId }), + }); + }, + }); +} + +// ===================================================== +// CREDIT NOTE MUTATIONS +// ===================================================== + +const CREATE_CREDIT_NOTE_MUTATION = gql` + mutation CreateCreditNote($input: CreateCreditNoteInput!) { + createCreditNote(input: $input) { + id + companyId + fiscalYearId + customerId + customerName + customerNumber + invoiceNumber + invoiceType + isCreditNote + originalInvoiceId + originalInvoiceNumber + creditReason + invoiceDate + status + amountExVat + amountVat + amountTotal + amountApplied + amountRemaining + notes + reference + createdAt + updatedAt + lines { + lineNumber + description + quantity + unit + unitPrice + discountPercent + vatCode + accountId + amountExVat + amountVat + amountTotal + } + } + } +`; + +const ISSUE_CREDIT_NOTE_MUTATION = gql` + mutation IssueCreditNote($id: ID!) { + issueCreditNote(id: $id) { + id + companyId + customerId + status + ledgerTransactionId + issuedAt + updatedAt + } + } +`; + +const APPLY_CREDIT_NOTE_MUTATION = gql` + mutation ApplyCreditNote($input: ApplyCreditNoteInput!) { + applyCreditNote(input: $input) { + id + companyId + customerId + status + amountApplied + amountRemaining + updatedAt + } + } +`; + +// Credit note input types +export interface CreateCreditNoteInput { + companyId: string; + fiscalYearId: string; + customerId: string; + creditNoteDate?: string; + originalInvoiceId?: string; + creditReason?: string; + vatCode?: string; + notes?: string; + reference?: string; +} + +export interface ApplyCreditNoteInput { + creditNoteId: string; + invoiceId: string; + amount: number; +} + +// Credit note response types +interface CreateCreditNoteResponse { + createCreditNote: Invoice; +} + +interface IssueCreditNoteResponse { + issueCreditNote: Invoice; +} + +interface ApplyCreditNoteResponse { + applyCreditNote: Invoice; +} + +/** + * Hook to create a new credit note (draft). + */ +export function useCreateCreditNote() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: CreateCreditNoteInput) => { + const data = await fetchGraphQL(CREATE_CREDIT_NOTE_MUTATION, { input }); + return data.createCreditNote; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('creditNotes', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoicesByCustomer', { customerId: data.customerId }), + }); + }, + }); +} + +/** + * Hook to issue a credit note (posts to ledger). + */ +export function useIssueCreditNote() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const data = await fetchGraphQL(ISSUE_CREDIT_NOTE_MUTATION, { id }); + return data.issueCreditNote; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('creditNotes', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('unappliedCreditNotes', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoicesByCustomer', { customerId: data.customerId }), + }); + }, + }); +} + +/** + * Hook to apply a credit note to an invoice. + */ +export function useApplyCreditNote() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: ApplyCreditNoteInput) => { + const data = await fetchGraphQL(APPLY_CREDIT_NOTE_MUTATION, { input }); + return data.applyCreditNote; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('creditNotes', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('unappliedCreditNotes', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoices', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoicesByCustomer', { customerId: data.customerId }), + }); + // Credit application affects customer balance + queryClient.invalidateQueries({ + queryKey: createQueryKey('customerBalance', { customerId: data.customerId }), + }); + }, + }); +} diff --git a/frontend/src/api/mutations/orderMutations.ts b/frontend/src/api/mutations/orderMutations.ts new file mode 100644 index 0000000..c31bad7 --- /dev/null +++ b/frontend/src/api/mutations/orderMutations.ts @@ -0,0 +1,443 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; +import type { Order } from '@/types/order'; +import type { Invoice } from '../queries/invoiceQueries'; + +// GraphQL Mutations +const CREATE_ORDER_MUTATION = gql` + mutation CreateOrder($input: CreateOrderInput!) { + createOrder(input: $input) { + id + companyId + fiscalYearId + customerId + customerName + customerNumber + orderNumber + orderDate + expectedDeliveryDate + status + amountExVat + amountVat + amountTotal + uninvoicedAmount + currency + notes + reference + createdBy + createdAt + updatedAt + lines { + lineNumber + productId + description + quantity + unit + unitPrice + discountPercent + vatCode + accountId + amountExVat + amountVat + amountTotal + isInvoiced + } + } + } +`; + +const ADD_ORDER_LINE_MUTATION = gql` + mutation AddOrderLine($input: AddOrderLineInput!) { + addOrderLine(input: $input) { + id + companyId + customerId + amountExVat + amountVat + amountTotal + updatedAt + lines { + lineNumber + productId + description + quantity + unit + unitPrice + discountPercent + vatCode + accountId + amountExVat + amountVat + amountTotal + isInvoiced + } + } + } +`; + +const UPDATE_ORDER_LINE_MUTATION = gql` + mutation UpdateOrderLine($input: UpdateOrderLineInput!) { + updateOrderLine(input: $input) { + id + companyId + customerId + amountExVat + amountVat + amountTotal + updatedAt + lines { + lineNumber + productId + description + quantity + unit + unitPrice + discountPercent + vatCode + accountId + amountExVat + amountVat + amountTotal + isInvoiced + } + } + } +`; + +const REMOVE_ORDER_LINE_MUTATION = gql` + mutation RemoveOrderLine($input: RemoveOrderLineInput!) { + removeOrderLine(input: $input) { + id + companyId + customerId + amountExVat + amountVat + amountTotal + updatedAt + lines { + lineNumber + productId + description + quantity + unit + unitPrice + discountPercent + vatCode + accountId + amountExVat + amountVat + amountTotal + isInvoiced + } + } + } +`; + +const CONFIRM_ORDER_MUTATION = gql` + mutation ConfirmOrder($orderId: ID!) { + confirmOrder(orderId: $orderId) { + id + companyId + customerId + status + confirmedAt + confirmedBy + updatedAt + } + } +`; + +const INVOICE_ORDER_LINES_MUTATION = gql` + mutation InvoiceOrderLines($input: InvoiceOrderLinesInput!) { + invoiceOrderLines(input: $input) { + id + companyId + invoiceNumber + status + amountTotal + } + } +`; + +const CANCEL_ORDER_MUTATION = gql` + mutation CancelOrder($input: CancelOrderInput!) { + cancelOrder(input: $input) { + id + companyId + customerId + status + cancelledAt + cancelledReason + cancelledBy + updatedAt + } + } +`; + +// Input types +export interface CreateOrderInput { + companyId: string; + fiscalYearId?: string; + customerId: string; + orderDate?: string; + expectedDeliveryDate?: string; + notes?: string; + reference?: string; +} + +export interface AddOrderLineInput { + orderId: string; + productId?: string; + description: string; + quantity: number; + unitPrice: number; + unit?: string; + discountPercent?: number; + vatCode: string; + accountId?: string; +} + +export interface UpdateOrderLineInput { + orderId: string; + lineNumber: number; + description?: string; + quantity?: number; + unitPrice?: number; + unit?: string; + discountPercent?: number; + vatCode?: string; + accountId?: string; +} + +export interface RemoveOrderLineInput { + orderId: string; + lineNumber: number; +} + + +export interface InvoiceOrderLinesInput { + orderId: string; + lineNumbers: number[]; + invoiceDate?: string; + dueDate?: string; +} + +export interface CancelOrderInput { + orderId: string; + reason: string; +} + +// Response types +interface CreateOrderResponse { + createOrder: Order; +} + +interface AddOrderLineResponse { + addOrderLine: Order; +} + +interface UpdateOrderLineResponse { + updateOrderLine: Order; +} + +interface RemoveOrderLineResponse { + removeOrderLine: Order; +} + +interface ConfirmOrderResponse { + confirmOrder: Order; +} + +interface InvoiceOrderLinesResponse { + invoiceOrderLines: Invoice; +} + +interface CancelOrderResponse { + cancelOrder: Order; +} + +/** + * Hook to create a new order (draft). + */ +export function useCreateOrder() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: CreateOrderInput) => { + const data = await fetchGraphQL(CREATE_ORDER_MUTATION, { input }); + return data.createOrder; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('orders', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('ordersByCustomer', { customerId: data.customerId }), + }); + }, + }); +} + +/** + * Hook to add a line to an order. + */ +export function useAddOrderLine() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: AddOrderLineInput) => { + const data = await fetchGraphQL(ADD_ORDER_LINE_MUTATION, { input }); + return data.addOrderLine; + }, + onSuccess: (data) => { + queryClient.setQueryData(createQueryKey('order', { id: data.id }), data); + queryClient.invalidateQueries({ + queryKey: createQueryKey('orders', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('ordersByCustomer', { customerId: data.customerId }), + }); + }, + }); +} + +/** + * Hook to update an order line. + */ +export function useUpdateOrderLine() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: UpdateOrderLineInput) => { + const data = await fetchGraphQL(UPDATE_ORDER_LINE_MUTATION, { input }); + return data.updateOrderLine; + }, + onSuccess: (data) => { + queryClient.setQueryData(createQueryKey('order', { id: data.id }), data); + queryClient.invalidateQueries({ + queryKey: createQueryKey('orders', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('ordersByCustomer', { customerId: data.customerId }), + }); + }, + }); +} + +/** + * Hook to remove an order line. + */ +export function useRemoveOrderLine() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: RemoveOrderLineInput) => { + const data = await fetchGraphQL(REMOVE_ORDER_LINE_MUTATION, { input }); + return data.removeOrderLine; + }, + onSuccess: (data) => { + queryClient.setQueryData(createQueryKey('order', { id: data.id }), data); + queryClient.invalidateQueries({ + queryKey: createQueryKey('orders', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('ordersByCustomer', { customerId: data.customerId }), + }); + }, + }); +} + +/** + * Hook to confirm an order (draft -> confirmed). + */ +export function useConfirmOrder() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (orderId: string) => { + const data = await fetchGraphQL(CONFIRM_ORDER_MUTATION, { orderId }); + return data.confirmOrder; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('order', { id: data.id }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('orders', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('confirmedOrders', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('ordersByCustomer', { customerId: data.customerId }), + }); + }, + }); +} + +/** + * Hook to create an invoice from order lines. + * Returns the created invoice. Order queries are invalidated to refresh order status. + */ +export function useConvertOrderToInvoice() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: InvoiceOrderLinesInput) => { + const data = await fetchGraphQL(INVOICE_ORDER_LINES_MUTATION, { input }); + return data.invoiceOrderLines; + }, + onSuccess: (invoice, variables) => { + // Invalidate order queries + queryClient.invalidateQueries({ + queryKey: createQueryKey('order', { id: variables.orderId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('orders', { companyId: invoice.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('confirmedOrders', { companyId: invoice.companyId }), + }); + queryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0] === 'ordersByCustomer', + }); + + // Invalidate invoice queries + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoices', { companyId: invoice.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('invoice', { id: invoice.id }), + }); + }, + }); +} + +/** + * Hook to cancel an order. + */ +export function useCancelOrder() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: CancelOrderInput) => { + const data = await fetchGraphQL(CANCEL_ORDER_MUTATION, { input }); + return data.cancelOrder; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('order', { id: data.id }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('orders', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('confirmedOrders', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('ordersByCustomer', { customerId: data.customerId }), + }); + }, + }); +} diff --git a/frontend/src/api/mutations/productMutations.ts b/frontend/src/api/mutations/productMutations.ts new file mode 100644 index 0000000..ca3675c --- /dev/null +++ b/frontend/src/api/mutations/productMutations.ts @@ -0,0 +1,211 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; +import type { Product } from '../queries/productQueries'; + +// GraphQL Mutations +const CREATE_PRODUCT_MUTATION = gql` + mutation CreateProduct($input: CreateProductInput!) { + createProduct(input: $input) { + id + companyId + productNumber + name + description + unitPrice + vatCode + unit + defaultAccountId + ean + manufacturer + isActive + createdAt + updatedAt + } + } +`; + +const UPDATE_PRODUCT_MUTATION = gql` + mutation UpdateProduct($input: UpdateProductInput!) { + updateProduct(input: $input) { + id + companyId + productNumber + name + description + unitPrice + vatCode + unit + defaultAccountId + ean + manufacturer + isActive + createdAt + updatedAt + } + } +`; + +const DEACTIVATE_PRODUCT_MUTATION = gql` + mutation DeactivateProduct($id: ID!) { + deactivateProduct(id: $id) { + id + companyId + isActive + updatedAt + } + } +`; + +const REACTIVATE_PRODUCT_MUTATION = gql` + mutation ReactivateProduct($id: ID!) { + reactivateProduct(id: $id) { + id + companyId + isActive + updatedAt + } + } +`; + +// Input types +export interface CreateProductInput { + companyId: string; + productNumber?: string; + name: string; + description?: string; + unitPrice: number; + vatCode: string; + unit?: string; + defaultAccountId?: string; + ean?: string; + manufacturer?: string; +} + +export interface UpdateProductInput { + id: string; + productNumber?: string; + name: string; + description?: string; + unitPrice: number; + vatCode: string; + unit?: string; + defaultAccountId?: string; + ean?: string; + manufacturer?: string; +} + +// Response types +interface CreateProductResponse { + createProduct: Product; +} + +interface UpdateProductResponse { + updateProduct: Product; +} + +interface DeactivateProductResponse { + deactivateProduct: Product; +} + +interface ReactivateProductResponse { + reactivateProduct: Product; +} + +/** + * Hook to create a new product. + */ +export function useCreateProduct() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: CreateProductInput) => { + const data = await fetchGraphQL(CREATE_PRODUCT_MUTATION, { input }); + return data.createProduct; + }, + onSuccess: (data) => { + // Invalidate products list for this company + queryClient.invalidateQueries({ + queryKey: createQueryKey('products', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('activeProducts', { companyId: data.companyId }), + }); + }, + }); +} + +/** + * Hook to update an existing product. + */ +export function useUpdateProduct() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: UpdateProductInput) => { + const data = await fetchGraphQL(UPDATE_PRODUCT_MUTATION, { input }); + return data.updateProduct; + }, + onSuccess: (data) => { + // Update the specific product in cache + queryClient.setQueryData(createQueryKey('product', { id: data.id }), data); + // Invalidate lists + queryClient.invalidateQueries({ + queryKey: createQueryKey('products', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('activeProducts', { companyId: data.companyId }), + }); + }, + }); +} + +/** + * Hook to deactivate a product. + */ +export function useDeactivateProduct() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const data = await fetchGraphQL(DEACTIVATE_PRODUCT_MUTATION, { id }); + return data.deactivateProduct; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('products', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('activeProducts', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('product', { id: data.id }), + }); + }, + }); +} + +/** + * Hook to reactivate a product. + */ +export function useReactivateProduct() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const data = await fetchGraphQL(REACTIVATE_PRODUCT_MUTATION, { id }); + return data.reactivateProduct; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: createQueryKey('products', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('activeProducts', { companyId: data.companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('product', { id: data.id }), + }); + }, + }); +} diff --git a/frontend/src/api/mutations/saftMutations.ts b/frontend/src/api/mutations/saftMutations.ts new file mode 100644 index 0000000..e66f72b --- /dev/null +++ b/frontend/src/api/mutations/saftMutations.ts @@ -0,0 +1,71 @@ +import { useMutation } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL } from '../client'; + +// GraphQL Mutations +const EXPORT_SAFT_MUTATION = gql` + mutation ExportSaftFile($companyId: ID!, $fiscalYearId: ID!) { + exportSaftFile(companyId: $companyId, fiscalYearId: $fiscalYearId) { + success + xmlContent + fileName + errorCode + errorMessage + } + } +`; + +// Response types +export interface SaftExportResult { + success: boolean; + xmlContent?: string; + fileName?: string; + errorCode?: string; + errorMessage?: string; +} + +interface ExportSaftResponse { + exportSaftFile: SaftExportResult; +} + +// Input types +export interface ExportSaftInput { + companyId: string; + fiscalYearId: string; +} + +/** + * Hook to export SAF-T file for a company and fiscal year. + * Returns XML content that can be downloaded as a file. + */ +export function useExportSaft() { + return useMutation({ + mutationFn: async (input: ExportSaftInput) => { + // Pass variables directly (companyId, fiscalYearId) - not wrapped in input object + const data = await fetchGraphQL(EXPORT_SAFT_MUTATION, { + companyId: input.companyId, + fiscalYearId: input.fiscalYearId, + }); + return data.exportSaftFile; + }, + }); +} + +/** + * Helper function to download SAF-T XML content as a file. + */ +export function downloadSaftFile(result: SaftExportResult): void { + if (!result.xmlContent || !result.fileName) { + throw new Error('No SAF-T content to download'); + } + + const blob = new Blob([result.xmlContent], { type: 'application/xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = result.fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/api/queries/accountQueries.ts b/frontend/src/api/queries/accountQueries.ts new file mode 100644 index 0000000..c6c28b4 --- /dev/null +++ b/frontend/src/api/queries/accountQueries.ts @@ -0,0 +1,235 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import type { Dayjs } from 'dayjs'; +import { fetchGraphQL, createQueryKey } from '../client'; +import type { Account, AccountBalance, AccountType } from '@/types/accounting'; + +// Runtime validation for AccountType +const VALID_ACCOUNT_TYPES: AccountType[] = [ + 'asset', 'liability', 'equity', 'revenue', 'cogs', + 'expense', 'personnel', 'financial', 'extraordinary' +]; + +function isValidAccountType(type: string): type is AccountType { + return VALID_ACCOUNT_TYPES.includes(type as AccountType); +} + +function parseAccountType(type: string, fallback: AccountType = 'asset'): AccountType { + const normalized = type.toLowerCase(); + if (isValidAccountType(normalized)) { + return normalized; + } + console.warn(`Unknown account type: "${type}", using fallback: "${fallback}"`); + return fallback; +} + +// GraphQL Queries +const ACCOUNTS_QUERY = gql` + query Accounts($companyId: ID!) { + accounts(companyId: $companyId) { + id + companyId + accountNumber + name + accountType + parentId + description + vatCodeId + isActive + isSystemAccount + standardAccountNumber + createdAt + updatedAt + } + } +`; + +const ACTIVE_ACCOUNTS_QUERY = gql` + query ActiveAccounts($companyId: ID!) { + activeAccounts(companyId: $companyId) { + id + companyId + accountNumber + name + accountType + parentId + description + vatCodeId + isActive + isSystemAccount + standardAccountNumber + createdAt + updatedAt + } + } +`; + +// Response types - map backend AccountType to frontend type +interface AccountResponse { + id: string; + companyId: string; + accountNumber: string; + name: string; + accountType: string; + parentId?: string; + description?: string; + vatCodeId?: string; + isActive: boolean; + isSystemAccount: boolean; + /** Erhvervsstyrelsens standardkontonummer for SAF-T rapportering */ + standardAccountNumber?: string; + createdAt: string; + updatedAt: string; +} + +interface AccountsResponse { + accounts: AccountResponse[]; +} + +interface ActiveAccountsResponse { + activeAccounts: AccountResponse[]; +} + +// Transform backend account type to frontend type +function transformAccount(acc: AccountResponse): Account { + return { + id: acc.id, + companyId: acc.companyId, + accountNumber: acc.accountNumber, + name: acc.name, + type: parseAccountType(acc.accountType), + parentId: acc.parentId, + description: acc.description, + vatCode: acc.vatCodeId, + isActive: acc.isActive, + balance: 0, // Not returned from backend yet + createdAt: acc.createdAt, + updatedAt: acc.updatedAt, + }; +} + +/** + * Hook to fetch all accounts for a company. + */ +export function useAccounts( + companyId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('accounts', { companyId }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(ACCOUNTS_QUERY, { companyId }); + return data.accounts.map(transformAccount); + }, + enabled: !!companyId, + ...options, + }); +} + +/** + * Hook to fetch active accounts for a company. + */ +export function useActiveAccounts( + companyId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('activeAccounts', { companyId }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(ACTIVE_ACCOUNTS_QUERY, { + companyId, + }); + return data.activeAccounts.map(transformAccount); + }, + enabled: !!companyId, + ...options, + }); +} + +// ===================================================================== +// ACCOUNT BALANCES WITH PERIOD FILTERING (from Ledger service) +// ===================================================================== + +const ACCOUNT_BALANCES_QUERY = gql` + query AccountBalances($companyId: ID!, $fromDate: DateTime, $toDate: DateTime) { + accountBalances(companyId: $companyId, fromDate: $fromDate, toDate: $toDate) { + id + accountNumber + name + accountType + isActive + totalDebits + totalCredits + netChange + entryCount + } + } +`; + +interface AccountBalanceResponse { + id: string; + accountNumber: string; + name: string; + accountType: string; + isActive: boolean; + totalDebits: number; + totalCredits: number; + netChange: number; + entryCount: number; +} + +interface AccountBalancesResponse { + accountBalances: AccountBalanceResponse[]; +} + +function transformAccountBalance(resp: AccountBalanceResponse): AccountBalance { + return { + id: resp.id, + accountNumber: resp.accountNumber, + name: resp.name, + accountType: parseAccountType(resp.accountType), + isActive: resp.isActive, + totalDebits: resp.totalDebits, + totalCredits: resp.totalCredits, + netChange: resp.netChange, + entryCount: resp.entryCount, + }; +} + +export interface DateRange { + startDate: Dayjs; + endDate: Dayjs; +} + +/** + * Hook to fetch account balances for a company within a specific date range. + * Uses the Ledger service's QueryEntriesAsync with Aggregate=true. + */ +export function useAccountBalances( + companyId: string | undefined, + dateRange?: DateRange, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('accountBalances', { + companyId, + // Use format() to avoid UTC conversion shifting the date + fromDate: dateRange?.startDate?.format('YYYY-MM-DDTHH:mm:ss'), + toDate: dateRange?.endDate?.format('YYYY-MM-DDTHH:mm:ss'), + }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(ACCOUNT_BALANCES_QUERY, { + companyId, + // Use format() to avoid UTC conversion shifting the date + fromDate: dateRange?.startDate?.format('YYYY-MM-DDTHH:mm:ss'), + toDate: dateRange?.endDate?.format('YYYY-MM-DDTHH:mm:ss'), + }); + return data.accountBalances.map(transformAccountBalance); + }, + enabled: !!companyId, + ...options, + }); +} diff --git a/frontend/src/api/queries/bankConnectionQueries.ts b/frontend/src/api/queries/bankConnectionQueries.ts new file mode 100644 index 0000000..e65d5d0 --- /dev/null +++ b/frontend/src/api/queries/bankConnectionQueries.ts @@ -0,0 +1,182 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; + +// Types +export interface LinkedAccount { + id: string; + accountNumber: string; + name: string; +} + +export interface BankAccountInfo { + accountId: string; + iban: string; + currency: string; + name: string | null; + linkedAccountId: string | null; + linkedAccount: LinkedAccount | null; + importFromDate: string | null; +} + +export interface BankConnection { + id: string; + companyId: string; + aspspName: string; + status: 'initiated' | 'established' | 'failed' | 'disconnected'; + validUntil: string | null; + failureReason: string | null; + createdAt: string; + updatedAt: string; + isActive: boolean; + accounts: BankAccountInfo[] | null; +} + +export interface Aspsp { + name: string; + country: string; + logo: string; + psuTypes: string[]; + personalAccounts: boolean; + businessAccounts: boolean; +} + +// GraphQL Queries +const AVAILABLE_BANKS_QUERY = gql` + query AvailableBanks($country: String) { + availableBanks(country: $country) { + name + country + logo + psuTypes + personalAccounts + businessAccounts + } + } +`; + +const BANK_CONNECTIONS_QUERY = gql` + query BankConnections($companyId: ID!) { + bankConnections(companyId: $companyId) { + id + companyId + aspspName + status + validUntil + failureReason + createdAt + updatedAt + isActive + accounts { + accountId + iban + currency + name + linkedAccountId + linkedAccount { + id + accountNumber + name + } + importFromDate + } + } + } +`; + +const ACTIVE_BANK_CONNECTIONS_QUERY = gql` + query ActiveBankConnections($companyId: ID!) { + activeBankConnections(companyId: $companyId) { + id + companyId + aspspName + status + validUntil + createdAt + updatedAt + isActive + accounts { + accountId + iban + currency + name + linkedAccountId + linkedAccount { + id + accountNumber + name + } + importFromDate + } + } + } +`; + +// Response types +interface AvailableBanksResponse { + availableBanks: Aspsp[]; +} + +interface BankConnectionsResponse { + bankConnections: BankConnection[]; +} + +interface ActiveBankConnectionsResponse { + activeBankConnections: BankConnection[]; +} + +/** + * Hook to fetch available banks for connection + */ +export function useAvailableBanks( + country: string = 'DK', + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('availableBanks', { country }), + queryFn: async () => { + const data = await fetchGraphQL(AVAILABLE_BANKS_QUERY, { country }); + return data.availableBanks; + }, + staleTime: 1000 * 60 * 60, // Banks don't change often, cache for 1 hour + ...options, + }); +} + +/** + * Hook to fetch all bank connections for a company + */ +export function useBankConnections( + companyId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('bankConnections', { companyId }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(BANK_CONNECTIONS_QUERY, { companyId }); + return data.bankConnections; + }, + enabled: !!companyId, + ...options, + }); +} + +/** + * Hook to fetch only active bank connections for a company + */ +export function useActiveBankConnections( + companyId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('activeBankConnections', { companyId }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(ACTIVE_BANK_CONNECTIONS_QUERY, { companyId }); + return data.activeBankConnections; + }, + enabled: !!companyId, + ...options, + }); +} diff --git a/frontend/src/api/queries/bankTransactionQueries.ts b/frontend/src/api/queries/bankTransactionQueries.ts new file mode 100644 index 0000000..aca9d72 --- /dev/null +++ b/frontend/src/api/queries/bankTransactionQueries.ts @@ -0,0 +1,274 @@ +import { useQuery, useMutation, useQueryClient, UseQueryOptions } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; + +// GraphQL Queries +const PENDING_BANK_TRANSACTIONS_QUERY = gql` + query PendingBankTransactions($companyId: ID!) { + pendingBankTransactions(companyId: $companyId) { + id + companyId + bankConnectionId + bankAccountId + externalId + amount + currency + transactionDate + bookingDate + valueDate + description + counterpartyName + counterpartyAccount + reference + creditorName + debtorName + status + journalEntryDraftId + createdAt + updatedAt + displayCounterparty + isIncome + } + } +`; + +const BANK_TRANSACTIONS_QUERY = gql` + query BankTransactions($companyId: ID!) { + bankTransactions(companyId: $companyId) { + id + companyId + bankAccountId + amount + currency + transactionDate + description + counterpartyName + status + journalEntryDraftId + displayCounterparty + isIncome + } + } +`; + +const SYNC_BANK_TRANSACTIONS_MUTATION = gql` + mutation SyncBankTransactions($companyId: ID!) { + syncBankTransactions(companyId: $companyId) { + totalConnections + totalAccounts + newTransactions + skippedDuplicates + errors + errorMessages + } + } +`; + +const MARK_BANK_TRANSACTION_BOOKED_MUTATION = gql` + mutation MarkBankTransactionBooked($id: ID!, $journalEntryDraftId: ID!) { + markBankTransactionBooked(id: $id, journalEntryDraftId: $journalEntryDraftId) { + id + status + journalEntryDraftId + } + } +`; + +const IGNORE_BANK_TRANSACTION_MUTATION = gql` + mutation IgnoreBankTransaction($id: ID!) { + ignoreBankTransaction(id: $id) { + id + status + } + } +`; + +// Response types +export interface BankTransactionResponse { + id: string; + companyId: string; + bankConnectionId: string; + bankAccountId: string; + externalId: string; + amount: number; + currency: string; + transactionDate: string; + bookingDate?: string; + valueDate?: string; + description?: string; + counterpartyName?: string; + counterpartyAccount?: string; + reference?: string; + creditorName?: string; + debtorName?: string; + status: 'pending' | 'booked' | 'ignored'; + journalEntryDraftId?: string; + createdAt: string; + updatedAt: string; + displayCounterparty: string; + isIncome: boolean; +} + +interface PendingBankTransactionsResponse { + pendingBankTransactions: BankTransactionResponse[]; +} + +interface BankTransactionsResponse { + bankTransactions: BankTransactionResponse[]; +} + +export interface SyncResult { + totalConnections: number; + totalAccounts: number; + newTransactions: number; + skippedDuplicates: number; + errors: number; + errorMessages: string[]; +} + +interface SyncBankTransactionsResponse { + syncBankTransactions: SyncResult; +} + +// Transform to store format (compatible with PendingBankTransaction in store) +// Named QuickBookingTransaction to distinguish from BankTransaction in types/accounting.ts (used for reconciliation) +export interface QuickBookingTransaction { + id: string; + date: string; + amount: number; + description: string; + counterparty: string; + bankAccountId: string; + bankAccountNumber: string; + isBooked: boolean; + bookedAt?: string; + transactionId?: string; +} + +function transformToQuickBookingTransaction(tx: BankTransactionResponse): QuickBookingTransaction { + return { + id: tx.id, + date: tx.transactionDate, + amount: tx.amount, + description: tx.description || '', + counterparty: tx.displayCounterparty, + bankAccountId: tx.bankAccountId, + bankAccountNumber: tx.counterpartyAccount || tx.bankAccountId, + isBooked: tx.status === 'booked', + bookedAt: tx.status === 'booked' ? tx.updatedAt : undefined, + transactionId: tx.journalEntryDraftId, + }; +} + +// Query hooks +export function usePendingBankTransactions( + companyId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('pendingBankTransactions', { companyId }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL( + PENDING_BANK_TRANSACTIONS_QUERY, + { companyId } + ); + return data.pendingBankTransactions.map(transformToQuickBookingTransaction); + }, + enabled: !!companyId, + ...options, + }); +} + +export function useBankTransactions( + companyId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('bankTransactions', { companyId }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL( + BANK_TRANSACTIONS_QUERY, + { companyId } + ); + return data.bankTransactions.map(transformToQuickBookingTransaction); + }, + enabled: !!companyId, + ...options, + }); +} + +// Mutation hooks +export function useSyncBankTransactions() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (companyId: string) => { + const data = await fetchGraphQL( + SYNC_BANK_TRANSACTIONS_MUTATION, + { companyId } + ); + return data.syncBankTransactions; + }, + onSuccess: (_, companyId) => { + // Invalidate pending transactions query to refresh the list + queryClient.invalidateQueries({ + queryKey: createQueryKey('pendingBankTransactions', { companyId }), + }); + queryClient.invalidateQueries({ + queryKey: createQueryKey('bankTransactions', { companyId }), + }); + }, + }); +} + +export function useMarkBankTransactionBooked() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + journalEntryDraftId, + }: { + id: string; + journalEntryDraftId: string; + }) => { + const data = await fetchGraphQL<{ markBankTransactionBooked: BankTransactionResponse }>( + MARK_BANK_TRANSACTION_BOOKED_MUTATION, + { id, journalEntryDraftId } + ); + return data.markBankTransactionBooked; + }, + onSuccess: () => { + // Invalidate all bank transaction queries + queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === 'pendingBankTransactions' || + query.queryKey[0] === 'bankTransactions', + }); + }, + }); +} + +export function useIgnoreBankTransaction() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const data = await fetchGraphQL<{ ignoreBankTransaction: BankTransactionResponse }>( + IGNORE_BANK_TRANSACTION_MUTATION, + { id } + ); + return data.ignoreBankTransaction; + }, + onSuccess: () => { + // Invalidate all bank transaction queries + queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === 'pendingBankTransactions' || + query.queryKey[0] === 'bankTransactions', + }); + }, + }); +} diff --git a/frontend/src/api/queries/companyQueries.ts b/frontend/src/api/queries/companyQueries.ts new file mode 100644 index 0000000..5e30726 --- /dev/null +++ b/frontend/src/api/queries/companyQueries.ts @@ -0,0 +1,152 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; +import type { Company, UserCompanyAccess, CompanyWithRole, CompanyRole } from '@/types/accounting'; + +// GraphQL Queries - using gql tag for proper parsing with graphql-request v7 +const MY_COMPANIES_QUERY = gql` + query MyCompanies { + myCompanies { + id + userId + companyId + role + grantedAt + isActive + company { + id + name + cvr + country + currency + fiscalYearStartMonth + } + } + } +`; + +const COMPANY_USERS_QUERY = gql` + query CompanyUsers($companyId: ID!) { + companyUsers(companyId: $companyId) { + id + userId + companyId + role + grantedBy + grantedAt + isActive + } + } +`; + +const COMPANY_QUERY = gql` + query Company($id: ID!) { + company(id: $id) { + id + name + cvr + address + city + postalCode + country + currency + fiscalYearStartMonth + vatRegistered + vatPeriodFrequency + bankName + bankRegNo + bankAccountNo + bankIban + bankBic + } + } +`; + +// Response types +interface MyCompaniesResponse { + myCompanies: UserCompanyAccess[]; +} + +interface CompanyUsersResponse { + companyUsers: UserCompanyAccess[]; +} + +interface CompanyResponse { + company: Company | null; +} + +// Normalize role from GraphQL (uppercase) to frontend (lowercase) +function normalizeRole(role: string): CompanyRole { + return role.toLowerCase() as CompanyRole; +} + +// Transform function to merge company with role +function transformToCompanyWithRole(access: UserCompanyAccess): CompanyWithRole | null { + if (!access.company) return null; + return { + ...access.company, + role: normalizeRole(access.role), + }; +} + +/** + * Hook to fetch all companies the current user has access to + */ +export function useMyCompanies( + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('myCompanies'), + queryFn: async () => { + const data = await fetchGraphQL(MY_COMPANIES_QUERY); + return data.myCompanies + .filter((access) => access.isActive && access.company) + .map(transformToCompanyWithRole) + .filter((c): c is CompanyWithRole => c !== null); + }, + ...options, + }); +} + +/** + * Hook to fetch all users with access to a company (requires Owner role) + */ +export function useCompanyUsers( + companyId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('companyUsers', { companyId }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(COMPANY_USERS_QUERY, { companyId }); + return data.companyUsers + .filter((access) => access.isActive) + .map((access) => ({ + ...access, + role: normalizeRole(access.role), + })); + }, + enabled: !!companyId, + ...options, + }); +} + +/** + * Hook to fetch a single company by ID + */ +export function useCompany( + id: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('company', { id }), + queryFn: async () => { + if (!id) return null; + const data = await fetchGraphQL(COMPANY_QUERY, { id }); + return data.company; + }, + enabled: !!id, + ...options, + }); +} diff --git a/frontend/src/api/queries/customerQueries.ts b/frontend/src/api/queries/customerQueries.ts new file mode 100644 index 0000000..ce81a34 --- /dev/null +++ b/frontend/src/api/queries/customerQueries.ts @@ -0,0 +1,174 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; + +// GraphQL Queries +const CUSTOMERS_QUERY = gql` + query Customers($companyId: ID!) { + customers(companyId: $companyId) { + id + companyId + customerNumber + customerType + name + cvr + address + postalCode + city + country + email + phone + paymentTermsDays + defaultRevenueAccountId + subLedgerAccountId + isActive + createdAt + updatedAt + } + } +`; + +const ACTIVE_CUSTOMERS_QUERY = gql` + query ActiveCustomers($companyId: ID!) { + activeCustomers(companyId: $companyId) { + id + companyId + customerNumber + customerType + name + cvr + address + postalCode + city + country + email + phone + paymentTermsDays + defaultRevenueAccountId + subLedgerAccountId + isActive + createdAt + updatedAt + } + } +`; + +const CUSTOMER_QUERY = gql` + query Customer($id: ID!) { + customer(id: $id) { + id + companyId + customerNumber + customerType + name + cvr + address + postalCode + city + country + email + phone + paymentTermsDays + defaultRevenueAccountId + subLedgerAccountId + isActive + createdAt + updatedAt + } + } +`; + +// Types +export type CustomerType = 'Business' | 'Private'; + +export interface Customer { + id: string; + companyId: string; + customerNumber: string; + customerType: CustomerType; + name: string; + cvr?: string; + address?: string; + postalCode?: string; + city?: string; + country?: string; + email?: string; + phone?: string; + paymentTermsDays: number; + defaultRevenueAccountId?: string; + subLedgerAccountId: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +// Response types +interface CustomersResponse { + customers: Customer[]; +} + +interface ActiveCustomersResponse { + activeCustomers: Customer[]; +} + +interface CustomerResponse { + customer: Customer; +} + +/** + * Hook to fetch all customers for a company. + */ +export function useCustomers( + companyId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('customers', { companyId }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(CUSTOMERS_QUERY, { companyId }); + return data.customers; + }, + enabled: !!companyId, + ...options, + }); +} + +/** + * Hook to fetch active customers for a company. + */ +export function useActiveCustomers( + companyId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('activeCustomers', { companyId }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(ACTIVE_CUSTOMERS_QUERY, { companyId }); + return data.activeCustomers; + }, + enabled: !!companyId, + ...options, + }); +} + +/** + * Hook to fetch a single customer by ID. + */ +export function useCustomer( + id: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('customer', { id }), + queryFn: async () => { + if (!id) return null; + const data = await fetchGraphQL(CUSTOMER_QUERY, { id }); + return data.customer; + }, + enabled: !!id, + ...options, + }); +} + diff --git a/frontend/src/api/queries/draftQueries.ts b/frontend/src/api/queries/draftQueries.ts new file mode 100644 index 0000000..5591f7b --- /dev/null +++ b/frontend/src/api/queries/draftQueries.ts @@ -0,0 +1,114 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; +import type { JournalEntryDraft } from '@/types/accounting'; + +// GraphQL Queries +const JOURNAL_ENTRY_DRAFTS_QUERY = gql` + query JournalEntryDrafts($companyId: ID!) { + journalEntryDrafts(companyId: $companyId) { + id + companyId + name + voucherNumber + documentDate + description + fiscalYearId + lines { + lineNumber + accountId + debitAmount + creditAmount + description + vatCode + } + attachmentIds + status + transactionId + createdBy + createdAt + updatedAt + } + } +`; + +const JOURNAL_ENTRY_DRAFT_QUERY = gql` + query JournalEntryDraft($id: ID!) { + journalEntryDraft(id: $id) { + id + companyId + name + voucherNumber + documentDate + description + fiscalYearId + lines { + lineNumber + accountId + debitAmount + creditAmount + description + vatCode + } + attachmentIds + status + transactionId + createdBy + createdAt + updatedAt + } + } +`; + +// Response types +interface JournalEntryDraftsResponse { + journalEntryDrafts: JournalEntryDraft[]; +} + +interface JournalEntryDraftResponse { + journalEntryDraft: JournalEntryDraft | null; +} + +/** + * Hook to fetch all active journal entry drafts for a company. + */ +export function useJournalEntryDrafts( + companyId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('journalEntryDrafts', { companyId }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL( + JOURNAL_ENTRY_DRAFTS_QUERY, + { companyId } + ); + return data.journalEntryDrafts; + }, + enabled: !!companyId, + ...options, + }); +} + +/** + * Hook to fetch a single journal entry draft by ID. + */ +export function useJournalEntryDraft( + id: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('journalEntryDraft', { id }), + queryFn: async () => { + if (!id) return null; + const data = await fetchGraphQL( + JOURNAL_ENTRY_DRAFT_QUERY, + { id } + ); + return data.journalEntryDraft; + }, + enabled: !!id, + ...options, + }); +} diff --git a/frontend/src/api/queries/fiscalYearQueries.ts b/frontend/src/api/queries/fiscalYearQueries.ts new file mode 100644 index 0000000..426a23d --- /dev/null +++ b/frontend/src/api/queries/fiscalYearQueries.ts @@ -0,0 +1,137 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; +import type { FiscalYear } from '@/types/periods'; + +// GraphQL Queries +const FISCAL_YEARS_QUERY = gql` + query FiscalYears($companyId: ID!) { + fiscalYears(companyId: $companyId) { + id + companyId + name + startDate + endDate + status + openingBalancePosted + closingDate + closedBy + reopenedDate + reopenedBy + lockedDate + lockedBy + createdAt + updatedAt + } + } +`; + +const FISCAL_YEAR_QUERY = gql` + query FiscalYear($id: ID!) { + fiscalYear(id: $id) { + id + companyId + name + startDate + endDate + status + openingBalancePosted + closingDate + closedBy + reopenedDate + reopenedBy + lockedDate + lockedBy + createdAt + updatedAt + } + } +`; + +// Response types +interface FiscalYearResponse { + id: string; + companyId: string; + name: string; + startDate: string; + endDate: string; + status: string; + openingBalancePosted: boolean; + closingDate?: string; + closedBy?: string; + reopenedDate?: string; + reopenedBy?: string; + lockedDate?: string; + lockedBy?: string; + createdAt: string; + updatedAt: string; +} + +interface FiscalYearsQueryResponse { + fiscalYears: FiscalYearResponse[]; +} + +interface FiscalYearQueryResponse { + fiscalYear: FiscalYearResponse | null; +} + +// Transform backend response to frontend type +function transformFiscalYear(fy: FiscalYearResponse): FiscalYear { + return { + id: fy.id, + companyId: fy.companyId, + name: fy.name, + startDate: fy.startDate, + endDate: fy.endDate, + status: fy.status.toLowerCase() as 'open' | 'closed' | 'locked', + openingBalancePosted: fy.openingBalancePosted, + closingDate: fy.closingDate, + closedBy: fy.closedBy, + reopenedDate: fy.reopenedDate, + reopenedBy: fy.reopenedBy, + lockedDate: fy.lockedDate, + lockedBy: fy.lockedBy, + createdAt: fy.createdAt, + updatedAt: fy.updatedAt, + }; +} + +/** + * Hook to fetch all fiscal years for a company. + */ +export function useFiscalYears( + companyId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('fiscalYears', { companyId }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(FISCAL_YEARS_QUERY, { + companyId, + }); + return data.fiscalYears.map(transformFiscalYear); + }, + enabled: !!companyId, + ...options, + }); +} + +/** + * Hook to fetch a single fiscal year by ID. + */ +export function useFiscalYear( + id: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('fiscalYear', { id }), + queryFn: async () => { + if (!id) return null; + const data = await fetchGraphQL(FISCAL_YEAR_QUERY, { id }); + return data.fiscalYear ? transformFiscalYear(data.fiscalYear) : null; + }, + enabled: !!id, + ...options, + }); +} diff --git a/frontend/src/api/queries/invoiceQueries.ts b/frontend/src/api/queries/invoiceQueries.ts new file mode 100644 index 0000000..caa73ee --- /dev/null +++ b/frontend/src/api/queries/invoiceQueries.ts @@ -0,0 +1,397 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; + +// GraphQL Queries +const INVOICES_QUERY = gql` + query Invoices($companyId: ID!, $status: String) { + invoices(companyId: $companyId, status: $status) { + id + companyId + fiscalYearId + customerId + customerName + customerNumber + invoiceNumber + invoiceType + isCreditNote + originalInvoiceId + originalInvoiceNumber + creditReason + invoiceDate + dueDate + status + amountExVat + amountVat + amountTotal + amountPaid + amountApplied + amountRemaining + currency + vatCode + paymentTermsDays + ledgerTransactionId + notes + reference + sentAt + issuedAt + paidAt + voidedAt + voidedReason + voidedBy + createdBy + updatedBy + createdAt + updatedAt + lines { + lineNumber + description + quantity + unit + unitPrice + discountPercent + vatCode + accountId + amountExVat + amountVat + amountTotal + } + } + } +`; + +const INVOICE_QUERY = gql` + query Invoice($id: ID!) { + invoice(id: $id) { + id + companyId + fiscalYearId + customerId + customerName + customerNumber + invoiceNumber + invoiceType + isCreditNote + originalInvoiceId + originalInvoiceNumber + creditReason + invoiceDate + dueDate + status + amountExVat + amountVat + amountTotal + amountPaid + amountApplied + amountRemaining + currency + vatCode + paymentTermsDays + ledgerTransactionId + notes + reference + sentAt + issuedAt + paidAt + voidedAt + voidedReason + voidedBy + createdBy + updatedBy + createdAt + updatedAt + lines { + lineNumber + description + quantity + unit + unitPrice + discountPercent + vatCode + accountId + amountExVat + amountVat + amountTotal + } + } + } +`; + +const INVOICES_BY_CUSTOMER_QUERY = gql` + query InvoicesByCustomer($customerId: ID!) { + invoicesByCustomer(customerId: $customerId) { + id + companyId + invoiceNumber + invoiceDate + dueDate + status + amountTotal + amountPaid + amountRemaining + customerName + } + } +`; + +// Credit note queries (via unified Invoice model) +const CREDIT_NOTES_QUERY = gql` + query CreditNotes($companyId: ID!) { + creditNotes(companyId: $companyId) { + id + companyId + fiscalYearId + customerId + customerName + customerNumber + invoiceNumber + invoiceType + isCreditNote + originalInvoiceId + originalInvoiceNumber + creditReason + invoiceDate + dueDate + status + amountExVat + amountVat + amountTotal + amountPaid + amountApplied + amountRemaining + currency + vatCode + ledgerTransactionId + notes + reference + sentAt + issuedAt + paidAt + voidedAt + voidedReason + createdBy + createdAt + updatedAt + lines { + lineNumber + description + quantity + unit + unitPrice + discountPercent + vatCode + accountId + amountExVat + amountVat + amountTotal + } + } + } +`; + +const UNAPPLIED_CREDIT_NOTES_QUERY = gql` + query UnappliedCreditNotes($companyId: ID!) { + unappliedCreditNotes(companyId: $companyId) { + id + companyId + customerId + customerName + invoiceNumber + invoiceDate + amountTotal + amountApplied + amountRemaining + } + } +`; + +// Types +export type InvoiceStatus = 'draft' | 'sent' | 'issued' | 'partially_paid' | 'partially_applied' | 'paid' | 'fully_applied' | 'voided'; +export type InvoiceDocumentType = 'invoice' | 'credit_note'; + +export interface InvoiceLine { + lineNumber: number; + description: string; + quantity: number; + unit?: string; + unitPrice: number; + discountPercent: number; + vatCode: string; + accountId?: string; + amountExVat: number; + amountVat: number; + amountTotal: number; +} + +export interface Invoice { + id: string; + companyId: string; + fiscalYearId?: string; + customerId: string; + customerName: string; + customerNumber: string; + invoiceNumber: string; + // Document type fields (for credit note support) + invoiceType: InvoiceDocumentType; + isCreditNote: boolean; + originalInvoiceId?: string; + originalInvoiceNumber?: string; + creditReason?: string; + // Dates + invoiceDate?: string; + dueDate?: string; + // Status and amounts + status: InvoiceStatus; + amountExVat: number; + amountVat: number; + amountTotal: number; + amountPaid: number; + amountApplied: number; // For credit notes + amountRemaining: number; + currency: string; + vatCode?: string; + paymentTermsDays: number; + ledgerTransactionId?: string; + notes?: string; + reference?: string; + sentAt?: string; + issuedAt?: string; // For credit notes + paidAt?: string; + voidedAt?: string; + voidedReason?: string; + voidedBy?: string; + createdBy: string; + updatedBy?: string; + createdAt: string; + updatedAt: string; + lines: InvoiceLine[]; +} + +// Response types +interface InvoicesResponse { + invoices: Invoice[]; +} + +interface InvoiceResponse { + invoice: Invoice; +} + +interface InvoicesByCustomerResponse { + invoicesByCustomer: Invoice[]; +} + +/** + * Hook to fetch invoices for a company with optional status filter. + */ +export function useInvoices( + companyId: string | undefined, + filters?: { status?: InvoiceStatus }, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('invoices', { companyId, ...filters }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(INVOICES_QUERY, { + companyId, + status: filters?.status, + }); + return data.invoices; + }, + enabled: !!companyId, + ...options, + }); +} + +/** + * Hook to fetch a single invoice by ID. + */ +export function useInvoice( + id: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('invoice', { id }), + queryFn: async () => { + if (!id) return null; + const data = await fetchGraphQL(INVOICE_QUERY, { id }); + return data.invoice; + }, + enabled: !!id, + ...options, + }); +} + +/** + * Hook to fetch invoices by customer. + */ +export function useInvoicesByCustomer( + customerId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('invoicesByCustomer', { customerId }), + queryFn: async () => { + if (!customerId) return []; + const data = await fetchGraphQL(INVOICES_BY_CUSTOMER_QUERY, { + customerId, + }); + return data.invoicesByCustomer; + }, + enabled: !!customerId, + ...options, + }); +} + +// ===================================================== +// CREDIT NOTE HOOKS (via unified Invoice model) +// ===================================================== + +interface CreditNotesResponse { + creditNotes: Invoice[]; +} + +interface UnappliedCreditNotesResponse { + unappliedCreditNotes: Invoice[]; +} + +/** + * Hook to fetch all credit notes for a company. + * Credit notes are invoices with invoiceType = 'credit_note'. + */ +export function useCreditNotes( + companyId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('creditNotes', { companyId }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(CREDIT_NOTES_QUERY, { companyId }); + return data.creditNotes; + }, + enabled: !!companyId, + ...options, + }); +} + +/** + * Hook to fetch unapplied credit notes (with remaining balance). + */ +export function useUnappliedCreditNotes( + companyId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('unappliedCreditNotes', { companyId }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(UNAPPLIED_CREDIT_NOTES_QUERY, { + companyId, + }); + return data.unappliedCreditNotes; + }, + enabled: !!companyId, + ...options, + }); +} diff --git a/frontend/src/api/queries/orderQueries.ts b/frontend/src/api/queries/orderQueries.ts new file mode 100644 index 0000000..1521513 --- /dev/null +++ b/frontend/src/api/queries/orderQueries.ts @@ -0,0 +1,369 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; +import type { Order, OrderStatus } from '@/types/order'; + +// GraphQL Queries +const ORDERS_QUERY = gql` + query Orders($companyId: ID!, $status: String) { + orders(companyId: $companyId, status: $status) { + id + companyId + fiscalYearId + customerId + customerName + customerNumber + orderNumber + orderDate + expectedDeliveryDate + status + amountExVat + amountVat + amountTotal + uninvoicedAmount + uninvoicedLineCount + currency + notes + reference + confirmedAt + confirmedBy + cancelledAt + cancelledReason + cancelledBy + createdBy + updatedBy + createdAt + updatedAt + lines { + lineNumber + productId + description + quantity + unit + unitPrice + discountPercent + vatCode + accountId + amountExVat + amountVat + amountTotal + isInvoiced + invoiceId + invoicedAt + } + } + } +`; + +const ORDER_QUERY = gql` + query Order($id: ID!) { + order(id: $id) { + id + companyId + fiscalYearId + customerId + customerName + customerNumber + orderNumber + orderDate + expectedDeliveryDate + status + amountExVat + amountVat + amountTotal + uninvoicedAmount + uninvoicedLineCount + currency + notes + reference + confirmedAt + confirmedBy + cancelledAt + cancelledReason + cancelledBy + createdBy + updatedBy + createdAt + updatedAt + lines { + lineNumber + productId + description + quantity + unit + unitPrice + discountPercent + vatCode + accountId + amountExVat + amountVat + amountTotal + isInvoiced + invoiceId + invoicedAt + } + uninvoicedLines { + lineNumber + productId + description + quantity + unit + unitPrice + discountPercent + vatCode + accountId + amountExVat + amountVat + amountTotal + isInvoiced + } + } + } +`; + +const ORDER_BY_NUMBER_QUERY = gql` + query OrderByNumber($companyId: ID!, $orderNumber: String!) { + orderByNumber(companyId: $companyId, orderNumber: $orderNumber) { + id + companyId + fiscalYearId + customerId + customerName + customerNumber + orderNumber + orderDate + expectedDeliveryDate + status + amountExVat + amountVat + amountTotal + uninvoicedAmount + uninvoicedLineCount + currency + notes + reference + confirmedAt + confirmedBy + cancelledAt + cancelledReason + cancelledBy + createdBy + updatedBy + createdAt + updatedAt + lines { + lineNumber + productId + description + quantity + unit + unitPrice + discountPercent + vatCode + accountId + amountExVat + amountVat + amountTotal + isInvoiced + invoiceId + invoicedAt + } + } + } +`; + +const ORDERS_BY_CUSTOMER_QUERY = gql` + query OrdersByCustomer($customerId: ID!, $status: String) { + ordersByCustomer(customerId: $customerId, status: $status) { + id + companyId + orderNumber + orderDate + expectedDeliveryDate + status + amountTotal + uninvoicedAmount + customerName + } + } +`; + +const CONFIRMED_ORDERS_QUERY = gql` + query ConfirmedOrders($companyId: ID!) { + confirmedOrders(companyId: $companyId) { + id + companyId + customerId + customerName + customerNumber + orderNumber + orderDate + expectedDeliveryDate + status + amountExVat + amountVat + amountTotal + uninvoicedAmount + uninvoicedLineCount + currency + reference + lines { + lineNumber + description + quantity + unit + unitPrice + discountPercent + vatCode + amountExVat + amountVat + amountTotal + isInvoiced + invoiceId + invoicedAt + } + uninvoicedLines { + lineNumber + description + quantity + unit + unitPrice + discountPercent + vatCode + amountExVat + amountVat + amountTotal + } + } + } +`; + +// Response types +interface OrdersResponse { + orders: Order[]; +} + +interface OrderResponse { + order: Order | null; +} + +interface OrderByNumberResponse { + orderByNumber: Order | null; +} + +interface OrdersByCustomerResponse { + ordersByCustomer: Order[]; +} + +interface ConfirmedOrdersResponse { + confirmedOrders: Order[]; +} + +/** + * Hook to fetch orders for a company with optional status filter. + */ +export function useOrders( + companyId: string | undefined, + filters?: { status?: OrderStatus }, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('orders', { companyId, ...filters }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(ORDERS_QUERY, { + companyId, + status: filters?.status, + }); + return data.orders; + }, + enabled: !!companyId, + ...options, + }); +} + +/** + * Hook to fetch a single order by ID. + */ +export function useOrder( + id: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('order', { id }), + queryFn: async () => { + if (!id) return null; + const data = await fetchGraphQL(ORDER_QUERY, { id }); + return data.order; + }, + enabled: !!id, + ...options, + }); +} + +/** + * Hook to fetch an order by order number. + */ +export function useOrderByNumber( + companyId: string | undefined, + orderNumber: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('orderByNumber', { companyId, orderNumber }), + queryFn: async () => { + if (!companyId || !orderNumber) return null; + const data = await fetchGraphQL(ORDER_BY_NUMBER_QUERY, { + companyId, + orderNumber, + }); + return data.orderByNumber; + }, + enabled: !!companyId && !!orderNumber, + ...options, + }); +} + +/** + * Hook to fetch orders by customer. + */ +export function useOrdersByCustomer( + customerId: string | undefined, + filters?: { status?: OrderStatus }, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('ordersByCustomer', { customerId, ...filters }), + queryFn: async () => { + if (!customerId) return []; + const data = await fetchGraphQL(ORDERS_BY_CUSTOMER_QUERY, { + customerId, + status: filters?.status, + }); + return data.ordersByCustomer; + }, + enabled: !!customerId, + ...options, + }); +} + +/** + * Hook to fetch confirmed orders ready for invoicing. + */ +export function useConfirmedOrders( + companyId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('confirmedOrders', { companyId }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(CONFIRMED_ORDERS_QUERY, { + companyId, + }); + return data.confirmedOrders; + }, + enabled: !!companyId, + ...options, + }); +} diff --git a/frontend/src/api/queries/productQueries.ts b/frontend/src/api/queries/productQueries.ts new file mode 100644 index 0000000..f6872a1 --- /dev/null +++ b/frontend/src/api/queries/productQueries.ts @@ -0,0 +1,150 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; +import type { Product } from '@/types/product'; + +// Re-export Product type for backwards compatibility +export type { Product } from '@/types/product'; + +// GraphQL Queries +const PRODUCTS_QUERY = gql` + query Products($companyId: ID!) { + products(companyId: $companyId) { + id + companyId + productNumber + name + description + unitPrice + vatCode + unit + defaultAccountId + ean + manufacturer + isActive + createdAt + updatedAt + } + } +`; + +const PRODUCT_QUERY = gql` + query Product($id: ID!) { + product(id: $id) { + id + companyId + productNumber + name + description + unitPrice + vatCode + unit + defaultAccountId + ean + manufacturer + isActive + createdAt + updatedAt + } + } +`; + +// Response types +interface ProductsResponse { + products: Product[]; +} + +interface ProductResponse { + product: Product | null; +} + +/** + * Hook to fetch all products for a company. + */ +export function useProducts( + companyId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('products', { companyId }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(PRODUCTS_QUERY, { companyId }); + return data.products; + }, + enabled: !!companyId, + ...options, + }); +} + +/** + * Hook to fetch active products for a company. + */ +export function useActiveProducts( + companyId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('activeProducts', { companyId }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(PRODUCTS_QUERY, { companyId }); + return data.products.filter((p) => p.isActive); + }, + enabled: !!companyId, + ...options, + }); +} + +/** + * Hook to fetch a single product by ID. + */ +export function useProduct( + id: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('product', { id }), + queryFn: async () => { + if (!id) return null; + const data = await fetchGraphQL(PRODUCT_QUERY, { id }); + return data.product; + }, + enabled: !!id, + ...options, + }); +} + +// GraphQL Query for manufacturers autocomplete +const MANUFACTURERS_QUERY = gql` + query Manufacturers($companyId: ID!, $searchTerm: String) { + manufacturers(companyId: $companyId, searchTerm: $searchTerm) + } +`; + +interface ManufacturersResponse { + manufacturers: string[]; +} + +/** + * Hook to fetch manufacturers for autocomplete. + */ +export function useManufacturers( + companyId: string | undefined, + searchTerm?: string, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: createQueryKey('manufacturers', { companyId, searchTerm }), + queryFn: async () => { + if (!companyId) return []; + const data = await fetchGraphQL(MANUFACTURERS_QUERY, { + companyId, + searchTerm: searchTerm || undefined, + }); + return data.manufacturers; + }, + enabled: !!companyId, + ...options, + }); +} diff --git a/frontend/src/api/queries/vatQueries.ts b/frontend/src/api/queries/vatQueries.ts new file mode 100644 index 0000000..7a4598f --- /dev/null +++ b/frontend/src/api/queries/vatQueries.ts @@ -0,0 +1,81 @@ +// VAT (Moms) queries for SKAT compliance reporting + +import { useQuery } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { fetchGraphQL, createQueryKey } from '../client'; + +// Response types matching backend VatReportDto +export interface VatReportResponse { + // SKAT boxes (VAT amounts) + boxA: number; // Salgsmoms (Output VAT) + boxB: number; // Købsmoms (Input VAT deduction) + boxC: number; // EU-varekøb moms + boxD: number; // Ydelseskøb moms + + // Basis/turnover fields + basis1: number; // Salg med moms + basis2: number; // Salg uden moms (export) + basis3: number; // EU-varekøb + basis4: number; // Ydelseskøb fra udland + + // Summary + totalOutputVat: number; + totalInputVat: number; + netVat: number; + + // Period info + periodStart: string; + periodEnd: string; + transactionCount: number; +} + +const VAT_REPORT_QUERY = gql` + query VatReport($companyId: ID!, $periodStart: DateOnly!, $periodEnd: DateOnly!) { + vatReport(companyId: $companyId, periodStart: $periodStart, periodEnd: $periodEnd) { + boxA + boxB + boxC + boxD + basis1 + basis2 + basis3 + basis4 + totalOutputVat + totalInputVat + netVat + periodStart + periodEnd + transactionCount + } + } +`; + +interface VatReportQueryResponse { + vatReport: VatReportResponse; +} + +/** + * Hook to fetch a VAT report for a company within a date range. + * + * @param companyId - The company ID + * @param periodStart - Start date in YYYY-MM-DD format + * @param periodEnd - End date in YYYY-MM-DD format + */ +export function useVatReport( + companyId: string | undefined, + periodStart: string | undefined, + periodEnd: string | undefined +) { + return useQuery({ + queryKey: createQueryKey('vatReport', { companyId, periodStart, periodEnd }), + queryFn: async () => { + const data = await fetchGraphQL(VAT_REPORT_QUERY, { + companyId, + periodStart, + periodEnd, + }); + return data.vatReport; + }, + enabled: !!companyId && !!periodStart && !!periodEnd, + }); +} diff --git a/frontend/src/components/auth/CompanyGuard.tsx b/frontend/src/components/auth/CompanyGuard.tsx new file mode 100644 index 0000000..56dc900 --- /dev/null +++ b/frontend/src/components/auth/CompanyGuard.tsx @@ -0,0 +1,114 @@ +import { ReactNode, useEffect } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { Spin, Typography } from 'antd'; +import { useMyCompanies } from '@/api/queries/companyQueries'; +import { useCompanyStore } from '@/stores/companyStore'; + +const { Text } = Typography; + +interface CompanyGuardProps { + children: ReactNode; +} + +/** + * Guard component that ensures user has at least one company. + * Redirects to /opret-virksomhed wizard if no companies exist. + */ +export default function CompanyGuard({ children }: CompanyGuardProps) { + const navigate = useNavigate(); + const location = useLocation(); + const { setCompanies, setActiveCompany, activeCompany } = useCompanyStore(); + + const { + data: companies, + isLoading, + isError, + error, + } = useMyCompanies({ + staleTime: 5 * 60 * 1000, + refetchOnMount: true, + }); + + // Sync companies to store when loaded + useEffect(() => { + if (companies) { + setCompanies(companies); + + // Set active company if not already set + if (companies.length > 0 && !activeCompany) { + // Try to restore from localStorage + const stored = localStorage.getItem('books-company-storage'); + if (stored) { + try { + const parsed = JSON.parse(stored); + const storedCompany = parsed.state?.activeCompany; + if (storedCompany) { + const found = companies.find((c) => c.id === storedCompany.id); + if (found) { + setActiveCompany(found); + return; + } + } + } catch { + // Ignore parse errors + } + } + // Default to first company + setActiveCompany(companies[0]); + } + } + }, [companies, activeCompany, setCompanies, setActiveCompany]); + + // Redirect to wizard if no companies and not already on wizard page + useEffect(() => { + if (!isLoading && companies !== undefined) { + const isOnWizard = location.pathname === '/opret-virksomhed'; + + if (companies.length === 0 && !isOnWizard) { + navigate('/opret-virksomhed', { replace: true }); + } + // Note: Users with existing companies CAN access the wizard to create more + } + }, [companies, isLoading, location.pathname, navigate]); + + // Show loading state + if (isLoading) { + return ( +
+ + Henter dine virksomheder... +
+ ); + } + + // Show error state + if (isError) { + return ( +
+ + Kunne ikke hente virksomheder: {error?.message || 'Ukendt fejl'} + +
+ ); + } + + return <>{children}; +} diff --git a/frontend/src/components/bank-reconciliation/DocumentUploadModal.tsx b/frontend/src/components/bank-reconciliation/DocumentUploadModal.tsx new file mode 100644 index 0000000..f31fc64 --- /dev/null +++ b/frontend/src/components/bank-reconciliation/DocumentUploadModal.tsx @@ -0,0 +1,624 @@ +import { useState, useMemo } from 'react'; +import { + Modal, + Spin, + Result, + Descriptions, + Tag, + Space, + Button, + Typography, + Divider, + Alert, + Table, + Collapse, + message, +} from 'antd'; +import { + FileOutlined, + CheckCircleOutlined, + ExclamationCircleOutlined, + LinkOutlined, + BankOutlined, + CalendarOutlined, + InfoCircleOutlined, +} from '@ant-design/icons'; +import { formatCurrency, formatDateShort, formatCVR } from '@/lib/formatters'; +import { AmountText } from '@/components/shared/AmountText'; +import { StatusBadge } from '@/components/shared/StatusBadge'; +import { useResponsiveModal } from '@/hooks/useResponsiveModal'; +import { usePostJournalEntryDraft, useDiscardJournalEntryDraft } from '@/api/mutations/draftMutations'; +import { accountingColors } from '@/styles/theme'; +import { spacing } from '@/styles/designTokens'; +import type { DocumentProcessingResult, ExtractedLineItem, SuggestedJournalLine } from '@/api/documentProcessing'; + +const { Text, Title } = Typography; + +interface DocumentUploadModalProps { + visible: boolean; + result: DocumentProcessingResult | null; + isProcessing: boolean; + error: string | null; + fileName?: string; + onConfirm: () => void; + onClose: () => void; +} + +interface JournalPreviewLine { + key: number; + accountNumber: string; + accountName: string; + debit: number; + credit: number; + vatCode?: string; +} + +/** + * Modal showing document processing results from AI analysis. + * Displays extracted information, journal entry preview, and matched bank transaction. + * Offers options to save as draft or post immediately. + */ +export function DocumentUploadModal({ + visible, + result, + isProcessing, + error, + fileName, + onConfirm, + onClose, +}: DocumentUploadModalProps) { + const responsiveModalProps = useResponsiveModal({ size: 'large' }); + const [isPosting, setIsPosting] = useState(false); + + // Mutations + const postDraftMutation = usePostJournalEntryDraft(); + const discardDraftMutation = useDiscardJournalEntryDraft(); + + // Build journal preview lines directly from API response (no separate fetch needed) + const journalLines = useMemo((): JournalPreviewLine[] => { + if (!result?.suggestedLines || result.suggestedLines.length === 0) return []; + + return result.suggestedLines.map((line: SuggestedJournalLine, idx: number) => ({ + key: idx, + accountNumber: line.accountNumber || '-', + accountName: line.accountName || 'Ukendt konto', + debit: line.debitAmount, + credit: line.creditAmount, + vatCode: line.vatCode, + })); + }, [result?.suggestedLines]); + + // Calculate totals + const { totalDebit, totalCredit, isBalanced } = useMemo(() => { + const debit = journalLines.reduce((sum, l) => sum + l.debit, 0); + const credit = journalLines.reduce((sum, l) => sum + l.credit, 0); + return { + totalDebit: debit, + totalCredit: credit, + isBalanced: Math.abs(debit - credit) < 0.01, + }; + }, [journalLines]); + + const handlePostNow = async () => { + if (!result?.draftId) return; + + setIsPosting(true); + try { + await postDraftMutation.mutateAsync(result.draftId); + message.success('Bogfoert!'); + onConfirm(); + } catch (err) { + message.error('Kunne ikke bogfoere. Proev igen.'); + } finally { + setIsPosting(false); + } + }; + + const handleSaveAsDraft = () => { + // Draft is already saved, just close the modal + onConfirm(); + }; + + const handleCancel = async () => { + // Discard the draft if one was created + if (result?.draftId && !result.isDuplicate) { + try { + await discardDraftMutation.mutateAsync(result.draftId); + } catch { + // Silently fail - draft cleanup is best effort + } + } + onClose(); + }; + + // Processing state + if (isProcessing) { + return ( + + + Behandler dokument + + } + footer={null} + closable={false} + maskClosable={false} + width={500} + > +
+ +
+ Analyserer {fileName || 'dokument'}... +
+
+ + AI-tjenesten udtraekker information fra dokumentet + +
+
+
+ ); + } + + // Error state + if (error) { + return ( + + + Fejl ved behandling + + } + footer={[ + , + ]} + onCancel={onClose} + width={500} + > + + + ); + } + + // Duplicate detection + if (result?.isDuplicate) { + return ( + + + Dokument allerede behandlet + + } + footer={[ + , + , + ]} + onCancel={onClose} + width={500} + > + + {result.draftId && Kladde ID: {result.draftId}} + + ); + } + + // Success state with comprehensive verification view + const previewColumns = [ + { + title: 'Konto', + key: 'account', + render: (_: unknown, record: JournalPreviewLine) => ( + + {record.accountNumber} + {record.accountName} + + ), + }, + { + title: 'Debet', + dataIndex: 'debit', + key: 'debit', + align: 'right' as const, + width: 120, + render: (value: number) => + value > 0 ? : null, + }, + { + title: 'Kredit', + dataIndex: 'credit', + key: 'credit', + align: 'right' as const, + width: 120, + render: (value: number) => + value > 0 ? : null, + }, + ]; + + return ( + + + Dokument analyseret + + } + footer={[ + , + , + , + ]} + onCancel={handleCancel} + {...responsiveModalProps} + > + {result && ( +
+ {/* Section 1: Extracted Document Info */} + {result.extraction && ( + + )} + + {/* Section 2: Journal Entry Preview */} + +
+
+ + Foreslaaet bogfoering + + {journalLines.length > 0 && ( + isBalanced ? ( + + ) : ( + + ) + )} +
+ + {journalLines.length > 0 ? ( + ( + + + I alt + + + + + + + + + )} + /> + ) : ( + } + /> + )} + + + {/* Section 3: Bank Transaction Match */} + {result.bankTransactionMatch && ( + <> + + + <LinkOutlined style={{ marginRight: 8 }} /> + Matchet banktransaktion + +
+ +
+ + + + {result.bankTransactionMatch.description || + result.bankTransactionMatch.counterparty} + + + +
+ + {result.bankTransactionMatch.date && + formatDateShort(result.bankTransactionMatch.date)} + {result.bankTransactionMatch.counterparty && + ` - ${result.bankTransactionMatch.counterparty}`} + +
+
+ + )} + + {/* No match found */} + {!result.bankTransactionMatch && result.extraction?.amount && ( + <> + + } + /> + + )} + + {/* Account suggestion confidence */} + {result.accountSuggestion && ( +
+ + Kontoforslag baseret paa AI-analyse ( + {Math.round(result.accountSuggestion.confidence * 100)}% sikkerhed) + +
+ )} + + )} + + ); +} + +/** + * Sub-component for displaying extracted document information + */ +function ExtractedInfoSection({ + extraction, +}: { + extraction: NonNullable; +}) { + const hasLineItems = extraction.lineItems && extraction.lineItems.length > 0; + + const lineItemColumns = [ + { + title: 'Beskrivelse', + dataIndex: 'description', + key: 'description', + ellipsis: true, + }, + { + title: 'Antal', + dataIndex: 'quantity', + key: 'quantity', + align: 'right' as const, + width: 80, + render: (val?: number) => val?.toLocaleString('da-DK') ?? '-', + }, + { + title: 'Enhedspris', + dataIndex: 'unitPrice', + key: 'unitPrice', + align: 'right' as const, + width: 100, + render: (val?: number) => (val != null ? formatCurrency(val) : '-'), + }, + { + title: 'Beloeb', + dataIndex: 'amount', + key: 'amount', + align: 'right' as const, + width: 100, + render: (val?: number) => (val != null ? formatCurrency(val) : '-'), + }, + ]; + + return ( +
+ + <FileOutlined style={{ marginRight: 8 }} /> + Udtrukket information + + + + {extraction.vendor && ( + + {extraction.vendor} + + )} + {extraction.vendorCvr && ( + + + {formatCVR(extraction.vendorCvr)} + + + )} + {extraction.invoiceNumber && ( + + {extraction.invoiceNumber} + + )} + {extraction.documentType && ( + + {mapDocumentType(extraction.documentType)} + + )} + {extraction.date && ( + + + + {formatDateShort(extraction.date)} + + + )} + {extraction.dueDate && ( + + + + {formatDateShort(extraction.dueDate)} + + + )} + + + {/* Amount breakdown */} +
+ + {extraction.amountExVat != null && ( +
+ Beloeb ekskl. moms + +
+ )} + {extraction.vatAmount != null && ( +
+ Moms (25%) + +
+ )} + {extraction.amount != null && ( +
+ Beloeb inkl. moms + + {formatCurrency(extraction.amount)} + +
+ )} + {extraction.currency && extraction.currency !== 'DKK' && ( +
+ Valuta + {extraction.currency} +
+ )} + {extraction.paymentReference && ( +
+ Betalingsreference + + {extraction.paymentReference} + +
+ )} +
+
+ + {/* Line items (collapsible) */} + {hasLineItems && ( + ({ + ...item, + key: idx, + }))} + columns={lineItemColumns} + pagination={false} + size="small" + bordered + /> + ), + }, + ]} + /> + )} +
+ ); +} + +function mapDocumentType(type: string): string { + const typeMap: Record = { + invoice: 'Faktura', + receipt: 'Kvittering', + credit_note: 'Kreditnota', + bank_statement: 'Kontoudtog', + contract: 'Kontrakt', + other: 'Andet', + }; + return typeMap[type] || type; +} + +export default DocumentUploadModal; diff --git a/frontend/src/components/company/UserAccessManager.tsx b/frontend/src/components/company/UserAccessManager.tsx new file mode 100644 index 0000000..2b64484 --- /dev/null +++ b/frontend/src/components/company/UserAccessManager.tsx @@ -0,0 +1,345 @@ +import { useState } from 'react'; +import { + Modal, + Table, + Button, + Space, + Tag, + Form, + Input, + Select, + Popconfirm, + Typography, + Alert, + Tooltip, +} from 'antd'; +import { showSuccess, showError } from '@/lib/errorHandling'; +import { decodeHtmlEntities } from '@/lib/formatters'; +import { + UserAddOutlined, + DeleteOutlined, + EditOutlined, + TeamOutlined, + CrownOutlined, + UserOutlined, + EyeOutlined, +} from '@ant-design/icons'; +import { useActiveCompany, useCanAdmin, getRoleLabel, getRoleColor } from '@/stores/companyStore'; +import { useCompanyUsers } from '@/api/queries/companyQueries'; +import { useGrantUserAccess, useChangeUserRole, useRevokeUserAccess } from '@/api/mutations/companyMutations'; +import type { UserCompanyAccess, CompanyRole } from '@/types/accounting'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; + +const { Text } = Typography; + +interface UserAccessManagerProps { + open: boolean; + onClose: () => void; +} + +interface InviteFormValues { + userId: string; + role: CompanyRole; +} + +const roleOptions = [ + { value: 'owner', label: 'Ejer', icon: , description: 'Fuld adgang inkl. brugeradministration' }, + { value: 'accountant', label: 'Bogholder', icon: , description: 'Kan bogføre og redigere' }, + { value: 'viewer', label: 'Læser', icon: , description: 'Kun læseadgang' }, +]; + +export default function UserAccessManager({ open, onClose }: UserAccessManagerProps) { + const activeCompany = useActiveCompany(); + const canAdmin = useCanAdmin(); + const [inviteModalOpen, setInviteModalOpen] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [form] = Form.useForm(); + + const { data: users = [], isLoading, refetch } = useCompanyUsers(activeCompany?.id); + const grantAccess = useGrantUserAccess(); + const changeRole = useChangeUserRole(); + const revokeAccess = useRevokeUserAccess(); + + const handleInvite = async (values: InviteFormValues) => { + if (!activeCompany) return; + + try { + await grantAccess.mutateAsync({ + userId: values.userId, + companyId: activeCompany.id, + role: values.role, + }); + showSuccess(`Bruger ${decodeHtmlEntities(values.userId)} er blevet tilføjet som ${getRoleLabel(values.role)}`); + setInviteModalOpen(false); + form.resetFields(); + refetch(); + } catch (error) { + showError('Kunne ikke tilføje bruger. Prøv igen.'); + } + }; + + const handleChangeRole = async (userId: string, newRole: CompanyRole) => { + if (!activeCompany) return; + + try { + await changeRole.mutateAsync({ + userId, + companyId: activeCompany.id, + newRole, + }); + showSuccess(`Brugerens rolle er ændret til ${getRoleLabel(newRole)}`); + setEditingUser(null); + refetch(); + } catch (error) { + showError('Kunne ikke ændre rolle. Prøv igen.'); + } + }; + + const handleRevoke = async (userId: string) => { + if (!activeCompany) return; + + try { + await revokeAccess.mutateAsync({ + userId, + companyId: activeCompany.id, + }); + showSuccess('Brugerens adgang er blevet fjernet'); + refetch(); + } catch (error) { + showError('Kunne ikke fjerne adgang. Prøv igen.'); + } + }; + + const columns: ColumnsType = [ + { + title: 'Bruger', + dataIndex: 'userId', + key: 'userId', + render: (userId: string) => ( + + + {decodeHtmlEntities(userId)} + + ), + }, + { + title: 'Rolle', + dataIndex: 'role', + key: 'role', + render: (role: CompanyRole, record) => { + if (editingUser?.userId === record.userId) { + return ( +
+ + + {/* Invite User Modal */} + + + Tilføj bruger + + } + open={inviteModalOpen} + onCancel={() => { + setInviteModalOpen(false); + form.resetFields(); + }} + footer={null} + > +
+ + } + placeholder="fx. bruger@example.com" + /> + + + +
( + + + + Total ændring + + + {/* Empty - no total for current balance */} + + + + D: + + / + K: + + + + + {isBalanced ? ( + + + Balancerer + + ) : ( + + + Difference: {(totals.debit - totals.credit).toFixed(2)} + + )} + + + + )} + /> + + + ), + }, + ]} + /> + ); +} + +export default BalanceImpactPanel; diff --git a/frontend/src/components/settings/BankConnectionsTab.tsx b/frontend/src/components/settings/BankConnectionsTab.tsx new file mode 100644 index 0000000..bc392da --- /dev/null +++ b/frontend/src/components/settings/BankConnectionsTab.tsx @@ -0,0 +1,698 @@ +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { + Card, + Button, + Space, + Typography, + Tag, + Divider, + Row, + Col, + Modal, + Select, + Spin, + Empty, + Alert, + Popconfirm, + Avatar, + DatePicker, +} from 'antd'; +import { showSuccess, showError } from '@/lib/errorHandling'; +import type { Dayjs } from 'dayjs'; +import dayjs from 'dayjs'; +import { + PlusOutlined, + BankOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + ClockCircleOutlined, + ExclamationCircleOutlined, + DisconnectOutlined, + LinkOutlined, + ReloadOutlined, + InboxOutlined, +} from '@ant-design/icons'; +import { useBankConnections, useAvailableBanks } from '@/api/queries/bankConnectionQueries'; +import { useStartBankConnection, useDisconnectBankConnection, useLinkBankAccount, useReconnectBankConnection, useArchiveBankConnection } from '@/api/mutations/bankConnectionMutations'; +import { useCreateAccount } from '@/api/mutations/accountMutations'; +import { useActiveAccounts } from '@/api/queries/accountQueries'; +import type { BankConnection, BankAccountInfo } from '@/api/queries/bankConnectionQueries'; + +const { Title, Text } = Typography; + +interface BankConnectionsTabProps { + companyId: string | undefined; +} + +function getStatusTag(status: BankConnection['status'], isActive: boolean) { + if (status === 'established' && isActive) { + return } color="success">Aktiv; + } + if (status === 'established' && !isActive) { + return } color="warning">Udløbet; + } + if (status === 'initiated') { + return } color="processing">Afventer; + } + if (status === 'failed') { + return } color="error">Fejlet; + } + if (status === 'disconnected') { + return } color="default">Afbrudt; + } + return {status}; +} + +function formatDate(dateString: string) { + return new Date(dateString).toLocaleDateString('da-DK', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +export default function BankConnectionsTab({ companyId }: BankConnectionsTabProps) { + const [searchParams, setSearchParams] = useSearchParams(); + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [selectedBank, setSelectedBank] = useState(null); + const [psuType, setPsuType] = useState('personal'); + + // Link account modal state + const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); + const [linkingAccount, setLinkingAccount] = useState<{ connectionId: string; account: BankAccountInfo } | null>(null); + const [selectedLinkedAccount, setSelectedLinkedAccount] = useState(null); + const [importFromDate, setImportFromDate] = useState(dayjs()); + + const { data: connections, isLoading: isLoadingConnections, error: connectionsError, refetch } = useBankConnections(companyId); + const { data: activeAccounts } = useActiveAccounts(companyId); + + // Handle callback from bank authorization + useEffect(() => { + const success = searchParams.get('success'); + const error = searchParams.get('error'); + + if (success === 'true') { + showSuccess('Bankforbindelse oprettet!'); + refetch(); // Refresh connections list + // Clean up URL + setSearchParams({ tab: 'bankAccounts' }); + } else if (error) { + const errorMessages: Record = { + 'missing_parameters': 'Manglende parametre fra banken', + 'completion_failed': 'Kunne ikke fuldføre forbindelsen', + 'internal_error': 'Der opstod en intern fejl', + }; + showError(errorMessages[error] || `Fejl: ${error}`); + setSearchParams({ tab: 'bankAccounts' }); + } + }, [searchParams, setSearchParams, refetch]); + const { data: availableBanks, isLoading: isLoadingBanks } = useAvailableBanks('DK', { + enabled: isAddModalOpen, + }); + + const startConnection = useStartBankConnection(); + const disconnectConnection = useDisconnectBankConnection(); + const reconnectConnection = useReconnectBankConnection(); + const archiveConnection = useArchiveBankConnection(); + const linkBankAccount = useLinkBankAccount(); + const createAccount = useCreateAccount(); + + // Filter accounts for linking: only bank accounts (1970-1989) + const linkableAccounts = activeAccounts?.filter((acc) => { + const accountNum = parseInt(acc.accountNumber, 10); + return accountNum >= 1970 && accountNum <= 1989; + }) || []; + + // Find next available bank account number (1970-1989) + const getNextBankAccountNumber = () => { + const usedNumbers = new Set( + linkableAccounts.map((acc) => parseInt(acc.accountNumber, 10)) + ); + for (let num = 1970; num <= 1989; num++) { + if (!usedNumbers.has(num)) { + return num.toString(); + } + } + return '1970'; // Fallback + }; + + const handleCreateBankAccount = async () => { + if (!companyId) return; + + const accountNumber = getNextBankAccountNumber(); + const accountName = linkableAccounts.length === 0 + ? 'Bankkonto' + : `Bankkonto ${linkableAccounts.length + 1}`; + + try { + const newAccount = await createAccount.mutateAsync({ + companyId, + accountNumber, + name: accountName, + accountType: 'asset', + description: 'Bankkonto til Open Banking', + }); + showSuccess(`Bankkonto "${accountName}" oprettet`); + // Auto-select the newly created account + setSelectedLinkedAccount(newAccount.id); + } catch (error) { + showError(error, 'Kunne ikke oprette bankkonto'); + console.error('Failed to create bank account:', error); + } + }; + + const handleOpenLinkModal = (connectionId: string, account: BankAccountInfo) => { + setLinkingAccount({ connectionId, account }); + setSelectedLinkedAccount(account.linkedAccountId || null); + setImportFromDate(account.importFromDate ? dayjs(account.importFromDate) : dayjs()); + setIsLinkModalOpen(true); + }; + + const handleLinkAccount = async () => { + if (!linkingAccount || !selectedLinkedAccount || !companyId) { + showError('Vælg venligst en konto'); + return; + } + + try { + await linkBankAccount.mutateAsync({ + input: { + connectionId: linkingAccount.connectionId, + bankAccountId: linkingAccount.account.accountId, + linkedAccountId: selectedLinkedAccount, + importFromDate: importFromDate?.toISOString(), + }, + companyId, + }); + showSuccess('Bankkonto koblet til finanskonto'); + setIsLinkModalOpen(false); + setLinkingAccount(null); + setSelectedLinkedAccount(null); + setImportFromDate(dayjs()); + } catch (error) { + showError(error, 'Kunne ikke koble bankkonto'); + console.error('Failed to link account:', error); + } + }; + + const handleAddBankAccount = async () => { + if (!selectedBank || !companyId) { + showError('Vælg venligst en bank'); + return; + } + + try { + // Use backend callback URL - backend will handle OAuth and redirect back to frontend + const apiBaseUrl = import.meta.env.VITE_API_URL || 'https://localhost:5001'; + const redirectUrl = `${apiBaseUrl}/api/banking/callback`; + console.log('Enable Banking redirect URL:', redirectUrl); + + const result = await startConnection.mutateAsync({ + companyId, + aspspName: selectedBank, + redirectUrl, + psuType, + }); + + // Redirect to bank authorization + // The state parameter contains connection ID, backend will use it to complete + window.location.href = result.authorizationUrl; + } catch (error) { + showError(error, 'Kunne ikke starte bankforbindelse'); + console.error('Failed to start bank connection:', error); + } + }; + + const handleDisconnect = async (connectionId: string) => { + if (!companyId) return; + + try { + await disconnectConnection.mutateAsync({ id: connectionId, companyId }); + showSuccess('Bankforbindelse afbrudt'); + } catch (error) { + showError(error, 'Kunne ikke afbryde bankforbindelse'); + console.error('Failed to disconnect:', error); + } + }; + + const handleReconnect = async (connectionId: string, psuType?: string) => { + if (!companyId) return; + + try { + // Use backend callback URL - backend will handle OAuth and redirect back to frontend + const apiBaseUrl = import.meta.env.VITE_API_URL || 'https://localhost:5001'; + const redirectUrl = `${apiBaseUrl}/api/banking/callback`; + + const result = await reconnectConnection.mutateAsync({ + input: { + connectionId, + redirectUrl, + psuType: psuType ?? 'personal', + }, + companyId, + }); + + // Redirect to bank authorization + window.location.href = result.authorizationUrl; + } catch (error) { + showError(error, 'Kunne ikke genoptage bankforbindelse'); + console.error('Failed to reconnect bank connection:', error); + } + }; + + const handleArchive = async (connectionId: string) => { + if (!companyId) return; + + try { + await archiveConnection.mutateAsync({ id: connectionId, companyId }); + showSuccess('Bankforbindelse arkiveret'); + } catch (error) { + showError(error, 'Kunne ikke arkivere bankforbindelse'); + console.error('Failed to archive:', error); + } + }; + + const activeConnections = connections?.filter(c => c.status === 'established' && c.isActive) || []; + const otherConnections = connections?.filter(c => !(c.status === 'established' && c.isActive)) || []; + + if (isLoadingConnections) { + return ( + +
+ +
+ Indlæser bankforbindelser... +
+
+
+ ); + } + + if (connectionsError) { + return ( + + + + ); + } + + return ( + + + {/* Header */} +
+
+ Tilknyttede bankkonti + + Forbind dine bankkonti via Open Banking for automatisk import af transaktioner + +
+ +
+ + + + {/* Active Connections */} + {activeConnections.length > 0 && ( + <> + Aktive forbindelser + {activeConnections.map((connection) => ( + + ))} + + )} + + {/* Empty State */} + {connections?.length === 0 && ( + } + description={ + + Ingen bankkonti tilknyttet + + Tilføj en bankkonto for at importere transaktioner automatisk + + + } + > + + + )} + + {/* Other Connections (inactive, failed, etc.) */} + {otherConnections.length > 0 && ( + <> + + + <Text type="secondary">Tidligere forbindelser</Text> + + {otherConnections.map((connection) => ( + + ))} + + )} +
+ + {/* Add Bank Modal */} + { + setIsAddModalOpen(false); + setSelectedBank(null); + }} + onOk={handleAddBankAccount} + okText="Forbind bank" + cancelText="Annuller" + confirmLoading={startConnection.isPending} + okButtonProps={{ disabled: !selectedBank }} + > + + + +
+ Vælg bank + +
+
+
+ + {/* Link Bank Account Modal */} + { + setIsLinkModalOpen(false); + setLinkingAccount(null); + setSelectedLinkedAccount(null); + setImportFromDate(dayjs()); + }} + onOk={handleLinkAccount} + okText="Kobl konto" + cancelText="Annuller" + confirmLoading={linkBankAccount.isPending} + okButtonProps={{ disabled: !selectedLinkedAccount }} + > + + {linkingAccount && ( + } + /> + )} + +
+ Vælg bankkonto (1970-1989) + + Banktransaktioner vil blive importeret til denne konto + + {linkableAccounts.length > 0 ? ( + + + {selectedPreset === 'custom' && ( + handleCustomRangeChange(dates as [Dayjs, Dayjs] | null)} + format="DD-MM-YYYY" + size={size} + allowClear={false} + /> + )} + + {selectedPreset !== 'custom' && ( + + {formatDateRange()} + + )} + + ); +} + +export default PeriodFilter; diff --git a/frontend/src/components/shared/ShortcutTooltip.tsx b/frontend/src/components/shared/ShortcutTooltip.tsx new file mode 100644 index 0000000..116a0ad --- /dev/null +++ b/frontend/src/components/shared/ShortcutTooltip.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { Tooltip, Tag, theme } from 'antd'; +import type { TooltipProps } from 'antd'; +import { formatShortcut, shortcuts } from '@/lib/keyboardShortcuts'; + +const { useToken } = theme; + +interface ShortcutTooltipProps extends Omit { + /** The shortcut ID from keyboardShortcuts registry */ + shortcutId?: string; + /** Or provide the shortcut keys directly (e.g., 'mod+k') */ + shortcutKeys?: string; + /** The label to show before the shortcut */ + label?: string; + /** Children to wrap */ + children: React.ReactNode; +} + +/** + * ShortcutTooltip - Tooltip wrapper that shows keyboard shortcut hints + * + * Usage: + * + * + * + * + * Or with direct keys: + * + * + * + */ +export function ShortcutTooltip({ + shortcutId, + shortcutKeys, + label, + children, + ...tooltipProps +}: ShortcutTooltipProps) { + // Get keys from ID or direct prop + const keys = shortcutId ? shortcuts[shortcutId]?.keys : shortcutKeys; + + // If no shortcut, just render children + if (!keys) { + return <>{children}; + } + + const formattedKeys = formatShortcut(keys); + const shortcutLabel = label || (shortcutId ? shortcuts[shortcutId]?.label : ''); + + const tooltipContent = ( +
+ {shortcutLabel && {shortcutLabel}} +
+ {formattedKeys.split(' ').map((key, i) => ( + + {key} + + ))} +
+
+ ); + + return ( + + {children} + + ); +} + +/** + * Inline keyboard shortcut badge for use in menus, buttons, etc. + */ +interface ShortcutBadgeProps { + /** The shortcut ID from keyboardShortcuts registry */ + shortcutId?: string; + /** Or provide the shortcut keys directly */ + shortcutKeys?: string; + /** Custom style */ + style?: React.CSSProperties; +} + +export function ShortcutBadge({ + shortcutId, + shortcutKeys, + style, +}: ShortcutBadgeProps) { + const { token } = useToken(); + + const keys = shortcutId ? shortcuts[shortcutId]?.keys : shortcutKeys; + + if (!keys) { + return null; + } + + const formattedKeys = formatShortcut(keys); + + return ( + + {formattedKeys.split(' ').map((key, i) => ( + + {key} + + ))} + + ); +} + +export default ShortcutTooltip; diff --git a/frontend/src/components/shared/ShortcutsHelpModal.tsx b/frontend/src/components/shared/ShortcutsHelpModal.tsx new file mode 100644 index 0000000..349a43f --- /dev/null +++ b/frontend/src/components/shared/ShortcutsHelpModal.tsx @@ -0,0 +1,135 @@ +import { Modal, Typography, Tag, theme, Divider } from 'antd'; +import { + getShortcutsGrouped, + categoryNames, + formatShortcut, + type ShortcutCategory, + type ShortcutDefinition, +} from '@/lib/keyboardShortcuts'; + +const { Title, Text } = Typography; +const { useToken } = theme; + +interface ShortcutsHelpModalProps { + open: boolean; + onClose: () => void; +} + +/** + * ShortcutsHelpModal - Display all keyboard shortcuts + * Opened with Cmd+/ + */ +export function ShortcutsHelpModal({ open, onClose }: ShortcutsHelpModalProps) { + const groupedShortcuts = getShortcutsGrouped(); + + // Category order for display + const categoryOrder: ShortcutCategory[] = [ + 'global', + 'navigation', + 'bogforing', + 'faktura', + 'bank', + 'kunder', + 'produkter', + ]; + + return ( + + + Brug disse genveje til at navigere hurtigere i applikationen. + + + {categoryOrder.map((category, index) => { + const shortcuts = groupedShortcuts[category]; + if (!shortcuts || shortcuts.length === 0) return null; + + return ( +
+ {index > 0 && } + + {categoryNames[category]} + +
+ {shortcuts.map((shortcut) => ( + + ))} +
+
+ ); + })} + + + + + Tip: Tryk ⌘K for at abne + kommandopaletten og hurtigt navigere til enhver side. + +
+ ); +} + +interface ShortcutRowProps { + shortcut: ShortcutDefinition; +} + +function ShortcutRow({ shortcut }: ShortcutRowProps) { + const { token } = useToken(); + const formattedKeys = formatShortcut(shortcut.keys); + + return ( +
+
+ {shortcut.label} + {shortcut.description && ( + + {shortcut.description} + + )} +
+
+ {formattedKeys.split(' ').map((key, i) => ( + + {key} + + ))} +
+
+ ); +} + +export default ShortcutsHelpModal; diff --git a/frontend/src/components/shared/SkeletonLoader.tsx b/frontend/src/components/shared/SkeletonLoader.tsx new file mode 100644 index 0000000..9a16c8d --- /dev/null +++ b/frontend/src/components/shared/SkeletonLoader.tsx @@ -0,0 +1,141 @@ +import { Skeleton, Card, Space, Row, Col } from 'antd'; +import { spacing } from '@/styles/designTokens'; + +interface SkeletonLoaderProps { + /** Type of skeleton to show */ + type?: 'page' | 'table' | 'card' | 'form' | 'list'; + /** Number of rows for table/list types */ + rows?: number; + /** Number of cards for card type */ + cards?: number; + /** Whether to show avatar placeholder */ + avatar?: boolean; +} + +/** + * Skeleton loading placeholders for different content types. + * Provides visual feedback while content is loading. + * + * @example + * {isLoading ? : } + */ +export function SkeletonLoader({ + type = 'page', + rows = 5, + cards = 3, + avatar = false, +}: SkeletonLoaderProps) { + switch (type) { + case 'table': + return ( +
+ {/* Table header */} + + {/* Table rows */} + {Array.from({ length: rows }).map((_, i) => ( + + ))} +
+ ); + + case 'card': + return ( + + {Array.from({ length: cards }).map((_, i) => ( +
+ + + + + ))} + + ); + + case 'form': + return ( + + {Array.from({ length: rows }).map((_, i) => ( +
+ + +
+ ))} + +
+ ); + + case 'list': + return ( + + {Array.from({ length: rows }).map((_, i) => ( + + ))} + + ); + + case 'page': + default: + return ( +
+ {/* Page header */} +
+ + +
+ {/* Content cards */} + +
+ + + + + + + + + + + + + + + + + + + + + {/* Main content area */} + + + + + ); + } +} + +/** + * Inline skeleton for text content + */ +export function TextSkeleton({ width = 100 }: { width?: number | string }) { + return ; +} + +/** + * Skeleton for amount/number display + */ +export function AmountSkeleton() { + return ; +} + +export default SkeletonLoader; diff --git a/frontend/src/components/shared/StatisticCard.tsx b/frontend/src/components/shared/StatisticCard.tsx new file mode 100644 index 0000000..087d82d --- /dev/null +++ b/frontend/src/components/shared/StatisticCard.tsx @@ -0,0 +1,143 @@ +import { ReactNode } from 'react'; +import { Card, Statistic, Tag, Space } from 'antd'; +import { RiseOutlined, FallOutlined } from '@ant-design/icons'; +import { formatCurrency } from '@/lib/formatters'; +import { spacing, colSpans } from '@/styles/designTokens'; +import { accountingColors } from '@/styles/theme'; + +export interface StatisticCardProps { + /** Title of the statistic */ + title: string; + /** Numeric value to display */ + value: number; + /** Optional prefix icon */ + icon?: ReactNode; + /** Suffix text (e.g., "kr.") */ + suffix?: string; + /** Number of decimal places */ + precision?: number; + /** Whether to format as currency */ + isCurrency?: boolean; + /** Optional color for the value */ + valueColor?: 'debit' | 'credit' | 'neutral' | 'balance' | 'default'; + /** Optional percentage change to display */ + change?: number; + /** Label for the change tag */ + changeLabel?: string; + /** Additional content below the statistic */ + footer?: ReactNode; + /** Card size */ + size?: 'default' | 'small'; + /** Whether the card is loading */ + loading?: boolean; +} + +/** + * A consistent card component for displaying KPI statistics. + * Combines Card + Statistic + optional change indicator. + * + * @example + * } + * isCurrency + * change={0.12} + * changeLabel="denne måned" + * /> + */ +export function StatisticCard({ + title, + value, + icon, + suffix = 'kr.', + precision = 2, + isCurrency = false, + valueColor, + change, + changeLabel = 'denne måned', + footer, + size = 'small', + loading = false, +}: StatisticCardProps) { + const getValueStyle = () => { + if (!valueColor || valueColor === 'default') return undefined; + return { color: accountingColors[valueColor] }; + }; + + const getChangeColor = () => { + if (change === undefined) return 'default'; + return change >= 0 ? 'green' : 'red'; + }; + + const getChangeIcon = () => { + if (change === undefined) return null; + return change >= 0 ? : ; + }; + + const formatValue = (val: number | string) => { + if (isCurrency) { + return formatCurrency(val as number); + } + return val; + }; + + return ( + + formatValue(v as number) : undefined} + /> + + {(change !== undefined || footer) && ( +
+ {change !== undefined && ( + + {change >= 0 ? '+' : ''} + {(change * 100).toFixed(1)}% {changeLabel} + + )} + {footer &&
{footer}
} +
+ )} +
+ ); +} + +export interface StatisticCardFooterTagsProps { + tags: Array<{ + label: string; + count?: number; + color: string; + icon?: ReactNode; + }>; +} + +/** + * Helper component for rendering multiple tags in the footer + */ +export function StatisticCardFooterTags({ tags }: StatisticCardFooterTagsProps) { + return ( + + {tags.map((tag, index) => ( + + {tag.count !== undefined ? `${tag.count} ` : ''} + {tag.label} + + ))} + + ); +} + +/** + * Responsive column spans for StatisticCard grids + * Use with Ant Design's Col component + */ +export const statisticCardColSpan = colSpans.kpiCard; + +export default StatisticCard; diff --git a/frontend/src/components/shared/StatusBadge.tsx b/frontend/src/components/shared/StatusBadge.tsx new file mode 100644 index 0000000..1f2ccf2 --- /dev/null +++ b/frontend/src/components/shared/StatusBadge.tsx @@ -0,0 +1,207 @@ +import { Tag, Badge, Tooltip } from 'antd'; +import { + CheckCircleOutlined, + ClockCircleOutlined, + CloseCircleOutlined, + ExclamationCircleOutlined, + MinusCircleOutlined, + SyncOutlined, + LockOutlined, +} from '@ant-design/icons'; + +export type StatusType = + | 'active' + | 'inactive' + | 'pending' + | 'error' + | 'success' + | 'warning' + | 'info' + | 'draft' + | 'closed' + | 'locked' + | 'processing'; + +export interface StatusBadgeProps { + /** Status type determines color and icon */ + status: StatusType; + /** Text to display in the badge */ + text?: string; + /** Whether to show the icon */ + showIcon?: boolean; + /** Custom icon override */ + icon?: React.ReactNode; + /** Tooltip text */ + tooltip?: string; + /** Size of the badge */ + size?: 'small' | 'default'; + /** Whether to show as dot only (for compact displays) */ + dot?: boolean; +} + +const statusConfig: Record< + StatusType, + { + color: string; + icon: React.ReactNode; + defaultText: string; + } +> = { + active: { + color: 'green', + icon: , + defaultText: 'Aktiv', + }, + inactive: { + color: 'default', + icon: , + defaultText: 'Inaktiv', + }, + pending: { + color: 'orange', + icon: , + defaultText: 'Afventer', + }, + error: { + color: 'red', + icon: , + defaultText: 'Fejl', + }, + success: { + color: 'green', + icon: , + defaultText: 'Succces', + }, + warning: { + color: 'orange', + icon: , + defaultText: 'Advarsel', + }, + info: { + color: 'blue', + icon: , + defaultText: 'Info', + }, + draft: { + color: 'default', + icon: , + defaultText: 'Kladde', + }, + closed: { + color: 'default', + icon: , + defaultText: 'Lukket', + }, + locked: { + color: 'default', + icon: , + defaultText: 'Låst', + }, + processing: { + color: 'blue', + icon: , + defaultText: 'Behandles', + }, +}; + +/** + * A consistent badge/tag component for displaying status. + * + * @example + * + * + * + */ +export function StatusBadge({ + status, + text, + showIcon = true, + icon, + tooltip, + size = 'default', + dot = false, +}: StatusBadgeProps) { + const config = statusConfig[status]; + + if (dot) { + const badge = ; + + return tooltip ? {badge} : badge; + } + + const tag = ( + + {text || config.defaultText} + + ); + + return tooltip ? {tag} : tag; +} + +/** + * Fiscal year specific status badge + */ +export type FiscalYearStatus = 'active' | 'closed' | 'future'; + +const fiscalYearStatusConfig: Record = { + active: { status: 'active', text: 'Aktiv' }, + closed: { status: 'closed', text: 'Lukket' }, + future: { status: 'pending', text: 'Fremtidig' }, +}; + +export function FiscalYearStatusBadge({ status }: { status: FiscalYearStatus }) { + const config = fiscalYearStatusConfig[status]; + return ; +} + +/** + * Account status badge + */ +export type AccountStatus = 'active' | 'inactive' | 'locked'; + +export function AccountStatusBadge({ status }: { status: AccountStatus }) { + return ; +} + +/** + * Transaction status badge + */ +export type TransactionStatus = 'posted' | 'pending' | 'draft' | 'cancelled'; + +const transactionStatusConfig: Record = { + posted: { status: 'success', text: 'Bogført' }, + pending: { status: 'pending', text: 'Afventer' }, + draft: { status: 'draft', text: 'Kladde' }, + cancelled: { status: 'inactive', text: 'Annulleret' }, +}; + +export function TransactionStatusBadge({ status }: { status: TransactionStatus }) { + const config = transactionStatusConfig[status]; + return ; +} + +/** + * Count badge - shows a count with a status color + */ +export interface CountBadgeProps { + count: number; + label: string; + status?: StatusType; + showZero?: boolean; +} + +export function CountBadge({ count, label, status = 'info', showZero = false }: CountBadgeProps) { + if (count === 0 && !showZero) return null; + + return ( + + {count} {label} + + ); +} + +export default StatusBadge; diff --git a/frontend/src/components/shared/index.ts b/frontend/src/components/shared/index.ts new file mode 100644 index 0000000..b069c5f --- /dev/null +++ b/frontend/src/components/shared/index.ts @@ -0,0 +1,56 @@ +// Shared Components +// Export all reusable components from a single entry point + +export { + StatisticCard, + StatisticCardFooterTags, + statisticCardColSpan, + type StatisticCardProps, + type StatisticCardFooterTagsProps, +} from './StatisticCard'; + +export { + StatusBadge, + FiscalYearStatusBadge, + AccountStatusBadge, + TransactionStatusBadge, + CountBadge, + type StatusType, + type StatusBadgeProps, + type FiscalYearStatus, + type AccountStatus, + type TransactionStatus, + type CountBadgeProps, +} from './StatusBadge'; + +export { + AmountText, + BalanceDisplay, + DoubleEntryAmount, + RunningBalance, + type AmountType, + type AmountTextProps, + type BalanceDisplayProps, + type DoubleEntryAmountProps, + type RunningBalanceProps, +} from './AmountText'; + +export { + EmptyState, + TableEmptyState, + FilterEmptyState, + type EmptyStateVariant, + type EmptyStateProps, +} from './EmptyState'; + +export { + ISODatePicker, + ISODateRangePicker, + type ISODatePickerProps, + type ISODateRangePickerProps, +} from './ISODatePicker'; + +export { + DemoDataDisclaimer, + type DemoDataDisclaimerProps, +} from './DemoDataDisclaimer'; diff --git a/frontend/src/hooks/useAutoSave.ts b/frontend/src/hooks/useAutoSave.ts new file mode 100644 index 0000000..b5c6acf --- /dev/null +++ b/frontend/src/hooks/useAutoSave.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; + +interface UseAutoSaveOptions { + /** Data to auto-save */ + data: T; + /** Function to save the data */ + onSave: (data: T) => Promise; + /** Debounce delay in milliseconds (default: 2000) */ + debounceMs?: number; + /** Whether auto-save is enabled (default: true) */ + enabled?: boolean; + /** Callback when save starts */ + onSaveStart?: () => void; + /** Callback when save succeeds */ + onSaveSuccess?: () => void; + /** Callback when save fails */ + onSaveError?: (error: Error) => void; +} + +interface UseAutoSaveReturn { + /** Whether there are unsaved changes */ + isDirty: boolean; + /** Whether a save is in progress */ + isSaving: boolean; + /** Last error from save attempt */ + error: Error | null; + /** Manually trigger a save */ + saveNow: () => Promise; + /** Mark as not dirty (e.g., after manual save) */ + markClean: () => void; +} + +/** + * Hook for auto-saving data with debounce. + * Tracks dirty state and provides manual save capability. + */ +export function useAutoSave({ + data, + onSave, + debounceMs = 2000, + enabled = true, + onSaveStart, + onSaveSuccess, + onSaveError, +}: UseAutoSaveOptions): UseAutoSaveReturn { + const [isDirty, setIsDirty] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + // Keep a ref to the latest data for saving + const dataRef = useRef(data); + const initialDataRef = useRef(null); + + // Store initial data on first render + useEffect(() => { + if (initialDataRef.current === null) { + initialDataRef.current = JSON.stringify(data); + } + }, []); + + // Update the data ref when data changes + useEffect(() => { + dataRef.current = data; + }, [data]); + + // Perform the actual save + const performSave = useCallback(async () => { + if (!enabled || isSaving) return; + + setIsSaving(true); + setError(null); + onSaveStart?.(); + + try { + await onSave(dataRef.current); + setIsDirty(false); + onSaveSuccess?.(); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + setError(error); + onSaveError?.(error); + } finally { + setIsSaving(false); + } + }, [enabled, isSaving, onSave, onSaveStart, onSaveSuccess, onSaveError]); + + // Debounced save function + const debouncedSave = useDebouncedCallback(performSave, debounceMs); + + // Track changes and trigger debounced save + useEffect(() => { + if (!enabled || initialDataRef.current === null) return; + + const currentData = JSON.stringify(data); + const hasChanged = currentData !== initialDataRef.current; + + if (hasChanged && !isDirty) { + setIsDirty(true); + } + + if (hasChanged) { + debouncedSave(); + } + }, [data, enabled, isDirty, debouncedSave]); + + // Manual save function + const saveNow = useCallback(async () => { + debouncedSave.cancel(); + await performSave(); + }, [debouncedSave, performSave]); + + // Mark as clean (e.g., after successful post) + const markClean = useCallback(() => { + setIsDirty(false); + initialDataRef.current = JSON.stringify(dataRef.current); + }, []); + + return { + isDirty, + isSaving, + error, + saveNow, + markClean, + }; +} diff --git a/frontend/src/hooks/usePageHotkeys.ts b/frontend/src/hooks/usePageHotkeys.ts new file mode 100644 index 0000000..b538608 --- /dev/null +++ b/frontend/src/hooks/usePageHotkeys.ts @@ -0,0 +1,130 @@ +import { useEffect } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { useHotkeyStore, useHotkeysEnabled } from '@/stores/hotkeyStore'; +import { shortcuts } from '@/lib/keyboardShortcuts'; + +interface HotkeyAction { + /** Shortcut ID from keyboardShortcuts registry */ + shortcutId: string; + /** Action to execute when shortcut is triggered */ + action: () => void; + /** Optional condition - if false, shortcut is disabled */ + enabled?: boolean; +} + +interface UsePageHotkeysOptions { + /** Enable shortcuts only when this element is focused */ + scope?: string; + /** Disable shortcuts in form elements */ + enableOnFormTags?: boolean; +} + +/** + * usePageHotkeys - Hook for registering page-specific keyboard shortcuts + * + * Usage: + * usePageHotkeys([ + * { shortcutId: 'newInvoice', action: () => setModalOpen(true) }, + * { shortcutId: 'saveDraft', action: handleSave, enabled: isDirty }, + * ]); + */ +export function usePageHotkeys( + actions: HotkeyAction[], + options: UsePageHotkeysOptions = {} +) { + const hotkeysEnabled = useHotkeysEnabled(); + const { commandPaletteOpen, shortcutsHelpOpen, setActiveContext } = useHotkeyStore(); + + const { enableOnFormTags = false } = options; + + // Set active context when component mounts + useEffect(() => { + setActiveContext('page'); + return () => setActiveContext('global'); + }, [setActiveContext]); + + // Register each hotkey + actions.forEach(({ shortcutId, action, enabled = true }) => { + const shortcut = shortcuts[shortcutId]; + + // eslint-disable-next-line react-hooks/rules-of-hooks + useHotkeys( + shortcut?.keys || '', + (e) => { + e.preventDefault(); + if ( + hotkeysEnabled && + enabled && + !commandPaletteOpen && + !shortcutsHelpOpen && + shortcut + ) { + action(); + } + }, + { + enableOnFormTags, + enableOnContentEditable: false, + enabled: hotkeysEnabled && enabled && !commandPaletteOpen && !shortcutsHelpOpen, + }, + [hotkeysEnabled, enabled, commandPaletteOpen, shortcutsHelpOpen, action] + ); + }); +} + +/** + * useSingleHotkey - Hook for a single keyboard shortcut + * + * Usage: + * useSingleHotkey('newInvoice', () => setModalOpen(true)); + */ +export function useSingleHotkey( + shortcutId: string, + action: () => void, + enabled: boolean = true +) { + usePageHotkeys([{ shortcutId, action, enabled }]); +} + +/** + * useFormHotkeys - Hook for form-specific shortcuts (like Cmd+S to save) + * Enables shortcuts even when form elements are focused + */ +export function useFormHotkeys(actions: HotkeyAction[]) { + const hotkeysEnabled = useHotkeysEnabled(); + const { commandPaletteOpen, shortcutsHelpOpen, setActiveContext } = useHotkeyStore(); + + useEffect(() => { + setActiveContext('form'); + return () => setActiveContext('global'); + }, [setActiveContext]); + + actions.forEach(({ shortcutId, action, enabled = true }) => { + const shortcut = shortcuts[shortcutId]; + + // eslint-disable-next-line react-hooks/rules-of-hooks + useHotkeys( + shortcut?.keys || '', + (e) => { + e.preventDefault(); + if ( + hotkeysEnabled && + enabled && + !commandPaletteOpen && + !shortcutsHelpOpen && + shortcut + ) { + action(); + } + }, + { + enableOnFormTags: true, // Enable in forms + enableOnContentEditable: true, + enabled: hotkeysEnabled && enabled && !commandPaletteOpen && !shortcutsHelpOpen, + }, + [hotkeysEnabled, enabled, commandPaletteOpen, shortcutsHelpOpen, action] + ); + }); +} + +export default usePageHotkeys; diff --git a/frontend/src/hooks/usePeriod.ts b/frontend/src/hooks/usePeriod.ts index efb2913..cbfe816 100644 --- a/frontend/src/hooks/usePeriod.ts +++ b/frontend/src/hooks/usePeriod.ts @@ -10,7 +10,7 @@ import { canPostToDate, validatePeriodClose, } from '@/lib/periods'; -import type { AccountingPeriod, FiscalYear, PeriodStatus } from '@/types/periods'; +import type { AccountingPeriod, PeriodStatus } from '@/types/periods'; import type { Transaction } from '@/types/accounting'; /** @@ -206,7 +206,6 @@ export function usePeriodManagement() { closePeriod, reopenPeriod, lockPeriod, - updatePeriod, } = usePeriodStore(); const canClosePeriod = useCallback( diff --git a/frontend/src/hooks/useResponsiveModal.ts b/frontend/src/hooks/useResponsiveModal.ts new file mode 100644 index 0000000..d57d081 --- /dev/null +++ b/frontend/src/hooks/useResponsiveModal.ts @@ -0,0 +1,93 @@ +import { Grid } from 'antd'; +import type { ModalProps } from 'antd'; + +const { useBreakpoint } = Grid; + +export type ModalSize = 'small' | 'medium' | 'large' | 'xlarge'; + +const SIZE_MAP = { + small: { desktop: 400, tablet: 400, mobile: '100%' }, + medium: { desktop: 520, tablet: 520, mobile: '100%' }, + large: { desktop: 640, tablet: 600, mobile: '100%' }, + xlarge: { desktop: 800, tablet: 700, mobile: '100%' }, +} as const; + +interface ResponsiveModalOptions { + size?: ModalSize; + fullScreenOnMobile?: boolean; +} + +/** + * Hook that returns responsive modal props based on screen size. + * On mobile, modals take full width. On tablet, they use a slightly smaller width. + * + * @example + * const modalProps = useResponsiveModal({ size: 'medium' }); + * return ... + */ +export function useResponsiveModal(options: ResponsiveModalOptions = {}): Partial { + const { size = 'medium', fullScreenOnMobile = true } = options; + const screens = useBreakpoint(); + + const isMobile = !screens.sm; // < 576px + const isTablet = screens.sm && !screens.md; // 576px - 768px + + const sizeConfig = SIZE_MAP[size]; + + if (isMobile && fullScreenOnMobile) { + return { + width: sizeConfig.mobile, + styles: { + content: { + maxWidth: '100vw', + margin: 0, + top: 0, + paddingBottom: 0, + }, + body: { + maxHeight: 'calc(100vh - 110px)', + overflow: 'auto', + }, + }, + centered: false, + style: { + top: 0, + padding: 0, + maxWidth: '100%', + }, + }; + } + + if (isTablet) { + return { + width: sizeConfig.tablet, + styles: { + content: { + maxWidth: '90vw', + }, + body: { + maxHeight: 'calc(100vh - 200px)', + overflow: 'auto', + }, + }, + centered: true, + }; + } + + // Desktop + return { + width: sizeConfig.desktop, + styles: { + content: { + maxWidth: '90vw', + }, + body: { + maxHeight: 'calc(100vh - 200px)', + overflow: 'auto', + }, + }, + centered: true, + }; +} + +export default useResponsiveModal; diff --git a/frontend/src/lib/errorHandling.ts b/frontend/src/lib/errorHandling.ts new file mode 100644 index 0000000..3ca8194 --- /dev/null +++ b/frontend/src/lib/errorHandling.ts @@ -0,0 +1,225 @@ +import { notification, message } from 'antd'; +import type { ArgsProps as NotificationArgsProps } from 'antd/es/notification'; + +/** + * Common backend error messages mapped to Danish user-friendly messages + */ +const ERROR_TRANSLATIONS: Record = { + // CVR validation + 'CVR is required for business customers': 'CVR er påkrævet for erhvervskunder', + 'CVR is required for VAT-registered companies': 'CVR er påkrævet for momsregistrerede virksomheder', + 'Invalid CVR format': 'Ugyldigt CVR-format (skal være 8 cifre)', + + // Customer errors + 'Customer not found': 'Kunde ikke fundet', + 'Customer is deactivated': 'Kunden er deaktiveret', + 'Customer already exists': 'Kunden findes allerede', + + // Invoice errors + 'Invoice not found': 'Faktura ikke fundet', + 'Invoice must have at least one line': 'Fakturaen skal have mindst én linje', + 'Invoice has already been sent': 'Fakturaen er allerede sendt', + 'Invoice has already been voided': 'Fakturaen er allerede annulleret', + 'Cannot void a paid invoice': 'Kan ikke annullere en betalt faktura', + 'Payment amount exceeds remaining balance': 'Betalingsbeløbet overstiger restbeløbet', + + // Credit note errors + 'Credit note not found': 'Kreditnota ikke fundet', + 'Credit note must have at least one line': 'Kreditnotaen skal have mindst én linje', + 'Credit note has already been issued': 'Kreditnotaen er allerede udstedt', + 'Credit note has already been voided': 'Kreditnotaen er allerede annulleret', + 'Cannot void an applied credit note': 'Kan ikke annullere en anvendt kreditnota', + 'Application amount exceeds remaining credit': 'Beløbet overstiger resterende kredit', + + // Company errors + 'Company not found': 'Virksomhed ikke fundet', + 'Company name is required': 'Virksomhedsnavn er påkrævet', + 'Bank account number is required when registration number is provided': 'Kontonummer er påkrævet når reg.nr er udfyldt', + + // Fiscal year errors + 'Fiscal year not found': 'Regnskabsår ikke fundet', + 'Fiscal year is closed': 'Regnskabsåret er lukket', + 'Fiscal year is locked': 'Regnskabsåret er låst', + 'Cannot post to a closed fiscal year': 'Kan ikke bogføre på et lukket regnskabsår', + 'Cannot post to a locked fiscal year': 'Kan ikke bogføre på et låst regnskabsår', + 'Fiscal year duration must be between 6 and 18 months': 'Regnskabsårets varighed skal være mellem 6 og 18 måneder', + 'Fiscal year must be exactly 12 months': 'Regnskabsåret skal være præcis 12 måneder', + + // Account errors + 'Account not found': 'Konto ikke fundet', + 'Account number already exists': 'Kontonummeret findes allerede', + 'Cannot modify system account': 'Kan ikke ændre systemkonto', + 'Cannot delete account with transactions': 'Kan ikke slette konto med posteringer', + + // Journal entry errors + 'Journal entry is not balanced': 'Bilag balancerer ikke (debet skal være lig kredit)', + 'Voucher number already exists': 'Bilagsnummeret findes allerede', + 'Invalid VAT code': 'Ugyldig momskode', + + // Bank connection errors + 'Bank connection not found': 'Bankforbindelse ikke fundet', + 'Bank connection is not active': 'Bankforbindelsen er ikke aktiv', + 'Bank authorization expired': 'Bankautorisation er udløbet', + 'Bank synchronization failed': 'Banksynkronisering fejlede', + 'Cannot re-initiate an active connection': 'Kan ikke genoptage en aktiv forbindelse', + 'No bank accounts found in the connection': 'Ingen bankkonti fundet i forbindelsen', + 'Bank connection must be initiated before establishing': 'Bankforbindelse skal påbegyndes først', + 'Cannot disconnect a connection that was never initiated': 'Kan ikke afbryde en forbindelse der aldrig blev påbegyndt', + 'Cannot archive an active connection': 'Kan ikke arkivere en aktiv forbindelse', + 'Session ID is required': 'Session ID er påkrævet', + 'Authorization ID is required': 'Autorisations ID er påkrævet', + + // General errors + 'Unauthorized': 'Du har ikke adgang til denne handling', + 'Forbidden': 'Adgang nægtet', + 'Not found': 'Ikke fundet', + 'Internal server error': 'Der opstod en serverfejl', + 'Network error': 'Netværksfejl - tjek din internetforbindelse', + 'Request timeout': 'Anmodningen tog for lang tid', + 'Validation failed': 'Validering fejlede', + + // Additional customer errors + 'Customer name is required': 'Kundenavn er påkrævet', + 'Email format is invalid': 'Ugyldig email-format', + 'Phone number is invalid': 'Ugyldigt telefonnummer', + + // Additional company errors + 'Company CVR already exists': 'CVR-nummeret er allerede registreret', + 'Invalid fiscal year start month': 'Ugyldig startmåned for regnskabsår', + + // Attachment errors + 'Attachment not found': 'Bilag ikke fundet', + 'Attachment size exceeds limit': 'Bilag overstiger maksimal filstørrelse', + 'Invalid file type': 'Ugyldig filtype', + 'Maximum attachments exceeded': 'Maksimalt antal bilag overskredet', + + // Draft errors + 'Draft not found': 'Kladde ikke fundet', + 'Draft has already been posted': 'Kladden er allerede bogført', + 'Draft has already been discarded': 'Kladden er allerede kasseret', + 'Cannot modify posted draft': 'Kan ikke ændre en bogført kladde', + + // User access errors + 'User not found': 'Bruger ikke fundet', + 'User already has access': 'Brugeren har allerede adgang', + 'Cannot remove own access': 'Kan ikke fjerne egen adgang', + 'Invalid role': 'Ugyldig rolle', +}; + +/** + * Extract a clean error message from various error types + */ +function extractErrorMessage(error: unknown): string { + if (typeof error === 'string') { + return error; + } + + if (error instanceof Error) { + // Remove "GraphQL request failed: " prefix if present + let message = error.message; + if (message.startsWith('GraphQL request failed: ')) { + message = message.replace('GraphQL request failed: ', ''); + } + + // Try to extract the actual error message from GraphQL response + const graphqlMatch = message.match(/:\s*(.+?)(?:\n|$)/); + if (graphqlMatch) { + return graphqlMatch[1].trim(); + } + + return message; + } + + return 'Der opstod en uventet fejl'; +} + +/** + * Translate backend error message to Danish + */ +function translateError(message: string): string { + // Check for exact match first + if (ERROR_TRANSLATIONS[message]) { + return ERROR_TRANSLATIONS[message]; + } + + // Check for partial matches (case-insensitive) + const lowerMessage = message.toLowerCase(); + for (const [key, translation] of Object.entries(ERROR_TRANSLATIONS)) { + if (lowerMessage.includes(key.toLowerCase())) { + return translation; + } + } + + // Return original if no translation found + return message; +} + +/** + * Show an error notification (persistent, for API errors) + * Uses notification.error for better visibility and user experience + */ +export function showError( + error: unknown, + title?: string, + options?: Partial +): void { + const rawMessage = extractErrorMessage(error); + const translatedMessage = translateError(rawMessage); + + notification.error({ + message: title || 'Fejl', + description: translatedMessage, + duration: 6, // 6 seconds - longer for errors so users can read + placement: 'topRight', + ...options, + }); +} + +/** + * Show a success message (brief toast) + */ +export function showSuccess(content: string, duration: number = 3): void { + message.success(content, duration); +} + +/** + * Show a warning message + */ +export function showWarning(content: string, duration: number = 4): void { + message.warning(content, duration); +} + +/** + * Show an info message + */ +export function showInfo(content: string, duration: number = 3): void { + message.info(content, duration); +} + +/** + * Show an error notification with a specific title for form validation failures + */ +export function showValidationError(fieldErrors?: string[]): void { + const description = fieldErrors?.length + ? fieldErrors.join('\n') + : 'Udfyld venligst alle påkrævede felter korrekt'; + + notification.error({ + message: 'Validering fejlede', + description, + duration: 5, + placement: 'topRight', + }); +} + +/** + * Show a network error notification + */ +export function showNetworkError(): void { + notification.error({ + message: 'Netværksfejl', + description: 'Kunne ikke forbinde til serveren. Tjek din internetforbindelse og prøv igen.', + duration: 8, + placement: 'topRight', + }); +} diff --git a/frontend/src/lib/periods.ts b/frontend/src/lib/periods.ts index b619fa3..65bfc88 100644 --- a/frontend/src/lib/periods.ts +++ b/frontend/src/lib/periods.ts @@ -8,8 +8,6 @@ import type { PeriodFrequency, PeriodStatus, PeriodValidationResult, - DANISH_MONTHS, - DANISH_MONTHS_SHORT, } from '@/types/periods'; import type { VATPeriodicitet } from '@/types/periods'; import type { Transaction } from '@/types/accounting'; diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx new file mode 100644 index 0000000..b6db33b --- /dev/null +++ b/frontend/src/pages/Admin.tsx @@ -0,0 +1,243 @@ +import { useState } from 'react'; +import { + Typography, + Card, + Form, + Input, + Select, + Button, + Alert, + Space, + Result, + Spin, + Divider, +} from 'antd'; +import { showSuccess, showError } from '@/lib/errorHandling'; +import { + ToolOutlined, + ReloadOutlined, + LockOutlined, + DashboardOutlined, +} from '@ant-design/icons'; +import { useUser } from '@/stores/authStore'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { graphqlClient } from '@/api/client'; +import { gql } from 'graphql-request'; + +const { Title, Text, Paragraph } = Typography; + +// Admin email that has access +const ADMIN_EMAIL = 'nhh@softwarehuset.com'; + +// Derive backend base URL from GraphQL endpoint +const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql'; +const BACKEND_BASE_URL = GRAPHQL_ENDPOINT.replace('/graphql', ''); + +// GraphQL queries and mutations +const LIST_READ_MODEL_TYPES = gql` + mutation ListReadModelTypes { + listReadModelTypes + } +`; + +const REPOPULATE_READ_MODEL = gql` + mutation RepopulateReadModel($aggregateId: String!, $readModelType: String!) { + repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType) + } +`; + +interface ListReadModelTypesResponse { + listReadModelTypes: string[]; +} + +interface RepopulateReadModelResponse { + repopulateReadModel: boolean; +} + +export default function Admin() { + const user = useUser(); + const [form] = Form.useForm(); + const [lastResult, setLastResult] = useState<{ success: boolean; message: string } | null>(null); + + // Check if user is admin + const isAdmin = user?.email?.toLowerCase() === ADMIN_EMAIL.toLowerCase(); + + // Fetch available read model types + const { data: readModelTypes, isLoading: typesLoading } = useQuery({ + queryKey: ['readModelTypes'], + queryFn: async () => { + const response = await graphqlClient.request(LIST_READ_MODEL_TYPES); + return response.listReadModelTypes; + }, + enabled: isAdmin, + }); + + // Repopulate mutation + const repopulateMutation = useMutation({ + mutationFn: async (variables: { aggregateId: string; readModelType: string }) => { + return graphqlClient.request(REPOPULATE_READ_MODEL, variables); + }, + onSuccess: (data, variables) => { + if (data.repopulateReadModel) { + setLastResult({ + success: true, + message: `Read model "${variables.readModelType}" for aggregate "${variables.aggregateId}" er blevet genopbygget.`, + }); + showSuccess('Read model genopbygget!'); + form.resetFields(); + } else { + setLastResult({ + success: false, + message: 'Genopbygning returnerede false - check logs for detaljer.', + }); + } + }, + onError: (error: Error) => { + setLastResult({ + success: false, + message: `Fejl: ${error.message}`, + }); + showError('Genopbygning fejlede'); + }, + }); + + const handleRepopulate = async () => { + try { + const values = await form.validateFields(); + setLastResult(null); + repopulateMutation.mutate(values); + } catch { + // Validation error - handled by form + } + }; + + // Show access denied if not admin + if (!isAdmin) { + return ( + } + title="Adgang nægtet" + subTitle="Du har ikke adgang til admin-området. Kun autoriserede administratorer kan tilgå denne side." + extra={ + + Logget ind som: {user?.email || 'Ukendt'} + + } + /> + ); + } + + return ( +
+ {/* Header */} +
+ + <ToolOutlined /> Administration + + Systemværktøjer til fejlfinding og vedligeholdelse +
+ + + + {/* Read Model Repair */} + + + Hvis en read model er ude af sync med event store, kan du genopbygge den ved at afspille alle events for et specifikt aggregat. + Dette er nyttigt når data vises forkert, eller handlinger fejler på grund af inkonsistent tilstand. + + + + + {typesLoading ? ( + + ) : ( + + + + + + + + + + + )} + + {lastResult && ( + + )} + + + + Tilgængelige Read Model Typer + + {readModelTypes?.join(', ') || 'Indlæser...'} + + + + {/* Background Jobs Dashboard */} + + + Hangfire dashboard giver overblik over baggrundsjobs, køer, og fejlede opgaver i systemet. + + + +
+ ); +} diff --git a/frontend/src/pages/CompanySetupWizard.tsx b/frontend/src/pages/CompanySetupWizard.tsx new file mode 100644 index 0000000..4eaa750 --- /dev/null +++ b/frontend/src/pages/CompanySetupWizard.tsx @@ -0,0 +1,501 @@ +import { useState } from 'react'; +import { + Card, + Steps, + Form, + Input, + Select, + Button, + Space, + Typography, + Result, + Divider, + Alert, + Row, + Col, +} from 'antd'; +import { showError } from '@/lib/errorHandling'; +import { + ShopOutlined, + BankOutlined, + CheckCircleOutlined, + ArrowLeftOutlined, + ArrowRightOutlined, + RocketOutlined, +} from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import { useCreateCompany } from '@/api/mutations/companyMutations'; +import { useCompanyStore } from '@/stores/companyStore'; +import { useMyCompanies } from '@/api/queries/companyQueries'; +import { colors } from '@/styles/designTokens'; +import { validateCVRModulus11 } from '@/lib/formatters'; + +const { Title, Text, Paragraph } = Typography; + +interface CompanyFormValues { + name: string; + cvr?: string; + country: string; + currency: string; + fiscalYearStartMonth: number; + vatRegistered: boolean; + vatPeriodFrequency?: 'MONTHLY' | 'QUARTERLY' | 'HALFYEARLY'; +} + +const vatPeriodOptions = [ + { value: 'MONTHLY', label: 'Månedlig' }, + { value: 'QUARTERLY', label: 'Kvartalsvis' }, + { value: 'HALFYEARLY', label: 'Halvårlig' }, +]; + +const monthOptions = [ + { value: 1, label: 'Januar' }, + { value: 2, label: 'Februar' }, + { value: 3, label: 'Marts' }, + { value: 4, label: 'April' }, + { value: 5, label: 'Maj' }, + { value: 6, label: 'Juni' }, + { value: 7, label: 'Juli' }, + { value: 8, label: 'August' }, + { value: 9, label: 'September' }, + { value: 10, label: 'Oktober' }, + { value: 11, label: 'November' }, + { value: 12, label: 'December' }, +]; + +export default function CompanySetupWizard() { + const [currentStep, setCurrentStep] = useState(0); + const [form] = Form.useForm(); + const navigate = useNavigate(); + const createCompany = useCreateCompany(); + const { setActiveCompany } = useCompanyStore(); + const { refetch: refetchCompanies } = useMyCompanies(); + const [createdCompanyName, setCreatedCompanyName] = useState(''); + + // Store form values in state for reliable confirmation display + const [formSnapshot, setFormSnapshot] = useState>({}); + + // Watch vatRegistered for conditional rendering in step 2 (while on that step) + const vatRegistered = Form.useWatch('vatRegistered', form); + + const handleNext = async () => { + try { + if (currentStep === 0) { + await form.validateFields(['name', 'cvr']); + } else if (currentStep === 1) { + await form.validateFields(['country', 'currency', 'fiscalYearStartMonth']); + } else if (currentStep === 2) { + await form.validateFields(['vatRegistered', 'vatPeriodFrequency']); + // Backend requires CVR for VAT-registered companies + const currentValues = form.getFieldsValue(); + if (currentValues.vatRegistered && !currentValues.cvr?.trim()) { + showError('CVR er påkrævet for momsregistrerede virksomheder'); + setCurrentStep(0); // Navigate back to step 0 where CVR is entered + return; + } + } + // Capture current form values before moving to next step + const currentValues = form.getFieldsValue(); + setFormSnapshot(prev => ({ ...prev, ...currentValues })); + setCurrentStep(currentStep + 1); + } catch { + // Validation failed, don't proceed + } + }; + + const handleBack = () => { + setCurrentStep(currentStep - 1); + }; + + const handleSubmit = async () => { + try { + // Use formSnapshot which was captured during navigation + const values = formSnapshot; + + // Defensive validation + if (!values.name || values.name.trim() === '') { + showError('Virksomhedsnavn er påkrævet'); + setCurrentStep(0); + return; + } + + // Debug logging + console.log('Creating company with values:', values); + + const company = await createCompany.mutateAsync({ + name: values.name.trim(), + cvr: values.cvr?.trim() || undefined, + country: values.country || 'DK', + currency: values.currency || 'DKK', + fiscalYearStartMonth: values.fiscalYearStartMonth ?? 1, + vatRegistered: values.vatRegistered ?? false, + vatPeriodFrequency: values.vatRegistered ? values.vatPeriodFrequency : undefined, + }); + + setCreatedCompanyName(values.name); + + // Refetch companies to get the new one with role + const { data: companies } = await refetchCompanies(); + const newCompany = companies?.find((c) => c.id === company.id); + if (newCompany) { + setActiveCompany(newCompany); + } + + setCurrentStep(4); // Success step + } catch (error) { + console.error('Company creation failed:', error); + showError(error, 'Kunne ikke oprette virksomhed'); + } + }; + + const handleGoToDashboard = () => { + navigate('/'); + }; + + const steps = [ + { + title: 'Virksomhed', + icon: , + }, + { + title: 'Regnskab', + icon: , + }, + { + title: 'Moms', + icon: , + }, + { + title: 'Bekræft', + icon: , + }, + ]; + + const renderStepContent = () => { + switch (currentStep) { + case 0: + return ( + +
+ Fortæl os om din virksomhed + + Vi bruger disse oplysninger til at oprette din kontoplan og konfigurere systemet. + +
+ + + } + /> + + + { + if (!value || value.length !== 8) return Promise.resolve(); + if (!validateCVRModulus11(value)) { + return Promise.reject('Ugyldigt CVR-nummer (modulus 11 check fejlet)'); + } + return Promise.resolve(); + }, + }, + ]} + > + + +
+ ); + + case 1: + return ( + +
+ Regnskabsindstillinger + + Konfigurer dine grundlæggende regnskabsindstillinger. + +
+ + +
+ + + + + + + + + + + + + + Ja, virksomheden er momsregistreret + Nej, ikke momsregistreret + + + + {vatRegistered && ( + + ({ + value: fy.id, + label: ( + + {fy.name} + {fy.status === 'locked' && ( + Låst + )} + {fy.status === 'closed' && ( + Afsluttet + )} + + ), + }))} + style={{ width: '100%' }} + /> + + + + + +
+ + Filen inkluderer: Kontoplan, kunder, leverandører og alle posteringer for perioden. + +
+ + + + {/* CSV Export Card (Coming Soon) */} + + + + CSV Eksport + + } + extra={Kommer snart} + > + + Eksporter kontoplan, posteringer eller kunder som CSV-filer til brug i regneark. + + + + + + + {/* PDF Report Card (Coming Soon) */} + + + + Årsrapport (PDF) + + } + extra={Kommer snart} + > + + Generér årsrapport i PDF format med resultatopgørelse og balance. + + + + + + + {/* Backup Card (Coming Soon) */} + + + + Fuld backup + + } + extra={Kommer snart} + > + + Download en komplet backup af alle virksomhedens data i JSON format. + + + + + + + + ); +} diff --git a/frontend/src/pages/Fakturaer.tsx b/frontend/src/pages/Fakturaer.tsx new file mode 100644 index 0000000..fa136e5 --- /dev/null +++ b/frontend/src/pages/Fakturaer.tsx @@ -0,0 +1,1023 @@ +import { useState, useMemo, useEffect } from 'react'; +import { + Typography, + Button, + Card, + Table, + Space, + Tag, + Modal, + Form, + Input, + Select, + InputNumber, + Spin, + Alert, + Drawer, + Descriptions, + Popconfirm, + Row, + Col, + Statistic, + DatePicker, + Divider, + List, +} from 'antd'; +import { showSuccess, showError, showWarning } from '@/lib/errorHandling'; +import { + PlusOutlined, + EditOutlined, + SearchOutlined, + EyeOutlined, + SendOutlined, + StopOutlined, + DeleteOutlined, + DollarOutlined, + FileTextOutlined, +} from '@ant-design/icons'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import dayjs from 'dayjs'; +import { useCompany } from '@/hooks/useCompany'; +import { useCurrentFiscalYear } from '@/stores/periodStore'; +import { useInvoices, type Invoice, type InvoiceLine, type InvoiceStatus } from '@/api/queries/invoiceQueries'; +import { useActiveCustomers, type Customer } from '@/api/queries/customerQueries'; +import { useActiveProducts, type Product } from '@/api/queries/productQueries'; +import { + useCreateInvoice, + useAddInvoiceLine, + useUpdateInvoiceLine, + useRemoveInvoiceLine, + useSendInvoice, + useVoidInvoice, + useReceivePayment, + type CreateInvoiceInput, + type AddInvoiceLineInput, + type ReceivePaymentInput, + type VoidInvoiceInput, +} from '@/api/mutations/invoiceMutations'; +import { formatCurrency, formatDate } from '@/lib/formatters'; +import { spacing } from '@/styles/designTokens'; +import { accountingColors } from '@/styles/theme'; +import { AmountText } from '@/components/shared/AmountText'; +import { EmptyState } from '@/components/shared/EmptyState'; +import type { ColumnsType } from 'antd/es/table'; + +const { Title, Text } = Typography; + +const statusColors: Record = { + draft: 'default', + sent: 'processing', + issued: 'processing', + partially_paid: 'warning', + partially_applied: 'warning', + paid: 'success', + fully_applied: 'success', + voided: 'error', +}; + +const statusLabels: Record = { + draft: 'Kladde', + sent: 'Sendt', + issued: 'Udstedt', + partially_paid: 'Delvist betalt', + partially_applied: 'Delvist anvendt', + paid: 'Betalt', + fully_applied: 'Fuldt anvendt', + voided: 'Annulleret', +}; + +export default function Fakturaer() { + const navigate = useNavigate(); + const { company } = useCompany(); + const currentFiscalYear = useCurrentFiscalYear(); + const [searchParams, setSearchParams] = useSearchParams(); + const customerIdFilter = searchParams.get('customer'); + const shouldCreateNew = searchParams.get('create') === 'true'; + const preselectedCustomerId = searchParams.get('customerId'); + + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isLineModalOpen, setIsLineModalOpen] = useState(false); + const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false); + const [isVoidModalOpen, setIsVoidModalOpen] = useState(false); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [selectedInvoice, setSelectedInvoice] = useState(null); + const [editingLine, setEditingLine] = useState(null); + const [searchText, setSearchText] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [createForm] = Form.useForm(); + const [lineForm] = Form.useForm(); + const [paymentForm] = Form.useForm(); + const [voidForm] = Form.useForm(); + + // Fetch invoices + const { + data: invoices = [], + isLoading: loading, + error, + refetch, + } = useInvoices(company?.id); + + // Fetch customers for dropdown + const { data: customers = [] } = useActiveCustomers(company?.id); + + // Fetch products for line form + const { data: products = [] } = useActiveProducts(company?.id); + + // Mutations + const createInvoiceMutation = useCreateInvoice(); + const addInvoiceLineMutation = useAddInvoiceLine(); + const updateInvoiceLineMutation = useUpdateInvoiceLine(); + const removeInvoiceLineMutation = useRemoveInvoiceLine(); + const sendInvoiceMutation = useSendInvoice(); + const receivePaymentMutation = useReceivePayment(); + const voidInvoiceMutation = useVoidInvoice(); + + // Auto-open create modal when coming from customer card + useEffect(() => { + if (shouldCreateNew && preselectedCustomerId && customers.length > 0) { + createForm.resetFields(); + createForm.setFieldsValue({ + customerId: preselectedCustomerId, + invoiceDate: dayjs(), + }); + setIsCreateModalOpen(true); + // Clear URL params to prevent re-opening on refresh + setSearchParams({}, { replace: true }); + } + }, [shouldCreateNew, preselectedCustomerId, customers, createForm, setSearchParams]); + + // Filter invoices + const filteredInvoices = useMemo(() => { + return invoices.filter((invoice) => { + const matchesSearch = + searchText === '' || + invoice.invoiceNumber.toLowerCase().includes(searchText.toLowerCase()) || + invoice.customerName.toLowerCase().includes(searchText.toLowerCase()); + + const matchesStatus = statusFilter === 'all' || invoice.status === statusFilter; + + const matchesCustomer = !customerIdFilter || invoice.customerId === customerIdFilter; + + return matchesSearch && matchesStatus && matchesCustomer; + }); + }, [invoices, searchText, statusFilter, customerIdFilter]); + + // Statistics + const stats = useMemo(() => { + const total = invoices.length; + const draft = invoices.filter((i) => i.status === 'draft').length; + const outstanding = invoices + .filter((i) => ['sent', 'partially_paid'].includes(i.status)) + .reduce((sum, i) => sum + i.amountRemaining, 0); + const totalValue = invoices + .filter((i) => i.status !== 'voided') + .reduce((sum, i) => sum + i.amountTotal, 0); + return { total, draft, outstanding, totalValue }; + }, [invoices]); + + const handleCreateInvoice = () => { + createForm.resetFields(); + createForm.setFieldsValue({ + invoiceDate: dayjs(), + }); + setIsCreateModalOpen(true); + }; + + const handleSubmitCreate = async () => { + if (!company || !currentFiscalYear) { + showError('Virksomhed eller regnskabsår ikke valgt'); + return; + } + try { + const values = await createForm.validateFields(); + const input: CreateInvoiceInput = { + companyId: company.id, + fiscalYearId: currentFiscalYear.id, + customerId: values.customerId, + invoiceDate: values.invoiceDate?.toISOString(), + notes: values.notes || undefined, + reference: values.reference || undefined, + }; + const result = await createInvoiceMutation.mutateAsync(input); + showSuccess('Faktura oprettet'); + setIsCreateModalOpen(false); + createForm.resetFields(); + setSelectedInvoice(result); + setIsDrawerOpen(true); + } catch (err) { + if (err instanceof Error) { + showError(err); + } + } + }; + + const handleAddLine = () => { + setEditingLine(null); + lineForm.resetFields(); + lineForm.setFieldsValue({ quantity: 1, vatCode: 'S25' }); + setIsLineModalOpen(true); + }; + + const handleEditLine = (line: InvoiceLine) => { + setEditingLine(line); + lineForm.setFieldsValue({ + description: line.description, + quantity: line.quantity, + unitPrice: line.unitPrice, + unit: line.unit, + discountPercent: line.discountPercent, + vatCode: line.vatCode, + }); + setIsLineModalOpen(true); + }; + + const handleSubmitLine = async () => { + if (!selectedInvoice) return; + try { + const values = await lineForm.validateFields(); + if (editingLine) { + const result = await updateInvoiceLineMutation.mutateAsync({ + invoiceId: selectedInvoice.id, + lineNumber: editingLine.lineNumber, + ...values, + }); + showSuccess('Linje opdateret'); + setSelectedInvoice(result); + } else { + const input: AddInvoiceLineInput = { + invoiceId: selectedInvoice.id, + description: values.description, + quantity: values.quantity, + unitPrice: values.unitPrice, + unit: values.unit || undefined, + discountPercent: values.discountPercent || 0, + vatCode: values.vatCode, + }; + const result = await addInvoiceLineMutation.mutateAsync(input); + showSuccess('Linje tilføjet'); + setSelectedInvoice(result); + } + setIsLineModalOpen(false); + setEditingLine(null); + lineForm.resetFields(); + } catch (err) { + if (err instanceof Error) { + showError(err); + } + } + }; + + const handleRemoveLine = async (lineNumber: number) => { + if (!selectedInvoice) return; + try { + const result = await removeInvoiceLineMutation.mutateAsync({ + invoiceId: selectedInvoice.id, + lineNumber, + }); + showSuccess('Linje fjernet'); + setSelectedInvoice(result); + } catch (err) { + if (err instanceof Error) { + showError(err); + } + } + }; + + const handleSendInvoice = async () => { + if (!selectedInvoice) return; + if (selectedInvoice.lines.length === 0) { + showWarning('Tilføj mindst én linje før afsendelse'); + return; + } + try { + const result = await sendInvoiceMutation.mutateAsync(selectedInvoice.id); + showSuccess('Faktura sendt og bogført'); + setSelectedInvoice(result); + } catch (err) { + if (err instanceof Error) { + showError(err); + } + } + }; + + const handleReceivePayment = () => { + paymentForm.resetFields(); + paymentForm.setFieldsValue({ + amount: selectedInvoice?.amountRemaining, + paymentDate: dayjs(), + }); + setIsPaymentModalOpen(true); + }; + + const handleSubmitPayment = async () => { + if (!selectedInvoice) return; + try { + const values = await paymentForm.validateFields(); + const input: ReceivePaymentInput = { + invoiceId: selectedInvoice.id, + amount: values.amount, + bankAccountId: values.bankAccountId, + paymentDate: values.paymentDate?.toISOString(), + paymentReference: values.paymentReference || undefined, + }; + const result = await receivePaymentMutation.mutateAsync(input); + showSuccess('Betaling registreret'); + setIsPaymentModalOpen(false); + paymentForm.resetFields(); + setSelectedInvoice(result); + } catch (err) { + if (err instanceof Error) { + showError(err); + } + } + }; + + const handleVoidInvoice = () => { + voidForm.resetFields(); + setIsVoidModalOpen(true); + }; + + const handleSubmitVoid = async () => { + if (!selectedInvoice) return; + try { + const values = await voidForm.validateFields(); + const input: VoidInvoiceInput = { + invoiceId: selectedInvoice.id, + reason: values.reason, + }; + const result = await voidInvoiceMutation.mutateAsync(input); + showSuccess('Faktura annulleret'); + setIsVoidModalOpen(false); + voidForm.resetFields(); + setSelectedInvoice(result); + } catch (err) { + if (err instanceof Error) { + showError(err); + } + } + }; + + const handleViewInvoice = (invoice: Invoice) => { + setSelectedInvoice(invoice); + setIsDrawerOpen(true); + }; + + const columns: ColumnsType = [ + { + title: 'Fakturanr.', + dataIndex: 'invoiceNumber', + key: 'invoiceNumber', + width: 120, + sorter: (a, b) => a.invoiceNumber.localeCompare(b.invoiceNumber), + render: (value: string) => {value}, + }, + { + title: 'Kunde', + dataIndex: 'customerName', + key: 'customerName', + sorter: (a, b) => a.customerName.localeCompare(b.customerName), + ellipsis: true, + }, + { + title: 'Dato', + dataIndex: 'invoiceDate', + key: 'invoiceDate', + width: 100, + sorter: (a, b) => (a.invoiceDate || '').localeCompare(b.invoiceDate || ''), + render: (value: string | undefined) => (value ? formatDate(value) : '-'), + }, + { + title: 'Forfald', + dataIndex: 'dueDate', + key: 'dueDate', + width: 100, + render: (value: string | undefined, record: Invoice) => { + if (!value) return '-'; + const isOverdue = + ['sent', 'partially_paid'].includes(record.status) && + dayjs(value).isBefore(dayjs(), 'day'); + return ( + + {formatDate(value)} + + ); + }, + }, + { + title: 'Beløb', + dataIndex: 'amountTotal', + key: 'amountTotal', + width: 120, + align: 'right', + sorter: (a, b) => a.amountTotal - b.amountTotal, + render: (value: number) => , + }, + { + title: 'Restbeløb', + dataIndex: 'amountRemaining', + key: 'amountRemaining', + width: 120, + align: 'right', + render: (value: number, record: Invoice) => + record.status === 'voided' ? '-' : , + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + width: 120, + align: 'center', + filters: [ + { text: 'Kladde', value: 'draft' }, + { text: 'Sendt', value: 'sent' }, + { text: 'Delvist betalt', value: 'partially_paid' }, + { text: 'Betalt', value: 'paid' }, + { text: 'Annulleret', value: 'voided' }, + ], + onFilter: (value, record) => record.status === value, + render: (value: InvoiceStatus) => ( + {statusLabels[value]} + ), + }, + { + title: '', + key: 'actions', + width: 80, + align: 'center', + render: (_: unknown, record: Invoice) => ( + + + + {/* Error State */} + {error && ( + refetch()}> + Prøv igen + + } + /> + )} + + {/* Statistics */} + + + + + + + + + + + + + + formatCurrency(value as number)} + /> + + + + + formatCurrency(value as number)} + /> + + + + + {/* Filters */} + + + } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + style={{ width: 250 }} + allowClear + /> +
+ ) : ( + , + } + : undefined + } + /> + )} + + + {/* Create Invoice Draft Modal */} + setIsCreateModalOpen(false)} + onOk={handleSubmitCreate} + okText="Opret kladde" + cancelText="Annuller" + confirmLoading={createInvoiceMutation.isPending} + > + +
+ + + + + + + +
+ + {/* Invoice Detail Drawer */} + + + Faktura {selectedInvoice.invoiceNumber} + + {statusLabels[selectedInvoice.status]} + + + ) + } + placement="right" + width={700} + open={isDrawerOpen} + onClose={() => { + setIsDrawerOpen(false); + setSelectedInvoice(null); + }} + extra={ + selectedInvoice && ( + + {selectedInvoice.status === 'draft' && ( + <> + + + + )} + {['sent', 'partially_paid'].includes(selectedInvoice.status) && ( + <> + + + + )} + + ) + } + > + {selectedInvoice && ( +
+ + + {selectedInvoice.customerName} + + + {selectedInvoice.invoiceDate ? formatDate(selectedInvoice.invoiceDate) : '-'} + + + {selectedInvoice.dueDate ? formatDate(selectedInvoice.dueDate) : '-'} + + {selectedInvoice.reference && ( + + {selectedInvoice.reference} + + )} + + + Linjer + {selectedInvoice.lines.length > 0 ? ( + ( + } + onClick={() => handleEditLine(line)} + />, + handleRemoveLine(line.lineNumber)} + okText="Ja" + cancelText="Nej" + > +
+ {selectedInvoice.notes && ( + <> + Bemærkninger: +

{selectedInvoice.notes}

+ + )} + + +
+
+ Beløb ex. moms: + {formatCurrency(selectedInvoice.amountExVat)} +
+
+ Moms: + {formatCurrency(selectedInvoice.amountVat)} +
+
+ Total: + + {formatCurrency(selectedInvoice.amountTotal)} + +
+ {selectedInvoice.amountPaid > 0 && ( +
+ Betalt: + {formatCurrency(selectedInvoice.amountPaid)} +
+ )} + {selectedInvoice.amountRemaining > 0 && + selectedInvoice.status !== 'voided' && ( +
+ Restbeløb: + + {formatCurrency(selectedInvoice.amountRemaining)} + +
+ )} +
+ + + + )} + + + {/* Add/Edit Line Modal */} + { + setIsLineModalOpen(false); + setEditingLine(null); + lineForm.resetFields(); + }} + onOk={handleSubmitLine} + okText="Gem" + cancelText="Annuller" + confirmLoading={addInvoiceLineMutation.isPending || updateInvoiceLineMutation.isPending} + > +
+ {/* Product selector - copies values to form fields when selected */} + + + + +
+ + + + + + + + + + + + + + {/* Payment Modal */} + setIsPaymentModalOpen(false)} + onOk={handleSubmitPayment} + okText="Registrer" + cancelText="Annuller" + confirmLoading={receivePaymentMutation.isPending} + > +
+ + `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, '.')} + parser={(value) => value!.replace(/\./g, '') as unknown as number} + addonAfter="DKK" + /> + + + + + +
+ + {/* Void Modal */} + setIsVoidModalOpen(false)} + onOk={handleSubmitVoid} + okText="Annuller faktura" + okButtonProps={{ danger: true }} + cancelText="Fortryd" + confirmLoading={voidInvoiceMutation.isPending} + > + +
+ + + + +
+ + ); +} diff --git a/frontend/src/pages/Kreditnotaer.tsx b/frontend/src/pages/Kreditnotaer.tsx new file mode 100644 index 0000000..aba42de --- /dev/null +++ b/frontend/src/pages/Kreditnotaer.tsx @@ -0,0 +1,947 @@ +import { useState, useMemo } from 'react'; +import { + Typography, + Button, + Card, + Table, + Space, + Tag, + Modal, + Form, + Input, + Select, + InputNumber, + Spin, + Alert, + Drawer, + Descriptions, + Popconfirm, + Row, + Col, + Statistic, + DatePicker, + Divider, + List, +} from 'antd'; +import { showSuccess, showError, showWarning } from '@/lib/errorHandling'; +import { + PlusOutlined, + EditOutlined, + SearchOutlined, + EyeOutlined, + SendOutlined, + StopOutlined, + DeleteOutlined, + FileTextOutlined, + LinkOutlined, +} from '@ant-design/icons'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import dayjs from 'dayjs'; +import type { ColumnsType } from 'antd/es/table'; +import { useCompany } from '@/hooks/useCompany'; +import { useCurrentFiscalYear } from '@/stores/periodStore'; +import { + useCreditNotes, + useInvoices, + type Invoice, + type InvoiceLine, + type InvoiceStatus, +} from '@/api/queries/invoiceQueries'; +import { useActiveCustomers, type Customer } from '@/api/queries/customerQueries'; +import { + useCreateCreditNote, + useIssueCreditNote, + useApplyCreditNote, + useAddInvoiceLine, + useUpdateInvoiceLine, + useRemoveInvoiceLine, + useVoidInvoice, + type CreateCreditNoteInput, + type AddInvoiceLineInput, + type VoidInvoiceInput, +} from '@/api/mutations/invoiceMutations'; +import { formatCurrency, formatDate } from '@/lib/formatters'; +import { spacing } from '@/styles/designTokens'; +import { accountingColors } from '@/styles/theme'; +import { AmountText } from '@/components/shared/AmountText'; +import { EmptyState } from '@/components/shared/EmptyState'; + +const { Title, Text } = Typography; + +// Credit note statuses (using unified Invoice model) +const statusColors: Record = { + draft: 'default', + issued: 'processing', + partially_applied: 'warning', + fully_applied: 'success', + voided: 'error', + // Also include invoice statuses that might appear + sent: 'processing', + partially_paid: 'warning', + paid: 'success', +}; + +const statusLabels: Record = { + draft: 'Kladde', + issued: 'Udstedt', + partially_applied: 'Delvist anvendt', + fully_applied: 'Fuldt anvendt', + voided: 'Annulleret', + sent: 'Sendt', + partially_paid: 'Delvist betalt', + paid: 'Betalt', +}; + +export default function Kreditnotaer() { + const navigate = useNavigate(); + const { company } = useCompany(); + const currentFiscalYear = useCurrentFiscalYear(); + const [searchParams] = useSearchParams(); + const customerIdFilter = searchParams.get('customer'); + + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isLineModalOpen, setIsLineModalOpen] = useState(false); + const [isApplyModalOpen, setIsApplyModalOpen] = useState(false); + const [isVoidModalOpen, setIsVoidModalOpen] = useState(false); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [selectedCreditNote, setSelectedCreditNote] = useState(null); + const [editingLine, setEditingLine] = useState(null); + const [searchText, setSearchText] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [createForm] = Form.useForm(); + const [lineForm] = Form.useForm(); + const [applyForm] = Form.useForm(); + const [voidForm] = Form.useForm(); + + // Fetch credit notes + const { + data: creditNotes = [], + isLoading: loading, + error, + refetch, + } = useCreditNotes(company?.id); + + // Fetch customers for dropdown + const { data: customers = [] } = useActiveCustomers(company?.id); + + // Fetch invoices for applying credit notes (only when modal is open) + const { data: allInvoices = [] } = useInvoices(company?.id, undefined, { + enabled: !!company?.id && isApplyModalOpen, + }); + + const openInvoices: Invoice[] = allInvoices.filter( + (i: Invoice) => ['sent', 'partially_paid'].includes(i.status) && i.amountRemaining > 0 + ); + + // Mutations + const createCreditNoteMutation = useCreateCreditNote(); + const addLineMutation = useAddInvoiceLine(); + const updateLineMutation = useUpdateInvoiceLine(); + const removeLineMutation = useRemoveInvoiceLine(); + const issueCreditNoteMutation = useIssueCreditNote(); + const applyCreditNoteMutation = useApplyCreditNote(); + const voidMutation = useVoidInvoice(); + + // Filter credit notes + const filteredCreditNotes = useMemo(() => { + return creditNotes.filter((cn) => { + const matchesSearch = + searchText === '' || + cn.invoiceNumber.toLowerCase().includes(searchText.toLowerCase()) || + cn.customerName.toLowerCase().includes(searchText.toLowerCase()); + + const matchesStatus = statusFilter === 'all' || cn.status === statusFilter; + + const matchesCustomer = !customerIdFilter || cn.customerId === customerIdFilter; + + return matchesSearch && matchesStatus && matchesCustomer; + }); + }, [creditNotes, searchText, statusFilter, customerIdFilter]); + + // Statistics + const stats = useMemo(() => { + const total = creditNotes.length; + const draft = creditNotes.filter((cn) => cn.status === 'draft').length; + const unapplied = creditNotes + .filter((cn) => ['issued', 'partially_applied'].includes(cn.status)) + .reduce((sum, cn) => sum + cn.amountRemaining, 0); + const totalValue = creditNotes + .filter((cn) => cn.status !== 'voided') + .reduce((sum, cn) => sum + cn.amountTotal, 0); + return { total, draft, unapplied, totalValue }; + }, [creditNotes]); + + const handleCreateCreditNote = () => { + createForm.resetFields(); + createForm.setFieldsValue({ + creditNoteDate: dayjs(), + }); + setIsCreateModalOpen(true); + }; + + const handleSubmitCreate = async () => { + if (!company || !currentFiscalYear) { + showError('Virksomhed eller regnskabsår ikke valgt'); + return; + } + try { + const values = await createForm.validateFields(); + const input: CreateCreditNoteInput = { + companyId: company.id, + fiscalYearId: currentFiscalYear.id, + customerId: values.customerId, + creditNoteDate: values.creditNoteDate?.toISOString(), + originalInvoiceId: values.originalInvoiceId || undefined, + creditReason: values.reason || undefined, + }; + const result = await createCreditNoteMutation.mutateAsync(input); + showSuccess('Kreditnota oprettet'); + setIsCreateModalOpen(false); + createForm.resetFields(); + setSelectedCreditNote(result); + setIsDrawerOpen(true); + } catch (err) { + if (err instanceof Error) { + showError(err); + } + } + }; + + const handleAddLine = () => { + setEditingLine(null); + lineForm.resetFields(); + lineForm.setFieldsValue({ quantity: 1, vatCode: 'S25' }); + setIsLineModalOpen(true); + }; + + const handleEditLine = (line: InvoiceLine) => { + setEditingLine(line); + lineForm.setFieldsValue({ + description: line.description, + quantity: line.quantity, + unitPrice: line.unitPrice, + vatCode: line.vatCode, + }); + setIsLineModalOpen(true); + }; + + const handleSubmitLine = async () => { + if (!selectedCreditNote) return; + try { + const values = await lineForm.validateFields(); + if (editingLine) { + const result = await updateLineMutation.mutateAsync({ + invoiceId: selectedCreditNote.id, + lineNumber: editingLine.lineNumber, + description: values.description, + quantity: values.quantity, + unitPrice: values.unitPrice, + vatCode: values.vatCode, + }); + showSuccess('Linje opdateret'); + setSelectedCreditNote(result); + } else { + const input: AddInvoiceLineInput = { + invoiceId: selectedCreditNote.id, + description: values.description, + quantity: values.quantity, + unitPrice: values.unitPrice, + vatCode: values.vatCode, + }; + const result = await addLineMutation.mutateAsync(input); + showSuccess('Linje tilføjet'); + setSelectedCreditNote(result); + } + setIsLineModalOpen(false); + setEditingLine(null); + lineForm.resetFields(); + } catch (err) { + if (err instanceof Error) { + showError(err); + } + } + }; + + const handleRemoveLine = async (lineNumber: number) => { + if (!selectedCreditNote) return; + try { + const result = await removeLineMutation.mutateAsync({ + invoiceId: selectedCreditNote.id, + lineNumber, + }); + showSuccess('Linje fjernet'); + setSelectedCreditNote(result); + } catch (err) { + if (err instanceof Error) { + showError(err); + } + } + }; + + const handleIssueCreditNote = async () => { + if (!selectedCreditNote) return; + if (selectedCreditNote.lines.length === 0) { + showWarning('Tilføj mindst én linje før udstedelse'); + return; + } + try { + const result = await issueCreditNoteMutation.mutateAsync(selectedCreditNote.id); + showSuccess('Kreditnota udstedt og bogført'); + setSelectedCreditNote(result); + } catch (err) { + if (err instanceof Error) { + showError(err); + } + } + }; + + const handleApplyCreditNote = () => { + applyForm.resetFields(); + applyForm.setFieldsValue({ + amount: selectedCreditNote?.amountRemaining, + }); + setIsApplyModalOpen(true); + }; + + const handleSubmitApply = async () => { + if (!selectedCreditNote) return; + try { + const values = await applyForm.validateFields(); + const result = await applyCreditNoteMutation.mutateAsync({ + creditNoteId: selectedCreditNote.id, + invoiceId: values.invoiceId, + amount: values.amount, + }); + showSuccess('Kreditnota anvendt på faktura'); + setIsApplyModalOpen(false); + applyForm.resetFields(); + setSelectedCreditNote(result); + } catch (err) { + if (err instanceof Error) { + showError(err); + } + } + }; + + const handleVoidCreditNote = () => { + voidForm.resetFields(); + setIsVoidModalOpen(true); + }; + + const handleSubmitVoid = async () => { + if (!selectedCreditNote) return; + try { + const values = await voidForm.validateFields(); + const input: VoidInvoiceInput = { + invoiceId: selectedCreditNote.id, + reason: values.reason, + }; + const result = await voidMutation.mutateAsync(input); + showSuccess('Kreditnota annulleret'); + setIsVoidModalOpen(false); + voidForm.resetFields(); + setSelectedCreditNote(result); + } catch (err) { + if (err instanceof Error) { + showError(err); + } + } + }; + + const handleViewCreditNote = (creditNote: Invoice) => { + setSelectedCreditNote(creditNote); + setIsDrawerOpen(true); + }; + + const columns: ColumnsType = [ + { + title: 'Kreditnotanr.', + dataIndex: 'invoiceNumber', + key: 'invoiceNumber', + width: 130, + sorter: (a, b) => a.invoiceNumber.localeCompare(b.invoiceNumber), + render: (value: string) => {value}, + }, + { + title: 'Kunde', + dataIndex: 'customerName', + key: 'customerName', + sorter: (a, b) => a.customerName.localeCompare(b.customerName), + ellipsis: true, + }, + { + title: 'Dato', + dataIndex: 'invoiceDate', + key: 'invoiceDate', + width: 100, + sorter: (a, b) => (a.invoiceDate ?? '').localeCompare(b.invoiceDate ?? ''), + render: (value: string | undefined) => value ? formatDate(value) : '-', + }, + { + title: 'Beløb', + dataIndex: 'amountTotal', + key: 'amountTotal', + width: 120, + align: 'right', + sorter: (a, b) => a.amountTotal - b.amountTotal, + render: (value: number) => , + }, + { + title: 'Restbeløb', + dataIndex: 'amountRemaining', + key: 'amountRemaining', + width: 120, + align: 'right', + render: (value: number, record: Invoice) => + record.status === 'voided' ? '-' : , + }, + { + title: 'Orig. faktura', + dataIndex: 'originalInvoiceNumber', + key: 'originalInvoiceNumber', + width: 120, + render: (value: string | undefined) => (value ? {value} : '-'), + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + width: 130, + align: 'center', + filters: [ + { text: 'Kladde', value: 'draft' }, + { text: 'Udstedt', value: 'issued' }, + { text: 'Delvist anvendt', value: 'partially_applied' }, + { text: 'Fuldt anvendt', value: 'fully_applied' }, + { text: 'Annulleret', value: 'voided' }, + ], + onFilter: (value, record) => record.status === value, + render: (value: InvoiceStatus) => ( + {statusLabels[value]} + ), + }, + { + title: '', + key: 'actions', + width: 80, + align: 'center', + render: (_: unknown, record: Invoice) => ( + + + + {/* Error State */} + {error && ( + refetch()}> + Prøv igen + + } + /> + )} + + {/* Statistics */} + + + + + + + + + + + + + + formatCurrency(value as number)} + /> + + + + + formatCurrency(value as number)} + /> + + + + + {/* Filters */} + + + } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + style={{ width: 250 }} + allowClear + /> +
+ ) : ( + , + } + : undefined + } + /> + )} + + + {/* Create Credit Note Modal */} + setIsCreateModalOpen(false)} + onOk={handleSubmitCreate} + okText="Opret" + cancelText="Annuller" + confirmLoading={createCreditNoteMutation.isPending} + > +
+ + + + + + + +
+ + {/* Credit Note Detail Drawer */} + + + Kreditnota {selectedCreditNote.invoiceNumber} + + {statusLabels[selectedCreditNote.status] || selectedCreditNote.status} + + + ) + } + placement="right" + width={700} + open={isDrawerOpen} + onClose={() => { + setIsDrawerOpen(false); + setSelectedCreditNote(null); + }} + extra={ + selectedCreditNote && ( + + {selectedCreditNote.status === 'draft' && ( + <> + + + + )} + {['issued', 'partially_applied'].includes(selectedCreditNote.status) && ( + <> + + + + )} + + ) + } + > + {selectedCreditNote && ( +
+ + + {selectedCreditNote.customerName} + + + {selectedCreditNote.invoiceDate ? formatDate(selectedCreditNote.invoiceDate) : '-'} + + + {selectedCreditNote.originalInvoiceNumber || '-'} + + {selectedCreditNote.creditReason && ( + + {selectedCreditNote.creditReason} + + )} + + + Linjer + {selectedCreditNote.lines.length > 0 ? ( + ( + } + onClick={() => handleEditLine(line)} + />, + handleRemoveLine(line.lineNumber)} + okText="Ja" + cancelText="Nej" + > +
+ +
+
+ Beløb ex. moms: + {formatCurrency(-selectedCreditNote.amountExVat)} +
+
+ Moms: + {formatCurrency(-selectedCreditNote.amountVat)} +
+
+ Total: + + {formatCurrency(-selectedCreditNote.amountTotal)} + +
+ {selectedCreditNote.amountApplied > 0 && ( +
+ Anvendt: + {formatCurrency(-selectedCreditNote.amountApplied)} +
+ )} + {selectedCreditNote.amountRemaining > 0 && + selectedCreditNote.status !== 'voided' && ( +
+ Restbeløb: + + {formatCurrency(-selectedCreditNote.amountRemaining)} + +
+ )} +
+ + + + )} + + + {/* Add/Edit Line Modal */} + { + setIsLineModalOpen(false); + setEditingLine(null); + lineForm.resetFields(); + }} + onOk={handleSubmitLine} + okText="Gem" + cancelText="Annuller" + confirmLoading={addLineMutation.isPending || updateLineMutation.isPending} + > +
+ + + + +
+ + + + + + + + + + + + + (option?.label ?? '').toLowerCase().includes(input.toLowerCase()) + } + options={openInvoices.map((i) => ({ + value: i.id, + label: `${i.invoiceNumber} - ${i.customerName} (${formatCurrency(i.amountRemaining)})`, + }))} + /> + + + `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, '.')} + parser={(value) => value!.replace(/\./g, '') as unknown as number} + addonAfter="DKK" + /> + + + + + {/* Void Modal */} + setIsVoidModalOpen(false)} + onOk={handleSubmitVoid} + okText="Annuller kreditnota" + okButtonProps={{ danger: true }} + cancelText="Fortryd" + confirmLoading={voidMutation.isPending} + > + +
+ + + + +
+ + ); +} diff --git a/frontend/src/pages/Kunder.tsx b/frontend/src/pages/Kunder.tsx new file mode 100644 index 0000000..879b0d5 --- /dev/null +++ b/frontend/src/pages/Kunder.tsx @@ -0,0 +1,661 @@ +import { useState, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Typography, + Button, + Card, + Table, + Space, + Tag, + Modal, + Form, + Input, + Select, + Spin, + Alert, + Drawer, + Descriptions, + Popconfirm, + Row, + Col, + Statistic, +} from 'antd'; +import { showSuccess, showError } from '@/lib/errorHandling'; +import { + PlusOutlined, + EditOutlined, + SearchOutlined, + UserOutlined, + ShopOutlined, + EyeOutlined, + StopOutlined, + CheckOutlined, + FileTextOutlined, +} from '@ant-design/icons'; +import { useCompany } from '@/hooks/useCompany'; +import { usePageHotkeys } from '@/hooks/usePageHotkeys'; +import { ShortcutTooltip } from '@/components/shared/ShortcutTooltip'; +import { useCustomers, type Customer } from '@/api/queries/customerQueries'; +import { + useCreateCustomer, + useUpdateCustomer, + useDeactivateCustomer, + useReactivateCustomer, + type CreateCustomerInput, + type UpdateCustomerInput, +} from '@/api/mutations/customerMutations'; +import { formatDate, validateCVRModulus11 } from '@/lib/formatters'; +import { spacing } from '@/styles/designTokens'; +import { StatusBadge } from '@/components/shared/StatusBadge'; +import { EmptyState } from '@/components/shared/EmptyState'; +import type { ColumnsType } from 'antd/es/table'; + +const { Title, Text } = Typography; + +export default function Kunder() { + const navigate = useNavigate(); + const { company } = useCompany(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [editingCustomer, setEditingCustomer] = useState(null); + const [selectedCustomer, setSelectedCustomer] = useState(null); + const [searchText, setSearchText] = useState(''); + const [showInactive, setShowInactive] = useState(false); + const [form] = Form.useForm(); + + // Fetch customers using the new hook + const { + data: customers = [], + isLoading: loading, + error, + refetch, + } = useCustomers(company?.id); + + // Mutations using new hooks + const createCustomerMutation = useCreateCustomer(); + const updateCustomerMutation = useUpdateCustomer(); + const deactivateCustomerMutation = useDeactivateCustomer(); + const reactivateCustomerMutation = useReactivateCustomer(); + + // Filter customers + const filteredCustomers = useMemo(() => { + return customers.filter((customer) => { + const matchesSearch = + searchText === '' || + customer.name.toLowerCase().includes(searchText.toLowerCase()) || + customer.customerNumber.includes(searchText) || + (customer.cvr && customer.cvr.includes(searchText)) || + (customer.email && customer.email.toLowerCase().includes(searchText.toLowerCase())); + + const matchesStatus = showInactive || customer.isActive; + + return matchesSearch && matchesStatus; + }); + }, [customers, searchText, showInactive]); + + // Statistics + const stats = useMemo(() => { + const active = customers.filter((c) => c.isActive).length; + const business = customers.filter((c) => c.customerType === 'Business').length; + const private_ = customers.filter((c) => c.customerType === 'Private').length; + return { total: customers.length, active, business, private: private_ }; + }, [customers]); + + const handleCreate = () => { + setEditingCustomer(null); + form.resetFields(); + form.setFieldsValue({ customerType: 'Business', paymentTermsDays: 30 }); + setIsModalOpen(true); + }; + + const handleEdit = (customer: Customer) => { + setEditingCustomer(customer); + form.setFieldsValue({ + customerType: customer.customerType, + name: customer.name, + cvr: customer.cvr, + address: customer.address, + postalCode: customer.postalCode, + city: customer.city, + country: customer.country ?? 'DK', + email: customer.email, + phone: customer.phone, + paymentTermsDays: customer.paymentTermsDays, + }); + setIsModalOpen(true); + }; + + const handleView = (customer: Customer) => { + setSelectedCustomer(customer); + setIsDrawerOpen(true); + }; + + // Create invoice for selected customer + const handleCreateInvoice = () => { + if (selectedCustomer && selectedCustomer.isActive) { + setIsDrawerOpen(false); + navigate(`/fakturaer?create=true&customerId=${selectedCustomer.id}`); + } + }; + + // Keyboard shortcuts for this page + usePageHotkeys([ + { + shortcutId: 'newCustomer', + action: handleCreate, + }, + { + shortcutId: 'newInvoice', + action: handleCreateInvoice, + enabled: !!selectedCustomer && selectedCustomer.isActive, + }, + ]); + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + + if (editingCustomer) { + const input: UpdateCustomerInput = { + id: editingCustomer.id, + name: values.name, + cvr: values.cvr || undefined, + address: values.address || undefined, + postalCode: values.postalCode || undefined, + city: values.city || undefined, + country: values.country || undefined, + email: values.email || undefined, + phone: values.phone || undefined, + paymentTermsDays: values.paymentTermsDays, + }; + await updateCustomerMutation.mutateAsync(input); + showSuccess('Kunde opdateret'); + } else { + const input: CreateCustomerInput = { + companyId: company!.id, + customerType: values.customerType.toUpperCase() as 'BUSINESS' | 'PRIVATE', + name: values.name, + cvr: values.cvr || undefined, + address: values.address || undefined, + postalCode: values.postalCode || undefined, + city: values.city || undefined, + country: values.country || 'DK', + email: values.email || undefined, + phone: values.phone || undefined, + paymentTermsDays: values.paymentTermsDays ?? 30, + }; + await createCustomerMutation.mutateAsync(input); + showSuccess('Kunde oprettet'); + } + setIsModalOpen(false); + setEditingCustomer(null); + form.resetFields(); + } catch (err) { + if (err instanceof Error) { + showError(err); + } + } + }; + + const handleToggleActive = async (customer: Customer) => { + try { + if (customer.isActive) { + await deactivateCustomerMutation.mutateAsync(customer.id); + showSuccess('Kunde deaktiveret'); + } else { + await reactivateCustomerMutation.mutateAsync(customer.id); + showSuccess('Kunde genaktiveret'); + } + } catch (err) { + if (err instanceof Error) { + showError(err); + } + } + }; + + const columns: ColumnsType = [ + { + title: 'Kundenr.', + dataIndex: 'customerNumber', + key: 'customerNumber', + width: 100, + sorter: (a, b) => a.customerNumber.localeCompare(b.customerNumber), + render: (value: string) => {value}, + }, + { + title: 'Navn', + dataIndex: 'name', + key: 'name', + sorter: (a, b) => a.name.localeCompare(b.name), + render: (value: string, record: Customer) => ( + + {record.customerType === 'Business' ? : } + {value} + + ), + }, + { + title: 'CVR', + dataIndex: 'cvr', + key: 'cvr', + width: 100, + render: (value: string | undefined) => value || '-', + }, + { + title: 'Email', + dataIndex: 'email', + key: 'email', + ellipsis: true, + render: (value: string | undefined) => value || '-', + }, + { + title: 'Betalingsbetingelser', + dataIndex: 'paymentTermsDays', + key: 'paymentTermsDays', + width: 150, + align: 'center', + render: (value: number) => {value} dage, + }, + { + title: 'Status', + dataIndex: 'isActive', + key: 'isActive', + width: 100, + align: 'center', + render: (value: boolean) => ( + + ), + }, + { + title: 'Handlinger', + key: 'actions', + width: 150, + align: 'center', + render: (_: unknown, record: Customer) => ( + + + + + + {/* Error State */} + {error && ( + refetch()}> + Prøv igen + + } + /> + )} + + {/* Statistics */} + + + + + + + + + + + + + + } + /> + + + + + } + /> + + + + + {/* Filters */} + + + } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + style={{ width: 250 }} + allowClear + /> +
+ ) : ( + , + } + : undefined + } + /> + )} + + + {/* Create/Edit Modal */} + { + setIsModalOpen(false); + setEditingCustomer(null); + form.resetFields(); + }} + onOk={handleSubmit} + okText="Gem" + cancelText="Annuller" + confirmLoading={createCustomerMutation.isPending || updateCustomerMutation.isPending} + width={600} + > +
+ + + + + prev.customerType !== curr.customerType} + > + {({ getFieldValue }) => + getFieldValue('customerType') === 'Business' && ( + { + if (!value || value.length !== 8) return Promise.resolve(); + if (!validateCVRModulus11(value)) { + return Promise.reject('Ugyldigt CVR-nummer (modulus 11 check fejlet)'); + } + return Promise.resolve(); + }, + }, + ]} + > + + + ) + } + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + style={{ width: 250 }} + allowClear + /> +
+ ) : ( + , + } + : undefined + } + /> + )} + + + {/* Create Order Modal */} + setIsCreateModalOpen(false)} + onOk={handleSubmitCreate} + okText="Opret" + cancelText="Annuller" + confirmLoading={createOrderMutation.isPending} + > + + + + + + + + + + + {/* Order Detail Drawer */} + + + Ordre {selectedOrder.orderNumber} + + {ORDER_STATUS_LABELS[selectedOrder.status]} + + + ) + } + placement="right" + width={700} + open={isDrawerOpen} + onClose={() => { + setIsDrawerOpen(false); + setSelectedOrder(null); + }} + extra={ + selectedOrder && ( + + {selectedOrder.status === 'draft' && ( + <> + + + + )} + {canShowConvertToInvoice(selectedOrder) && ( + + + + )} + {selectedOrder.status !== 'cancelled' && selectedOrder.status !== 'fully_invoiced' && ( + + )} + + ) + } + > + {selectedOrder && ( +
+ + + {selectedOrder.customerName} + + + {selectedOrder.orderDate ? formatDate(selectedOrder.orderDate) : '-'} + + + {selectedOrder.expectedDeliveryDate ? formatDate(selectedOrder.expectedDeliveryDate) : '-'} + + {selectedOrder.reference && ( + + {selectedOrder.reference} + + )} + + + Linjer + {selectedOrder.lines.length > 0 ? ( + { + const linkedProduct = line.productId + ? products.find((p: Product) => p.id === line.productId) + : null; + return ( + + + {line.description} + {line.productId && ( + }> + {linkedProduct?.productNumber || 'Produkt'} + + )} + {line.isInvoiced && ( + }> + Faktureret + + )} + + } + description={ + + + {line.quantity} {line.unit || 'stk'} x {formatCurrency(line.unitPrice)} + + {line.discountPercent > 0 && ( + -{line.discountPercent}% + )} + {line.vatCode} + {line.isInvoiced && line.invoicedAt && ( + + Faktureret: {dayjs(line.invoicedAt).format('DD/MM/YYYY')} + + )} + + } + /> + + + ); + }} + /> + ) : ( + + )} + + + + +
+ {selectedOrder.notes && ( + <> + Bemaerkninger: +

{selectedOrder.notes}

+ + )} + {selectedOrder.cancelledReason && ( + <> + Annulleringsaarsag: +

{selectedOrder.cancelledReason}

+ + )} + + +
+
+ Beloeb ex. moms: + {formatCurrency(selectedOrder.amountExVat)} +
+
+ Moms: + {formatCurrency(selectedOrder.amountVat)} +
+
+ Total: + + {formatCurrency(selectedOrder.amountTotal)} + +
+ {(selectedOrder.uninvoicedAmount ?? selectedOrder.amountTotal) < selectedOrder.amountTotal && ( +
+ Faktureret: + {formatCurrency(selectedOrder.amountTotal - (selectedOrder.uninvoicedAmount ?? 0))} +
+ )} + {(selectedOrder.uninvoicedAmount ?? 0) > 0 && + selectedOrder.status !== 'cancelled' && ( +
+ Resterende: + + {formatCurrency(selectedOrder.uninvoicedAmount ?? 0)} + +
+ )} +
+ + + + )} + + + {/* Cancel Order Modal */} + setIsCancelModalOpen(false)} + onOk={handleSubmitCancel} + okText="Annuller ordre" + okButtonProps={{ danger: true }} + cancelText="Fortryd" + confirmLoading={cancelOrderMutation.isPending} + > + +
+ + + + +
+ + {/* Add Line Modal */} + { + setIsAddLineModalOpen(false); + setSelectedProductId(null); + }} + onOk={handleSubmitAddLine} + okText="Tilfoej" + cancelText="Annuller" + confirmLoading={addOrderLineMutation.isPending} + width={550} + > +
+ + { + setAddLineMode(e.target.value); + setSelectedProductId(null); + addLineForm.resetFields(); + addLineForm.setFieldsValue({ + quantity: 1, + vatCode: 'S25', + }); + }} + optionType="button" + buttonStyle="solid" + > + Vaelg produkt + Fritekst + + + + {addLineMode === 'product' && ( + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + style={{ width: 300 }} + /> + + + + + {/* Products table */} + {filteredProducts.length === 0 && searchText === '' && !showInactive ? ( + } + title="Ingen produkter endnu" + description="Opret dit første produkt for at komme i gang med hurtig fakturering" + primaryAction={{ + label: 'Opret produkt', + onClick: handleCreate, + icon: , + }} + /> + ) : ( + +
`${total} produkter`, + }} + /> + + )} + + {/* Create/Edit Modal */} + { + setIsModalOpen(false); + setEditingProduct(null); + form.resetFields(); + }} + onOk={handleSubmit} + okText={editingProduct ? 'Gem' : 'Opret'} + cancelText="Annuller" + confirmLoading={createProductMutation.isPending || updateProductMutation.isPending} + width={600} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + ({ value: m, label: m }))} + filterOption={(inputValue, option) => + option?.value.toLowerCase().includes(inputValue.toLowerCase()) ?? false + } + /> + + + + + + + + + + + + + + + + + + + + {/* View Drawer */} + { + setIsDrawerOpen(false); + setSelectedProduct(null); + }} + width={500} + > + {selectedProduct && ( +
+ + + {selectedProduct.productNumber || '-'} + + {selectedProduct.name} + + {selectedProduct.description || '-'} + + + {formatCurrency(selectedProduct.unitPrice)} + + {selectedProduct.unit || '-'} + + {selectedProduct.vatCode} + + + {selectedProduct.ean || '-'} + + + {selectedProduct.manufacturer || '-'} + + + + + + {formatDate(selectedProduct.createdAt)} + + + {formatDate(selectedProduct.updatedAt)} + + + +
+ + + handleToggleActive(selectedProduct)} + okText="Ja" + cancelText="Nej" + > + + + +
+
+ )} +
+ + ); +} diff --git a/frontend/src/pages/UserSettings.tsx b/frontend/src/pages/UserSettings.tsx new file mode 100644 index 0000000..471a694 --- /dev/null +++ b/frontend/src/pages/UserSettings.tsx @@ -0,0 +1,472 @@ +import { useState } from 'react'; +import { + Typography, + Card, + Row, + Col, + Form, + Input, + Button, + Tabs, + Switch, + Divider, + Avatar, + Upload, + Select, + Modal, +} from 'antd'; +import { showSuccess, showError, showInfo, showValidationError } from '@/lib/errorHandling'; +import { + SaveOutlined, + UserOutlined, + LockOutlined, + BellOutlined, + GlobalOutlined, + UploadOutlined, + ExclamationCircleOutlined, +} from '@ant-design/icons'; +import type { UploadProps } from 'antd'; +import { spacing } from '@/styles/designTokens'; + +const { Title, Text } = Typography; + +// Mock user data - replace with actual auth context +const mockUser = { + id: '1', + name: 'Nicolaj Hartmann', + email: 'nicolaj@example.com', + avatar: null, + phone: '+45 12 34 56 78', + language: 'da', + timezone: 'Europe/Copenhagen', + notifications: { + email: true, + browser: true, + weeklyReport: true, + deadlineReminders: true, + }, +}; + +export default function UserSettings() { + const [profileForm] = Form.useForm(); + const [passwordForm] = Form.useForm(); + const [notificationForm] = Form.useForm(); + const [isChangingPassword, setIsChangingPassword] = useState(false); + + const handleSaveProfile = async () => { + try { + const values = await profileForm.validateFields(); + console.log('Saving profile:', values); + showSuccess('Profil opdateret'); + } catch (error) { + console.error('Validation failed:', error); + showValidationError(); + } + }; + + const handleChangePassword = async () => { + try { + const values = await passwordForm.validateFields(); + if (values.newPassword !== values.confirmPassword) { + showError('Adgangskoderne stemmer ikke overens'); + return; + } + console.log('Changing password'); + showSuccess('Adgangskode opdateret'); + passwordForm.resetFields(); + setIsChangingPassword(false); + } catch (error) { + console.error('Validation failed:', error); + showValidationError(); + } + }; + + const handleSaveNotifications = async () => { + try { + const values = await notificationForm.validateFields(); + console.log('Saving notifications:', values); + showSuccess('Notifikationsindstillinger gemt'); + } catch (error) { + console.error('Validation failed:', error); + } + }; + + const handleDeleteAccount = () => { + Modal.confirm({ + title: 'Slet konto', + icon: , + content: ( +
+

Er du sikker på, at du vil slette din konto?

+

Denne handling kan ikke fortrydes.

+
+ ), + okText: 'Ja, slet min konto', + okType: 'danger', + cancelText: 'Annuller', + onOk() { + showInfo('Kontosletning er ikke implementeret endnu'); + }, + }); + }; + + const uploadProps: UploadProps = { + name: 'avatar', + showUploadList: false, + beforeUpload: (file) => { + const isImage = file.type.startsWith('image/'); + if (!isImage) { + showError('Du kan kun uploade billedfiler'); + } + const isLt2M = file.size / 1024 / 1024 < 2; + if (!isLt2M) { + showError('Billedet skal være mindre end 2MB'); + } + return false; // Prevent auto upload + }, + onChange: (info) => { + console.log('Upload:', info.file); + showSuccess('Profilbillede opdateret'); + }, + }; + + const tabItems = [ + { + key: 'profile', + label: ( + + Profil + + ), + children: ( + +
+
+ } src={mockUser.avatar} /> +
+ + + + + JPG eller PNG, max 2MB + +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + ), + }, + { + key: 'security', + label: ( + + Sikkerhed + + ), + children: ( + + Skift adgangskode + + Vi anbefaler at bruge en stærk adgangskode, som du ikke bruger andre steder. + + + {!isChangingPassword ? ( + + ) : ( +
+ + + + + + + + + + + + +
+ + +
+ + )} + + + + To-faktor-godkendelse + + Tilføj et ekstra lag af sikkerhed til din konto. + + + + + + Farezone + + Når du sletter din konto, mister du al adgang til virksomheder og data. + + +
+ ), + }, + { + key: 'notifications', + label: ( + + Notifikationer + + ), + children: ( + +
+ Email-notifikationer + + +
+
+ Generelle emails + + Modtag vigtige opdateringer om din konto + +
+ +
+
+ + +
+
+ Ugentlig rapport + + Få en opsummering af ugens bogføring hver mandag + +
+ +
+
+ + +
+
+ Påmindelser om frister + + Få besked når momsfrister eller andre deadlines nærmer sig + +
+ +
+
+ + + + Browser-notifikationer + + +
+
+ Push-notifikationer + + Modtag notifikationer direkte i browseren + +
+ +
+
+ +
+ +
+ +
+ ), + }, + { + key: 'preferences', + label: ( + + Præferencer + + ), + children: ( + +
+ +
+ + + + + + + + + Visningsindstillinger + + + + + + + + + +
+ +
+ + + ), + }, + ]; + + return ( +
+ {/* Header */} +
+ + Min profil + + Administrer dine personlige indstillinger +
+ + +
+ ); +} diff --git a/frontend/src/stores/hotkeyStore.ts b/frontend/src/stores/hotkeyStore.ts new file mode 100644 index 0000000..e68eb10 --- /dev/null +++ b/frontend/src/stores/hotkeyStore.ts @@ -0,0 +1,118 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export type HotkeyContext = 'global' | 'modal' | 'form' | 'table' | 'page'; + +interface RecentCommand { + id: string; + label: string; + timestamp: number; +} + +interface HotkeyState { + // Feature toggles + hotkeysEnabled: boolean; + + // Command palette state + commandPaletteOpen: boolean; + + // Shortcuts help modal + shortcutsHelpOpen: boolean; + + // Current context (determines which shortcuts are active) + activeContext: HotkeyContext; + + // Recent commands for command palette + recentCommands: RecentCommand[]; + + // Actions + setHotkeysEnabled: (enabled: boolean) => void; + toggleHotkeys: () => void; + + openCommandPalette: () => void; + closeCommandPalette: () => void; + toggleCommandPalette: () => void; + + openShortcutsHelp: () => void; + closeShortcutsHelp: () => void; + toggleShortcutsHelp: () => void; + + setActiveContext: (context: HotkeyContext) => void; + + addRecentCommand: (command: Omit) => void; + clearRecentCommands: () => void; +} + +const MAX_RECENT_COMMANDS = 5; + +export const useHotkeyStore = create()( + persist( + (set) => ({ + hotkeysEnabled: true, + commandPaletteOpen: false, + shortcutsHelpOpen: false, + activeContext: 'global', + recentCommands: [], + + setHotkeysEnabled: (enabled) => set({ hotkeysEnabled: enabled }), + + toggleHotkeys: () => + set((state) => ({ hotkeysEnabled: !state.hotkeysEnabled })), + + openCommandPalette: () => set({ commandPaletteOpen: true }), + + closeCommandPalette: () => set({ commandPaletteOpen: false }), + + toggleCommandPalette: () => + set((state) => ({ commandPaletteOpen: !state.commandPaletteOpen })), + + openShortcutsHelp: () => set({ shortcutsHelpOpen: true }), + + closeShortcutsHelp: () => set({ shortcutsHelpOpen: false }), + + toggleShortcutsHelp: () => + set((state) => ({ shortcutsHelpOpen: !state.shortcutsHelpOpen })), + + setActiveContext: (context) => set({ activeContext: context }), + + addRecentCommand: (command) => + set((state) => { + // Remove if already exists + const filtered = state.recentCommands.filter( + (c) => c.id !== command.id + ); + // Add to beginning with timestamp + const updated = [ + { ...command, timestamp: Date.now() }, + ...filtered, + ].slice(0, MAX_RECENT_COMMANDS); + return { recentCommands: updated }; + }), + + clearRecentCommands: () => set({ recentCommands: [] }), + }), + { + name: 'books-hotkey-storage', + partialize: (state) => ({ + hotkeysEnabled: state.hotkeysEnabled, + recentCommands: state.recentCommands, + }), + } + ) +); + +// Selector hooks +export const useHotkeysEnabled = () => + useHotkeyStore((state) => state.hotkeysEnabled); + +export const useCommandPaletteOpen = () => + useHotkeyStore((state) => state.commandPaletteOpen); + +export const useShortcutsHelpOpen = () => + useHotkeyStore((state) => state.shortcutsHelpOpen); + +export const useActiveHotkeyContext = () => + useHotkeyStore((state) => state.activeContext); + +export const useRecentCommands = () => + useHotkeyStore((state) => state.recentCommands); diff --git a/frontend/src/styles/designTokens.ts b/frontend/src/styles/designTokens.ts new file mode 100644 index 0000000..40f3da2 --- /dev/null +++ b/frontend/src/styles/designTokens.ts @@ -0,0 +1,374 @@ +/** + * Design Tokens for Books Frontend + * + * This file contains all design system tokens for consistent styling. + * Import these tokens instead of hardcoding values in components. + */ + +// ============================================================================ +// SPACING +// ============================================================================ + +export const spacing = { + /** 4px - Minimal spacing, tight elements */ + xs: 4, + /** 8px - Small spacing, compact layouts */ + sm: 8, + /** 12px - Medium spacing, default padding */ + md: 12, + /** 16px - Large spacing, section gaps */ + lg: 16, + /** 24px - Extra large, major sections */ + xl: 24, + /** 32px - Maximum spacing, page sections */ + xxl: 32, +} as const; + +// Semantic spacing aliases +export const componentSpacing = { + /** Space between cards in a grid */ + cardGap: spacing.lg, + /** Padding inside cards */ + cardPadding: spacing.lg, + /** Space between form fields */ + formGap: spacing.md, + /** Space between table rows */ + tableRowGap: spacing.sm, + /** Modal internal padding */ + modalPadding: spacing.xl, + /** Page section margin */ + sectionMargin: spacing.xl, + /** Inline element gap (buttons, tags) */ + inlineGap: spacing.sm, +} as const; + +// Grid gutters (for Ant Design Row/Col) +export const gridGutter = { + /** Standard grid gutter [horizontal, vertical] */ + default: [spacing.lg, spacing.lg] as [number, number], + /** Compact grid gutter */ + compact: [spacing.sm, spacing.sm] as [number, number], + /** Wide grid gutter */ + wide: [spacing.xl, spacing.xl] as [number, number], +} as const; + +// ============================================================================ +// TYPOGRAPHY +// ============================================================================ + +export const typography = { + /** Page titles, main headings */ + h1: { + fontSize: 24, + fontWeight: 600, + lineHeight: 1.3, + }, + /** Section headings */ + h2: { + fontSize: 18, + fontWeight: 600, + lineHeight: 1.4, + }, + /** Card titles, subsections */ + h3: { + fontSize: 16, + fontWeight: 600, + lineHeight: 1.4, + }, + /** Default body text */ + body: { + fontSize: 14, + fontWeight: 400, + lineHeight: 1.5, + }, + /** Compact body text (tables, dense UI) */ + bodyCompact: { + fontSize: 13, + fontWeight: 400, + lineHeight: 1.4, + }, + /** Small text, captions, labels */ + caption: { + fontSize: 12, + fontWeight: 400, + lineHeight: 1.4, + }, + /** Very small text, helper text */ + small: { + fontSize: 11, + fontWeight: 400, + lineHeight: 1.3, + }, +} as const; + +export const fontWeight = { + normal: 400, + medium: 500, + semibold: 600, + bold: 700, +} as const; + +// ============================================================================ +// COLORS +// ============================================================================ + +// Primary palette +export const colors = { + primary: '#1677ff', + primaryHover: '#4096ff', + primaryActive: '#0958d9', + + success: '#52c41a', + successHover: '#73d13d', + successBg: '#f6ffed', + + error: '#ff4d4f', + errorHover: '#ff7875', + errorBg: '#fff2f0', + + warning: '#d48806', // WCAG AA compliant (darker orange for better contrast) + warningHover: '#faad14', + warningBg: '#fffbe6', + + info: '#1677ff', + infoBg: '#e6f4ff', +} as const; + +// Gray scale +export const grays = { + /** White */ + white: '#ffffff', + /** Very light gray - backgrounds */ + gray1: '#fafafa', + /** Light gray - subtle backgrounds */ + gray2: '#f5f5f5', + /** Border light */ + gray3: '#f0f0f0', + /** Border normal */ + gray4: '#d9d9d9', + /** Disabled text, placeholders - WCAG improved contrast (3.5:1 ratio) */ + gray5: '#999999', + /** Secondary text */ + gray6: '#6b6b6b', + /** Primary text */ + gray7: '#595959', + /** Title text */ + gray8: '#434343', + /** Heading text */ + gray9: '#262626', + /** Near black */ + gray10: '#1f1f1f', + /** Black */ + black: '#000000', +} as const; + +// Semantic colors +export const semanticColors = { + /** Page background */ + pageBg: grays.gray2, + /** Card/panel background */ + cardBg: grays.white, + /** Header background */ + headerBg: grays.white, + /** Sidebar background */ + sidebarBg: '#001529', + + /** Primary text color */ + textPrimary: grays.gray9, + /** Secondary/muted text */ + textSecondary: grays.gray6, + /** Disabled text */ + textDisabled: grays.gray5, + /** Placeholder text */ + textPlaceholder: grays.gray5, + + /** Default border color */ + border: grays.gray4, + /** Light border (dividers) */ + borderLight: grays.gray3, + + /** Hover background */ + hoverBg: grays.gray2, + /** Selected/active background */ + selectedBg: '#e6f4ff', +} as const; + +// Accounting-specific colors +export const accountingColors = { + /** Debit amounts (expenses, outgoing) */ + debit: colors.error, + /** Credit amounts (income, incoming) */ + credit: colors.success, + /** Neutral/zero amounts */ + neutral: grays.gray6, + /** Balance/total amounts */ + balance: colors.primary, + /** Warning states */ + warning: colors.warning, +} as const; + +// Status colors for badges/tags +export const statusColors = { + active: { color: 'green', bg: colors.successBg }, + inactive: { color: 'default', bg: grays.gray2 }, + pending: { color: 'orange', bg: colors.warningBg }, + error: { color: 'red', bg: colors.errorBg }, + info: { color: 'blue', bg: colors.infoBg }, + draft: { color: 'default', bg: grays.gray2 }, + closed: { color: 'default', bg: grays.gray2 }, +} as const; + +// ============================================================================ +// BORDERS & RADIUS +// ============================================================================ + +export const borderRadius = { + /** No radius */ + none: 0, + /** Small radius - buttons, inputs */ + sm: 4, + /** Medium radius - cards, modals */ + md: 6, + /** Large radius - containers */ + lg: 8, + /** Full radius - pills, avatars */ + full: 9999, +} as const; + +export const borders = { + /** Standard border */ + default: `1px solid ${grays.gray4}`, + /** Light border for dividers */ + light: `1px solid ${grays.gray3}`, + /** Focus ring */ + focus: `2px solid ${colors.primary}`, +} as const; + +// ============================================================================ +// SHADOWS & ELEVATION +// ============================================================================ + +export const shadows = { + /** No shadow */ + none: 'none', + /** Subtle shadow - cards at rest */ + sm: '0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02)', + /** Medium shadow - hover states, dropdowns */ + md: '0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05)', + /** Large shadow - modals, popovers */ + lg: '0 6px 16px -8px rgba(0, 0, 0, 0.08), 0 9px 28px 0 rgba(0, 0, 0, 0.05), 0 12px 48px 16px rgba(0, 0, 0, 0.03)', +} as const; + +// Elevation levels (semantic) +export const elevation = { + /** Flat, no elevation */ + flat: shadows.none, + /** Cards, panels */ + raised: shadows.sm, + /** Hover states, floating elements */ + floating: shadows.md, + /** Modals, dialogs */ + overlay: shadows.lg, +} as const; + +// ============================================================================ +// TRANSITIONS +// ============================================================================ + +export const transitions = { + /** Fast transitions - hover states */ + fast: '0.1s ease-in-out', + /** Normal transitions - most UI */ + normal: '0.2s ease-in-out', + /** Slow transitions - layout changes */ + slow: '0.3s ease-in-out', +} as const; + +// ============================================================================ +// BREAKPOINTS +// ============================================================================ + +export const breakpoints = { + xs: 480, + sm: 576, + md: 768, + lg: 992, + xl: 1200, + xxl: 1600, +} as const; + +// Ant Design Col span presets +export const colSpans = { + /** Full width on all sizes */ + full: { xs: 24, sm: 24, md: 24, lg: 24, xl: 24 }, + /** Half width, full on mobile */ + half: { xs: 24, sm: 12, md: 12, lg: 12, xl: 12 }, + /** Third width, half on tablet, full on mobile */ + third: { xs: 24, sm: 12, md: 8, lg: 8, xl: 8 }, + /** Quarter width, half on tablet, full on mobile */ + quarter: { xs: 24, sm: 12, md: 6, lg: 6, xl: 6 }, + /** KPI cards: 4 columns on desktop, 2 on tablet, 1 on mobile */ + kpiCard: { xs: 24, sm: 12, md: 12, lg: 6, xl: 6 }, +} as const; + +// ============================================================================ +// COMPONENT-SPECIFIC TOKENS +// ============================================================================ + +export const componentTokens = { + header: { + height: 64, + padding: spacing.xl, + }, + sidebar: { + width: 200, + collapsedWidth: 80, + }, + modal: { + widthSm: 400, + widthMd: 520, + widthLg: 720, + }, + table: { + cellPadding: spacing.sm, + headerBg: grays.gray1, + rowHoverBg: grays.gray2, + }, + card: { + padding: spacing.lg, + paddingSm: spacing.md, + }, +} as const; + +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +/** + * Get spacing value in pixels + */ +export const getSpacing = (size: keyof typeof spacing): number => spacing[size]; + +/** + * Create consistent margin/padding style object + */ +export const createSpacingStyle = ( + top?: keyof typeof spacing, + right?: keyof typeof spacing, + bottom?: keyof typeof spacing, + left?: keyof typeof spacing +) => ({ + marginTop: top ? spacing[top] : undefined, + marginRight: right ? spacing[right] : undefined, + marginBottom: bottom ? spacing[bottom] : undefined, + marginLeft: left ? spacing[left] : undefined, +}); + +/** + * Format amount with accounting colors + */ +export const getAmountColor = (amount: number): string => { + if (amount > 0) return accountingColors.credit; + if (amount < 0) return accountingColors.debit; + return accountingColors.neutral; +}; diff --git a/frontend/src/types/order.ts b/frontend/src/types/order.ts new file mode 100644 index 0000000..9c46ee1 --- /dev/null +++ b/frontend/src/types/order.ts @@ -0,0 +1,74 @@ +/** + * Order types for the Order Management System + */ + +export type OrderStatus = 'draft' | 'confirmed' | 'partially_invoiced' | 'fully_invoiced' | 'cancelled'; + +export interface OrderLine { + lineNumber: number; + productId?: string; + description: string; + quantity: number; + unit?: string; + unitPrice: number; + discountPercent: number; + vatCode: string; + accountId?: string; + amountExVat: number; + amountVat: number; + amountTotal: number; + // Invoice tracking + isInvoiced: boolean; + invoiceId?: string; + invoicedAt?: string; +} + +export interface Order { + id: string; + companyId: string; + fiscalYearId?: string; + customerId: string; + customerName: string; + customerNumber: string; + orderNumber: string; + orderDate?: string; + expectedDeliveryDate?: string; + status: OrderStatus; + amountExVat: number; + amountVat: number; + amountTotal: number; + uninvoicedAmount?: number; + uninvoicedLineCount?: number; + currency: string; + notes?: string; + reference?: string; + confirmedAt?: string; + confirmedBy?: string; + cancelledAt?: string; + cancelledReason?: string; + cancelledBy?: string; + createdBy: string; + updatedBy?: string; + createdAt: string; + updatedAt: string; + lines: OrderLine[]; + uninvoicedLines?: OrderLine[]; +} + +// Status labels in Danish +export const ORDER_STATUS_LABELS: Record = { + draft: 'Kladde', + confirmed: 'Bekræftet', + partially_invoiced: 'Delvist faktureret', + fully_invoiced: 'Fuldt faktureret', + cancelled: 'Annulleret', +}; + +// Status colors for Ant Design tags +export const ORDER_STATUS_COLORS: Record = { + draft: 'default', + confirmed: 'processing', + partially_invoiced: 'warning', + fully_invoiced: 'success', + cancelled: 'error', +}; diff --git a/frontend/src/types/product.ts b/frontend/src/types/product.ts new file mode 100644 index 0000000..f7021af --- /dev/null +++ b/frontend/src/types/product.ts @@ -0,0 +1,20 @@ +/** + * Product types for the product catalog + */ + +export interface Product { + id: string; + companyId: string; + productNumber?: string; + name: string; + description?: string; + unitPrice: number; + vatCode: string; + unit?: string; + defaultAccountId?: string; + ean?: string; + manufacturer?: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 916a2ee..a903e1d 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/auth/protectedroute.tsx","./src/components/layout/applayout.tsx","./src/components/layout/companyswitcher.tsx","./src/components/layout/fiscalyearselector.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/modals/closefiscalyearwizard.tsx","./src/components/modals/createfiscalyearmodal.tsx","./src/components/simple-booking/accountquickpicker.tsx","./src/components/simple-booking/banktransactioncard.tsx","./src/components/simple-booking/quickbookmodal.tsx","./src/components/simple-booking/splitbookmodal.tsx","./src/components/simple-booking/index.ts","./src/components/tables/datatable.tsx","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/useperiod.ts","./src/lib/accounting.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/bankafstemning.tsx","./src/pages/dashboard.tsx","./src/pages/hurtigbogforing.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/settings.tsx","./src/services/authservice.ts","./src/stores/authstore.ts","./src/stores/companystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/simplebookingstore.ts","./src/stores/uistore.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/periods.ts","./src/types/ui.ts","./src/types/vat.ts"],"errors":true,"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/documentprocessing.ts","./src/api/mutations/accountmutations.ts","./src/api/mutations/bankconnectionmutations.ts","./src/api/mutations/companymutations.ts","./src/api/mutations/customermutations.ts","./src/api/mutations/draftmutations.ts","./src/api/mutations/fiscalyearmutations.ts","./src/api/mutations/invoicemutations.ts","./src/api/mutations/ordermutations.ts","./src/api/mutations/productmutations.ts","./src/api/mutations/saftmutations.ts","./src/api/queries/accountqueries.ts","./src/api/queries/bankconnectionqueries.ts","./src/api/queries/banktransactionqueries.ts","./src/api/queries/companyqueries.ts","./src/api/queries/customerqueries.ts","./src/api/queries/draftqueries.ts","./src/api/queries/fiscalyearqueries.ts","./src/api/queries/invoicequeries.ts","./src/api/queries/orderqueries.ts","./src/api/queries/productqueries.ts","./src/api/queries/vatqueries.ts","./src/components/auth/companyguard.tsx","./src/components/auth/protectedroute.tsx","./src/components/bank-reconciliation/documentuploadmodal.tsx","./src/components/company/useraccessmanager.tsx","./src/components/kassekladde/balanceimpactpanel.tsx","./src/components/layout/applayout.tsx","./src/components/layout/companyswitcher.tsx","./src/components/layout/fiscalyearselector.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/modals/closefiscalyearwizard.tsx","./src/components/modals/createfiscalyearmodal.tsx","./src/components/settings/bankconnectionstab.tsx","./src/components/shared/amounttext.tsx","./src/components/shared/attachmentupload.tsx","./src/components/shared/commandpalette.tsx","./src/components/shared/confirmationmodal.tsx","./src/components/shared/demodatadisclaimer.tsx","./src/components/shared/emptystate.tsx","./src/components/shared/errorboundary.tsx","./src/components/shared/fullpagedropzone.tsx","./src/components/shared/hotkeyprovider.tsx","./src/components/shared/isodatepicker.tsx","./src/components/shared/pageheader.tsx","./src/components/shared/periodfilter.tsx","./src/components/shared/shortcuttooltip.tsx","./src/components/shared/shortcutshelpmodal.tsx","./src/components/shared/skeletonloader.tsx","./src/components/shared/statisticcard.tsx","./src/components/shared/statusbadge.tsx","./src/components/shared/index.ts","./src/components/tables/datatable.tsx","./src/hooks/useautosave.ts","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/usepagehotkeys.ts","./src/hooks/useperiod.ts","./src/hooks/useresponsivemodal.ts","./src/lib/accounting.ts","./src/lib/errorhandling.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/keyboardshortcuts.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/admin.tsx","./src/pages/bankafstemning.tsx","./src/pages/companysetupwizard.tsx","./src/pages/dashboard.tsx","./src/pages/eksport.tsx","./src/pages/fakturaer.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/kreditnotaer.tsx","./src/pages/kunder.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/ordrer.tsx","./src/pages/produkter.tsx","./src/pages/settings.tsx","./src/pages/usersettings.tsx","./src/services/authservice.ts","./src/stores/authstore.ts","./src/stores/companystore.ts","./src/stores/hotkeystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/uistore.ts","./src/styles/designtokens.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/order.ts","./src/types/periods.ts","./src/types/product.ts","./src/types/ui.ts","./src/types/vat.ts"],"errors":true,"version":"5.6.3"} \ No newline at end of file