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 <noreply@anthropic.com>
This commit is contained in:
Nicolaj Hartmann 2026-01-30 22:20:03 +01:00
parent 1f75c5d791
commit 381156ade7
71 changed files with 16898 additions and 5 deletions

44
.beads/.gitignore vendored Normal file
View file

@ -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.

81
.beads/README.md Normal file
View file

@ -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 <issue-id>
# Update issue status
bd update <issue-id> --status in_progress
bd update <issue-id> --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* ⚡

62
.beads/config.yaml Normal file
View file

@ -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 <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

View file

4
.beads/metadata.json Normal file
View file

@ -0,0 +1,4 @@
{
"database": "beads.db",
"jsonl_export": "issues.jsonl"
}

3
.gitattributes vendored Normal file
View file

@ -0,0 +1,3 @@
# Use bd merge for beads JSONL files
.beads/issues.jsonl merge=beads

123
CLAUDE.md Normal file
View file

@ -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 <id> --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 <id> # View issue details
bd update <id> --status in_progress # Claim work
bd close <id> # 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 <id> --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 <id>` 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

View file

@ -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<DocumentProcessingResult> {
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 };
}

View file

@ -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<CreateAccountResponse>(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 }) });
},
});
}

View file

@ -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<StartBankConnectionResponse>(
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<CompleteBankConnectionResponse>(
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<DisconnectBankConnectionResponse>(
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<LinkBankAccountResponse>(
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<ReconnectBankConnectionResponse>(
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<ArchiveBankConnectionResponse>(
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 }),
});
},
});
}

View file

@ -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<CreateCompanyResponse>(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<UpdateCompanyResponse>(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<GrantUserAccessResponse>(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<ChangeUserRoleResponse>(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<RevokeUserAccessResponse>(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<UpdateCompanyBankDetailsResponse>(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') });
},
});
}

View file

@ -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<CreateCustomerResponse>(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<UpdateCustomerResponse>(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<DeactivateCustomerResponse>(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<ReactivateCustomerResponse>(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 }),
});
},
});
}

View file

@ -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<CreateDraftResponse>(
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<UpdateDraftResponse>(
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<JournalEntryDraft[]>(
{ 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<PostDraftResponse>(
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<DiscardDraftResponse>(
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 }),
});
},
});
}

View file

@ -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<CreateFiscalYearResponse>(
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<CloseFiscalYearResponse>(
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<ReopenFiscalYearResponse>(
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<LockFiscalYearResponse>(
LOCK_FISCAL_YEAR_MUTATION,
{ id }
);
return transformFiscalYear(data.lockFiscalYear);
},
onSuccess: (updatedFiscalYear) => {
queryClient.invalidateQueries({
queryKey: createQueryKey('fiscalYears', { companyId: updatedFiscalYear.companyId }),
});
},
});
}

View file

@ -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<CreateInvoiceResponse>(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<AddInvoiceLineResponse>(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<UpdateInvoiceLineResponse>(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<RemoveInvoiceLineResponse>(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<SendInvoiceResponse>(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<ReceivePaymentResponse>(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<VoidInvoiceResponse>(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<CreateCreditNoteResponse>(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<IssueCreditNoteResponse>(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<ApplyCreditNoteResponse>(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 }),
});
},
});
}

View file

@ -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<CreateOrderResponse>(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<AddOrderLineResponse>(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<UpdateOrderLineResponse>(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<RemoveOrderLineResponse>(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<ConfirmOrderResponse>(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<InvoiceOrderLinesResponse>(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<CancelOrderResponse>(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 }),
});
},
});
}

View file

@ -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<CreateProductResponse>(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<UpdateProductResponse>(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<DeactivateProductResponse>(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<ReactivateProductResponse>(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 }),
});
},
});
}

View file

@ -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<ExportSaftResponse>(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);
}

View file

@ -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<UseQueryOptions<Account[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('accounts', { companyId }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<AccountsResponse>(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<UseQueryOptions<Account[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('activeAccounts', { companyId }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<ActiveAccountsResponse>(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<UseQueryOptions<AccountBalance[], Error>, '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<AccountBalancesResponse>(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,
});
}

View file

@ -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<UseQueryOptions<Aspsp[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('availableBanks', { country }),
queryFn: async () => {
const data = await fetchGraphQL<AvailableBanksResponse>(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<UseQueryOptions<BankConnection[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('bankConnections', { companyId }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<BankConnectionsResponse>(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<UseQueryOptions<BankConnection[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('activeBankConnections', { companyId }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<ActiveBankConnectionsResponse>(ACTIVE_BANK_CONNECTIONS_QUERY, { companyId });
return data.activeBankConnections;
},
enabled: !!companyId,
...options,
});
}

View file

@ -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<UseQueryOptions<QuickBookingTransaction[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('pendingBankTransactions', { companyId }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<PendingBankTransactionsResponse>(
PENDING_BANK_TRANSACTIONS_QUERY,
{ companyId }
);
return data.pendingBankTransactions.map(transformToQuickBookingTransaction);
},
enabled: !!companyId,
...options,
});
}
export function useBankTransactions(
companyId: string | undefined,
options?: Omit<UseQueryOptions<QuickBookingTransaction[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('bankTransactions', { companyId }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<BankTransactionsResponse>(
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<SyncBankTransactionsResponse>(
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',
});
},
});
}

View file

@ -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<UseQueryOptions<CompanyWithRole[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('myCompanies'),
queryFn: async () => {
const data = await fetchGraphQL<MyCompaniesResponse>(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<UseQueryOptions<UserCompanyAccess[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('companyUsers', { companyId }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<CompanyUsersResponse>(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<UseQueryOptions<Company | null, Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('company', { id }),
queryFn: async () => {
if (!id) return null;
const data = await fetchGraphQL<CompanyResponse>(COMPANY_QUERY, { id });
return data.company;
},
enabled: !!id,
...options,
});
}

View file

@ -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<UseQueryOptions<Customer[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('customers', { companyId }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<CustomersResponse>(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<UseQueryOptions<Customer[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('activeCustomers', { companyId }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<ActiveCustomersResponse>(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<UseQueryOptions<Customer | null, Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('customer', { id }),
queryFn: async () => {
if (!id) return null;
const data = await fetchGraphQL<CustomerResponse>(CUSTOMER_QUERY, { id });
return data.customer;
},
enabled: !!id,
...options,
});
}

View file

@ -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<UseQueryOptions<JournalEntryDraft[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('journalEntryDrafts', { companyId }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<JournalEntryDraftsResponse>(
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<UseQueryOptions<JournalEntryDraft | null, Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('journalEntryDraft', { id }),
queryFn: async () => {
if (!id) return null;
const data = await fetchGraphQL<JournalEntryDraftResponse>(
JOURNAL_ENTRY_DRAFT_QUERY,
{ id }
);
return data.journalEntryDraft;
},
enabled: !!id,
...options,
});
}

View file

@ -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<UseQueryOptions<FiscalYear[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('fiscalYears', { companyId }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<FiscalYearsQueryResponse>(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<UseQueryOptions<FiscalYear | null, Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('fiscalYear', { id }),
queryFn: async () => {
if (!id) return null;
const data = await fetchGraphQL<FiscalYearQueryResponse>(FISCAL_YEAR_QUERY, { id });
return data.fiscalYear ? transformFiscalYear(data.fiscalYear) : null;
},
enabled: !!id,
...options,
});
}

View file

@ -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<UseQueryOptions<Invoice[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('invoices', { companyId, ...filters }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<InvoicesResponse>(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<UseQueryOptions<Invoice | null, Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('invoice', { id }),
queryFn: async () => {
if (!id) return null;
const data = await fetchGraphQL<InvoiceResponse>(INVOICE_QUERY, { id });
return data.invoice;
},
enabled: !!id,
...options,
});
}
/**
* Hook to fetch invoices by customer.
*/
export function useInvoicesByCustomer(
customerId: string | undefined,
options?: Omit<UseQueryOptions<Invoice[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('invoicesByCustomer', { customerId }),
queryFn: async () => {
if (!customerId) return [];
const data = await fetchGraphQL<InvoicesByCustomerResponse>(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<UseQueryOptions<Invoice[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('creditNotes', { companyId }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<CreditNotesResponse>(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<UseQueryOptions<Invoice[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('unappliedCreditNotes', { companyId }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<UnappliedCreditNotesResponse>(UNAPPLIED_CREDIT_NOTES_QUERY, {
companyId,
});
return data.unappliedCreditNotes;
},
enabled: !!companyId,
...options,
});
}

View file

@ -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<UseQueryOptions<Order[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('orders', { companyId, ...filters }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<OrdersResponse>(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<UseQueryOptions<Order | null, Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('order', { id }),
queryFn: async () => {
if (!id) return null;
const data = await fetchGraphQL<OrderResponse>(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<UseQueryOptions<Order | null, Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('orderByNumber', { companyId, orderNumber }),
queryFn: async () => {
if (!companyId || !orderNumber) return null;
const data = await fetchGraphQL<OrderByNumberResponse>(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<UseQueryOptions<Order[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('ordersByCustomer', { customerId, ...filters }),
queryFn: async () => {
if (!customerId) return [];
const data = await fetchGraphQL<OrdersByCustomerResponse>(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<UseQueryOptions<Order[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('confirmedOrders', { companyId }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<ConfirmedOrdersResponse>(CONFIRMED_ORDERS_QUERY, {
companyId,
});
return data.confirmedOrders;
},
enabled: !!companyId,
...options,
});
}

View file

@ -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<UseQueryOptions<Product[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('products', { companyId }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<ProductsResponse>(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<UseQueryOptions<Product[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('activeProducts', { companyId }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<ProductsResponse>(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<UseQueryOptions<Product | null, Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('product', { id }),
queryFn: async () => {
if (!id) return null;
const data = await fetchGraphQL<ProductResponse>(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<UseQueryOptions<string[], Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: createQueryKey('manufacturers', { companyId, searchTerm }),
queryFn: async () => {
if (!companyId) return [];
const data = await fetchGraphQL<ManufacturersResponse>(MANUFACTURERS_QUERY, {
companyId,
searchTerm: searchTerm || undefined,
});
return data.manufacturers;
},
enabled: !!companyId,
...options,
});
}

View file

@ -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<VatReportQueryResponse>(VAT_REPORT_QUERY, {
companyId,
periodStart,
periodEnd,
});
return data.vatReport;
},
enabled: !!companyId && !!periodStart && !!periodEnd,
});
}

View file

@ -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 (
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
gap: 16,
}}
>
<Spin size="large" />
<Text type="secondary">Henter dine virksomheder...</Text>
</div>
);
}
// Show error state
if (isError) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
gap: 16,
}}
>
<Text type="danger">
Kunne ikke hente virksomheder: {error?.message || 'Ukendt fejl'}
</Text>
</div>
);
}
return <>{children}</>;
}

View file

@ -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 (
<Modal
open={visible}
title={
<Space>
<FileOutlined />
Behandler dokument
</Space>
}
footer={null}
closable={false}
maskClosable={false}
width={500}
>
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" />
<div style={{ marginTop: 16 }}>
<Text type="secondary">Analyserer {fileName || 'dokument'}...</Text>
</div>
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
AI-tjenesten udtraekker information fra dokumentet
</Text>
</div>
</div>
</Modal>
);
}
// Error state
if (error) {
return (
<Modal
open={visible}
title={
<Space>
<ExclamationCircleOutlined style={{ color: '#ff4d4f' }} />
Fejl ved behandling
</Space>
}
footer={[
<Button key="close" onClick={onClose}>
Luk
</Button>,
]}
onCancel={onClose}
width={500}
>
<Result
status="error"
title="Dokumentet kunne ikke behandles"
subTitle={error}
/>
</Modal>
);
}
// Duplicate detection
if (result?.isDuplicate) {
return (
<Modal
open={visible}
title={
<Space>
<ExclamationCircleOutlined style={{ color: '#faad14' }} />
Dokument allerede behandlet
</Space>
}
footer={[
<Button key="close" onClick={onClose}>
Luk
</Button>,
<Button key="view" type="primary" onClick={onConfirm}>
Gaa til kladde
</Button>,
]}
onCancel={onClose}
width={500}
>
<Alert
message="Dette dokument er allerede blevet behandlet"
description={result.message || 'Du kan se den eksisterende kladde ved at klikke nedenfor.'}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
{result.draftId && <Text type="secondary">Kladde ID: {result.draftId}</Text>}
</Modal>
);
}
// Success state with comprehensive verification view
const previewColumns = [
{
title: 'Konto',
key: 'account',
render: (_: unknown, record: JournalPreviewLine) => (
<span>
<Text strong>{record.accountNumber}</Text>
<Text style={{ marginLeft: 8 }}>{record.accountName}</Text>
</span>
),
},
{
title: 'Debet',
dataIndex: 'debit',
key: 'debit',
align: 'right' as const,
width: 120,
render: (value: number) =>
value > 0 ? <AmountText amount={value} type="debit" showCurrency={false} /> : null,
},
{
title: 'Kredit',
dataIndex: 'credit',
key: 'credit',
align: 'right' as const,
width: 120,
render: (value: number) =>
value > 0 ? <AmountText amount={value} type="credit" showCurrency={false} /> : null,
},
];
return (
<Modal
open={visible}
title={
<Space>
<CheckCircleOutlined style={{ color: '#52c41a' }} />
Dokument analyseret
</Space>
}
footer={[
<Button key="cancel" onClick={handleCancel}>
Annuller
</Button>,
<Button key="draft" onClick={handleSaveAsDraft}>
Tilfoej til kladde
</Button>,
<Button
key="post"
type="primary"
onClick={handlePostNow}
loading={isPosting}
disabled={!result?.draftId || (journalLines.length > 0 && !isBalanced)}
>
Godkend og bogfoer
</Button>,
]}
onCancel={handleCancel}
{...responsiveModalProps}
>
{result && (
<div>
{/* Section 1: Extracted Document Info */}
{result.extraction && (
<ExtractedInfoSection extraction={result.extraction} />
)}
{/* Section 2: Journal Entry Preview */}
<Divider style={{ margin: `${spacing.md}px 0` }} />
<div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: spacing.sm }}>
<Title level={5} style={{ margin: 0, marginRight: spacing.sm }}>
Foreslaaet bogfoering
</Title>
{journalLines.length > 0 && (
isBalanced ? (
<StatusBadge status="success" text="Afstemmer" />
) : (
<StatusBadge status="error" text="Ubalance" />
)
)}
</div>
{journalLines.length > 0 ? (
<Table
dataSource={journalLines}
columns={previewColumns}
pagination={false}
size="small"
bordered
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell index={0}>
<Text strong>I alt</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<AmountText
amount={totalDebit}
type="debit"
showCurrency={false}
style={{ fontWeight: 'bold' }}
/>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="right">
<AmountText
amount={totalCredit}
type="credit"
showCurrency={false}
style={{ fontWeight: 'bold' }}
/>
</Table.Summary.Cell>
</Table.Summary.Row>
)}
/>
) : (
<Alert
message="Ingen kontobogfoering foreslaaet"
description="AI kunne ikke foreslaa konti til dette dokument. Du kan tilfoeje dokumentet til kladden og bogfoere manuelt."
type="info"
showIcon
icon={<InfoCircleOutlined />}
/>
)}
</div>
{/* Section 3: Bank Transaction Match */}
{result.bankTransactionMatch && (
<>
<Divider style={{ margin: `${spacing.md}px 0` }} />
<Title level={5} style={{ marginBottom: spacing.sm }}>
<LinkOutlined style={{ marginRight: 8 }} />
Matchet banktransaktion
</Title>
<div
style={{
padding: 12,
backgroundColor: '#e6f7ff',
borderRadius: 6,
border: '1px solid #91d5ff',
}}
>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Space>
<BankOutlined />
<Text>
{result.bankTransactionMatch.description ||
result.bankTransactionMatch.counterparty}
</Text>
</Space>
<AmountText amount={result.bankTransactionMatch.amount} showSign />
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{result.bankTransactionMatch.date &&
formatDateShort(result.bankTransactionMatch.date)}
{result.bankTransactionMatch.counterparty &&
` - ${result.bankTransactionMatch.counterparty}`}
</Text>
</Space>
</div>
</>
)}
{/* No match found */}
{!result.bankTransactionMatch && result.extraction?.amount && (
<>
<Divider style={{ margin: `${spacing.md}px 0` }} />
<Alert
message="Ingen matchende banktransaktion fundet"
description={`Der blev ikke fundet en pending banktransaktion paa ${formatCurrency(result.extraction.amount)}. Kladden er oprettet og kan matches manuelt.`}
type="info"
showIcon
icon={<InfoCircleOutlined />}
/>
</>
)}
{/* Account suggestion confidence */}
{result.accountSuggestion && (
<div style={{ marginTop: spacing.sm }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Kontoforslag baseret paa AI-analyse (
{Math.round(result.accountSuggestion.confidence * 100)}% sikkerhed)
</Text>
</div>
)}
</div>
)}
</Modal>
);
}
/**
* Sub-component for displaying extracted document information
*/
function ExtractedInfoSection({
extraction,
}: {
extraction: NonNullable<DocumentProcessingResult['extraction']>;
}) {
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 (
<div>
<Title level={5} style={{ marginBottom: 12 }}>
<FileOutlined style={{ marginRight: 8 }} />
Udtrukket information
</Title>
<Descriptions
bordered
size="small"
column={{ xs: 1, sm: 2 }}
style={{ marginBottom: hasLineItems ? 12 : 0 }}
>
{extraction.vendor && (
<Descriptions.Item label="Leverandoer" span={extraction.vendorCvr ? 1 : 2}>
<Text strong>{extraction.vendor}</Text>
</Descriptions.Item>
)}
{extraction.vendorCvr && (
<Descriptions.Item label="CVR">
<Text copyable={{ text: extraction.vendorCvr }}>
{formatCVR(extraction.vendorCvr)}
</Text>
</Descriptions.Item>
)}
{extraction.invoiceNumber && (
<Descriptions.Item label="Fakturanr.">
{extraction.invoiceNumber}
</Descriptions.Item>
)}
{extraction.documentType && (
<Descriptions.Item label="Type">
<Tag>{mapDocumentType(extraction.documentType)}</Tag>
</Descriptions.Item>
)}
{extraction.date && (
<Descriptions.Item label="Dokumentdato">
<Space>
<CalendarOutlined />
{formatDateShort(extraction.date)}
</Space>
</Descriptions.Item>
)}
{extraction.dueDate && (
<Descriptions.Item label="Forfaldsdato">
<Space>
<CalendarOutlined />
{formatDateShort(extraction.dueDate)}
</Space>
</Descriptions.Item>
)}
</Descriptions>
{/* Amount breakdown */}
<div
style={{
marginTop: 12,
padding: 12,
backgroundColor: '#fafafa',
borderRadius: 6,
border: '1px solid #f0f0f0',
}}
>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{extraction.amountExVat != null && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">Beloeb ekskl. moms</Text>
<AmountText amount={extraction.amountExVat} />
</div>
)}
{extraction.vatAmount != null && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">Moms (25%)</Text>
<AmountText amount={extraction.vatAmount} />
</div>
)}
{extraction.amount != null && (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
borderTop:
extraction.amountExVat != null || extraction.vatAmount != null
? '1px solid #e8e8e8'
: undefined,
paddingTop:
extraction.amountExVat != null || extraction.vatAmount != null ? 4 : 0,
marginTop:
extraction.amountExVat != null || extraction.vatAmount != null ? 4 : 0,
}}
>
<Text strong>Beloeb inkl. moms</Text>
<Text
strong
style={{
fontSize: 16,
color: accountingColors.debit,
}}
>
{formatCurrency(extraction.amount)}
</Text>
</div>
)}
{extraction.currency && extraction.currency !== 'DKK' && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">Valuta</Text>
<Tag>{extraction.currency}</Tag>
</div>
)}
{extraction.paymentReference && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">Betalingsreference</Text>
<Text copyable style={{ fontFamily: 'monospace' }}>
{extraction.paymentReference}
</Text>
</div>
)}
</Space>
</div>
{/* Line items (collapsible) */}
{hasLineItems && (
<Collapse
ghost
style={{ marginTop: 12 }}
items={[
{
key: 'lineItems',
label: `Fakturalinjer (${extraction.lineItems!.length})`,
children: (
<Table
dataSource={extraction.lineItems!.map((item: ExtractedLineItem, idx: number) => ({
...item,
key: idx,
}))}
columns={lineItemColumns}
pagination={false}
size="small"
bordered
/>
),
},
]}
/>
)}
</div>
);
}
function mapDocumentType(type: string): string {
const typeMap: Record<string, string> = {
invoice: 'Faktura',
receipt: 'Kvittering',
credit_note: 'Kreditnota',
bank_statement: 'Kontoudtog',
contract: 'Kontrakt',
other: 'Andet',
};
return typeMap[type] || type;
}
export default DocumentUploadModal;

View file

@ -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: <CrownOutlined />, description: 'Fuld adgang inkl. brugeradministration' },
{ value: 'accountant', label: 'Bogholder', icon: <UserOutlined />, description: 'Kan bogføre og redigere' },
{ value: 'viewer', label: 'Læser', icon: <EyeOutlined />, 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<UserCompanyAccess | null>(null);
const [form] = Form.useForm<InviteFormValues>();
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<UserCompanyAccess> = [
{
title: 'Bruger',
dataIndex: 'userId',
key: 'userId',
render: (userId: string) => (
<Space>
<UserOutlined />
<Text>{decodeHtmlEntities(userId)}</Text>
</Space>
),
},
{
title: 'Rolle',
dataIndex: 'role',
key: 'role',
render: (role: CompanyRole, record) => {
if (editingUser?.userId === record.userId) {
return (
<Select
defaultValue={role}
style={{ width: 140 }}
onChange={(newRole) => handleChangeRole(record.userId, newRole)}
onBlur={() => setEditingUser(null)}
autoFocus
options={roleOptions.map((opt) => ({
value: opt.value,
label: (
<Space>
{opt.icon}
{opt.label}
</Space>
),
}))}
/>
);
}
return (
<Tag color={getRoleColor(role)} icon={
role === 'owner' ? <CrownOutlined /> :
role === 'accountant' ? <UserOutlined /> :
<EyeOutlined />
}>
{getRoleLabel(role)}
</Tag>
);
},
},
{
title: 'Tilføjet',
dataIndex: 'grantedAt',
key: 'grantedAt',
render: (date: string) => dayjs(date).format('D. MMM YYYY'),
},
{
title: 'Tilføjet af',
dataIndex: 'grantedBy',
key: 'grantedBy',
render: (grantedBy: string) => (
<Text type="secondary">{decodeHtmlEntities(grantedBy)}</Text>
),
},
{
title: 'Handlinger',
key: 'actions',
width: 120,
render: (_, record) => (
<Space>
<Tooltip title="Ændr rolle">
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => setEditingUser(record)}
disabled={!canAdmin}
/>
</Tooltip>
<Popconfirm
title="Fjern adgang"
description={`Er du sikker på at du vil fjerne ${decodeHtmlEntities(record.userId)}'s adgang?`}
onConfirm={() => handleRevoke(record.userId)}
okText="Ja, fjern"
cancelText="Annuller"
okButtonProps={{ danger: true }}
>
<Tooltip title="Fjern adgang">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
disabled={!canAdmin}
/>
</Tooltip>
</Popconfirm>
</Space>
),
},
];
return (
<>
<Modal
title={
<Space>
<TeamOutlined />
<span>Brugeradgang - {activeCompany?.name}</span>
</Space>
}
open={open}
onCancel={onClose}
width={800}
footer={[
<Button key="close" onClick={onClose}>
Luk
</Button>,
canAdmin && (
<Button
key="invite"
type="primary"
icon={<UserAddOutlined />}
onClick={() => setInviteModalOpen(true)}
>
Tilføj bruger
</Button>
),
]}
>
{!canAdmin && (
<Alert
message="Kun ejere kan administrere brugeradgang"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Table
dataSource={users}
columns={columns}
rowKey="id"
loading={isLoading}
pagination={false}
size="middle"
locale={{
emptyText: 'Ingen brugere har adgang til denne virksomhed endnu',
}}
/>
</Modal>
{/* Invite User Modal */}
<Modal
title={
<Space>
<UserAddOutlined />
<span>Tilføj bruger</span>
</Space>
}
open={inviteModalOpen}
onCancel={() => {
setInviteModalOpen(false);
form.resetFields();
}}
footer={null}
>
<Form
form={form}
layout="vertical"
onFinish={handleInvite}
initialValues={{ role: 'viewer' }}
>
<Form.Item
name="userId"
label="Bruger-ID eller email"
rules={[{ required: true, message: 'Indtast bruger-ID eller email' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="fx. bruger@example.com"
/>
</Form.Item>
<Form.Item
name="role"
label="Rolle"
rules={[{ required: true, message: 'Vælg en rolle' }]}
>
<Select
options={roleOptions.map((opt) => ({
value: opt.value,
label: (
<Space direction="vertical" size={0}>
<Space>
{opt.icon}
<Text strong>{opt.label}</Text>
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
{opt.description}
</Text>
</Space>
),
}))}
optionRender={(option) => (
<div style={{ padding: '4px 0' }}>
{option.label}
</div>
)}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => {
setInviteModalOpen(false);
form.resetFields();
}}>
Annuller
</Button>
<Button
type="primary"
htmlType="submit"
loading={grantAccess.isPending}
>
Tilføj bruger
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</>
);
}

View file

@ -0,0 +1,259 @@
// BalanceImpactPanel - Shows the balance impact of journal entry draft lines
import { useMemo } from 'react';
import { Collapse, Table, Typography, Badge, Spin, Alert, Space } from 'antd';
import { FundViewOutlined, CheckCircleOutlined, WarningOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { useAccountBalances } from '@/api/queries/accountQueries';
import { AmountText } from '@/components/shared/AmountText';
import { spacing } from '@/styles/designTokens';
import type { Account } from '@/types/accounting';
const { Text } = Typography;
// Matches the EditableLine interface from Kassekladde.tsx
interface EditableLine {
lineNumber: number;
accountId?: string;
debitAmount: number;
creditAmount: number;
description?: string;
vatCode?: string;
}
interface AccountImpact {
accountId: string;
accountNumber: string;
accountName: string;
currentBalance: number;
debitChange: number;
creditChange: number;
netChange: number;
newBalance: number;
}
interface BalanceImpactPanelProps {
lines: EditableLine[];
accounts: Account[];
companyId: string;
defaultOpen?: boolean;
}
/**
* Panel that displays the balance impact on accounts affected by journal entry lines.
* Shows current balance, change, and projected new balance for each affected account.
*/
export function BalanceImpactPanel({
lines,
accounts,
companyId,
defaultOpen = true,
}: BalanceImpactPanelProps) {
// Fetch current account balances (no date filter = all time accumulated)
const { data: accountBalances, isLoading, error } = useAccountBalances(companyId);
// Calculate impacts for each affected account
const { impacts, totals, isBalanced } = useMemo(() => {
// Find all unique account IDs from lines
const affectedAccountIds = new Set(
lines.filter((l) => l.accountId).map((l) => l.accountId!)
);
if (affectedAccountIds.size === 0) {
return { impacts: [], totals: { debit: 0, credit: 0 }, isBalanced: true };
}
const calculatedImpacts: AccountImpact[] = [];
let totalDebit = 0;
let totalCredit = 0;
for (const accountId of affectedAccountIds) {
const account = accounts.find((a) => a.id === accountId);
if (!account) continue;
// Get current balance from the balances query
const balanceData = accountBalances?.find((b) => b.id === accountId);
const currentBalance = balanceData?.netChange ?? 0;
// Sum up all changes for this account from the draft lines
const linesForAccount = lines.filter((l) => l.accountId === accountId);
const debitChange = linesForAccount.reduce((sum, l) => sum + (l.debitAmount || 0), 0);
const creditChange = linesForAccount.reduce((sum, l) => sum + (l.creditAmount || 0), 0);
const netChange = debitChange - creditChange;
totalDebit += debitChange;
totalCredit += creditChange;
calculatedImpacts.push({
accountId,
accountNumber: account.accountNumber,
accountName: account.name,
currentBalance,
debitChange,
creditChange,
netChange,
newBalance: currentBalance + netChange,
});
}
// Sort by account number
calculatedImpacts.sort((a, b) => a.accountNumber.localeCompare(b.accountNumber));
return {
impacts: calculatedImpacts,
totals: { debit: totalDebit, credit: totalCredit },
isBalanced: Math.abs(totalDebit - totalCredit) < 0.01,
};
}, [lines, accounts, accountBalances]);
// Don't render if no lines with accounts
if (lines.length === 0 || !lines.some((l) => l.accountId)) {
return null;
}
const columns: ColumnsType<AccountImpact> = [
{
title: 'Konto',
key: 'account',
render: (_, record) => (
<span>
<Text strong>{record.accountNumber}</Text>
<Text style={{ marginLeft: spacing.sm }}>{record.accountName}</Text>
</span>
),
},
{
title: 'Aktuel saldo',
dataIndex: 'currentBalance',
key: 'currentBalance',
align: 'right',
width: 140,
render: (value: number) => (
<AmountText amount={value} type="neutral" showCurrency={false} />
),
},
{
title: 'Ændring',
key: 'change',
align: 'right',
width: 140,
render: (_, record) => (
<AmountText
amount={record.netChange}
type="auto"
showSign
showCurrency={false}
/>
),
},
{
title: 'Ny saldo',
dataIndex: 'newBalance',
key: 'newBalance',
align: 'right',
width: 140,
render: (value: number) => (
<AmountText amount={value} type="auto" showCurrency={false} style={{ fontWeight: 600 }} />
),
},
];
const panelHeader = (
<Space>
<FundViewOutlined />
<span>Konsekvens konti</span>
<Badge
count={impacts.length}
style={{ backgroundColor: '#1677ff' }}
title={`${impacts.length} konti påvirket`}
/>
{isBalanced ? (
<CheckCircleOutlined style={{ color: '#52c41a', marginLeft: spacing.sm }} />
) : (
<WarningOutlined style={{ color: '#faad14', marginLeft: spacing.sm }} />
)}
</Space>
);
return (
<Collapse
defaultActiveKey={defaultOpen ? ['impact'] : []}
style={{ marginTop: spacing.md }}
items={[
{
key: 'impact',
label: panelHeader,
children: (
<>
{error && (
<Alert
type="warning"
message="Kunne ikke hente aktuelle saldi"
description="Ændringer vises stadig, men aktuelle saldi er ikke tilgængelige."
style={{ marginBottom: spacing.md }}
/>
)}
<Spin spinning={isLoading}>
<Table
dataSource={impacts}
columns={columns}
rowKey="accountId"
pagination={false}
size="small"
bordered
summary={() => (
<Table.Summary fixed>
<Table.Summary.Row>
<Table.Summary.Cell index={0}>
<Text strong>Total ændring</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
{/* Empty - no total for current balance */}
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="right">
<Space size={4}>
<Text type="secondary">D:</Text>
<AmountText
amount={totals.debit}
type="debit"
showCurrency={false}
style={{ fontWeight: 600 }}
/>
<Text type="secondary" style={{ marginLeft: spacing.xs }}>/</Text>
<Text type="secondary">K:</Text>
<AmountText
amount={totals.credit}
type="credit"
showCurrency={false}
style={{ fontWeight: 600 }}
/>
</Space>
</Table.Summary.Cell>
<Table.Summary.Cell index={3} align="right">
{isBalanced ? (
<Text style={{ color: '#52c41a' }}>
<CheckCircleOutlined style={{ marginRight: 4 }} />
Balancerer
</Text>
) : (
<Text style={{ color: '#faad14' }}>
<WarningOutlined style={{ marginRight: 4 }} />
Difference: {(totals.debit - totals.credit).toFixed(2)}
</Text>
)}
</Table.Summary.Cell>
</Table.Summary.Row>
</Table.Summary>
)}
/>
</Spin>
</>
),
},
]}
/>
);
}
export default BalanceImpactPanel;

View file

@ -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 <Tag icon={<CheckCircleOutlined />} color="success">Aktiv</Tag>;
}
if (status === 'established' && !isActive) {
return <Tag icon={<ClockCircleOutlined />} color="warning">Udløbet</Tag>;
}
if (status === 'initiated') {
return <Tag icon={<ClockCircleOutlined />} color="processing">Afventer</Tag>;
}
if (status === 'failed') {
return <Tag icon={<CloseCircleOutlined />} color="error">Fejlet</Tag>;
}
if (status === 'disconnected') {
return <Tag icon={<DisconnectOutlined />} color="default">Afbrudt</Tag>;
}
return <Tag>{status}</Tag>;
}
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<string | null>(null);
const [psuType, setPsuType] = useState<string>('personal');
// Link account modal state
const [isLinkModalOpen, setIsLinkModalOpen] = useState(false);
const [linkingAccount, setLinkingAccount] = useState<{ connectionId: string; account: BankAccountInfo } | null>(null);
const [selectedLinkedAccount, setSelectedLinkedAccount] = useState<string | null>(null);
const [importFromDate, setImportFromDate] = useState<Dayjs | null>(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<string, string> = {
'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 (
<Card>
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
<div style={{ marginTop: 16 }}>
<Text type="secondary">Indlæser bankforbindelser...</Text>
</div>
</div>
</Card>
);
}
if (connectionsError) {
return (
<Card>
<Alert
message="Fejl ved indlæsning"
description="Kunne ikke indlæse bankforbindelser. Prøv at genindlæse siden."
type="error"
showIcon
/>
</Card>
);
}
return (
<Card>
<Space direction="vertical" style={{ width: '100%' }} size="large">
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Title level={5} style={{ margin: 0 }}>Tilknyttede bankkonti</Title>
<Text type="secondary">
Forbind dine bankkonti via Open Banking for automatisk import af transaktioner
</Text>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsAddModalOpen(true)}
>
Tilføj bankkonto
</Button>
</div>
<Divider style={{ margin: '12px 0' }} />
{/* Active Connections */}
{activeConnections.length > 0 && (
<>
<Title level={5} style={{ margin: 0 }}>Aktive forbindelser</Title>
{activeConnections.map((connection) => (
<BankConnectionCard
key={connection.id}
connection={connection}
onDisconnect={handleDisconnect}
onReconnect={handleReconnect}
onArchive={handleArchive}
onLinkAccount={handleOpenLinkModal}
isDisconnecting={disconnectConnection.isPending}
isReconnecting={reconnectConnection.isPending}
isArchiving={archiveConnection.isPending}
/>
))}
</>
)}
{/* Empty State */}
{connections?.length === 0 && (
<Empty
image={<BankOutlined style={{ fontSize: 48, color: '#d9d9d9' }} />}
description={
<Space direction="vertical" size={0}>
<Text>Ingen bankkonti tilknyttet</Text>
<Text type="secondary">
Tilføj en bankkonto for at importere transaktioner automatisk
</Text>
</Space>
}
>
<Button type="primary" onClick={() => setIsAddModalOpen(true)}>
Tilføj din første bankkonto
</Button>
</Empty>
)}
{/* Other Connections (inactive, failed, etc.) */}
{otherConnections.length > 0 && (
<>
<Divider />
<Title level={5} style={{ margin: 0 }}>
<Text type="secondary">Tidligere forbindelser</Text>
</Title>
{otherConnections.map((connection) => (
<BankConnectionCard
key={connection.id}
connection={connection}
onDisconnect={handleDisconnect}
onReconnect={handleReconnect}
onArchive={handleArchive}
onLinkAccount={handleOpenLinkModal}
isDisconnecting={disconnectConnection.isPending}
isReconnecting={reconnectConnection.isPending}
isArchiving={archiveConnection.isPending}
/>
))}
</>
)}
</Space>
{/* Add Bank Modal */}
<Modal
title="Tilføj bankkonto"
open={isAddModalOpen}
onCancel={() => {
setIsAddModalOpen(false);
setSelectedBank(null);
}}
onOk={handleAddBankAccount}
okText="Forbind bank"
cancelText="Annuller"
confirmLoading={startConnection.isPending}
okButtonProps={{ disabled: !selectedBank }}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Alert
message="Sikker bankforbindelse"
description="Du vil blive sendt til din banks hjemmeside for at godkende forbindelsen. Vi gemmer aldrig dine bankoplysninger."
type="info"
showIcon
/>
<div>
<Text strong>Vælg bank</Text>
<Select
style={{ width: '100%', marginTop: 8 }}
placeholder="Søg efter din bank..."
showSearch
loading={isLoadingBanks}
value={selectedBank}
onChange={setSelectedBank}
filterOption={(input, option) =>
(option?.searchText as string)?.toLowerCase().includes(input.toLowerCase()) ?? false
}
options={availableBanks?.map((bank) => ({
value: bank.name,
searchText: bank.name,
label: (
<Space>
{bank.logo ? (
<Avatar src={bank.logo} size="small" />
) : (
<BankOutlined />
)}
{bank.name}
</Space>
),
}))}
/>
</div>
<div>
<Text strong>Kontotype</Text>
<Select
style={{ width: '100%', marginTop: 8 }}
value={psuType}
onChange={setPsuType}
options={[
{ value: 'personal', label: 'Privatkonto' },
{ value: 'business', label: 'Erhvervskonto' },
]}
/>
</div>
</Space>
</Modal>
{/* Link Bank Account Modal */}
<Modal
title="Kobl bankkonto til finanskonto"
open={isLinkModalOpen}
onCancel={() => {
setIsLinkModalOpen(false);
setLinkingAccount(null);
setSelectedLinkedAccount(null);
setImportFromDate(dayjs());
}}
onOk={handleLinkAccount}
okText="Kobl konto"
cancelText="Annuller"
confirmLoading={linkBankAccount.isPending}
okButtonProps={{ disabled: !selectedLinkedAccount }}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{linkingAccount && (
<Alert
message={`Bankkonto: ${linkingAccount.account.name || linkingAccount.account.iban}`}
description={`IBAN: ${linkingAccount.account.iban}`}
type="info"
showIcon
icon={<BankOutlined />}
/>
)}
<div>
<Text strong>Vælg bankkonto (1970-1989)</Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
Banktransaktioner vil blive importeret til denne konto
</Text>
{linkableAccounts.length > 0 ? (
<Select
style={{ width: '100%' }}
placeholder="Søg efter konto..."
showSearch
value={selectedLinkedAccount}
onChange={setSelectedLinkedAccount}
filterOption={(input, option) =>
(option?.searchText as string)?.toLowerCase().includes(input.toLowerCase()) ?? false
}
options={linkableAccounts.map((acc) => ({
value: acc.id,
searchText: `${acc.accountNumber} ${acc.name}`,
label: (
<Space>
<Text strong>{acc.accountNumber}</Text>
<Text>{acc.name}</Text>
</Space>
),
}))}
/>
) : (
<Alert
message="Ingen bankkonti fundet"
description="Der er ingen bankkonti (1970-1989) i kontoplanen. Opret en bankkonto for at fortsætte."
type="warning"
showIcon
/>
)}
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={handleCreateBankAccount}
loading={createAccount.isPending}
style={{ width: '100%', marginTop: 8 }}
>
Opret ny bankkonto
</Button>
</div>
<div>
<Text strong>Importer transaktioner fra</Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
Transaktioner før denne dato vil ikke blive importeret
</Text>
<DatePicker
style={{ width: '100%' }}
value={importFromDate}
onChange={setImportFromDate}
format="DD-MM-YYYY"
placeholder="Vælg startdato"
/>
</div>
</Space>
</Modal>
</Card>
);
}
interface BankConnectionCardProps {
connection: BankConnection;
onDisconnect: (id: string) => void;
onReconnect: (id: string) => void;
onArchive: (id: string) => void;
onLinkAccount: (connectionId: string, account: BankAccountInfo) => void;
isDisconnecting: boolean;
isReconnecting: boolean;
isArchiving: boolean;
}
function BankConnectionCard({ connection, onDisconnect, onReconnect, onArchive, onLinkAccount, isDisconnecting, isReconnecting, isArchiving }: BankConnectionCardProps) {
// Allow disconnecting/removing any connection that isn't already disconnected
const canDisconnect = connection.status !== 'disconnected';
// Allow reconnecting for initiated (pending), failed, disconnected, or expired connections
const isExpired = connection.status === 'established' && !connection.isActive;
const canReconnect = connection.status === 'initiated' || connection.status === 'failed' || connection.status === 'disconnected' || isExpired;
// Allow archiving for non-active connections (expired, failed, disconnected, or stuck in initiated)
const canArchive = !connection.isActive || connection.status !== 'established';
const canLink = connection.status === 'established' && connection.isActive;
return (
<Card size="small" style={{ marginTop: 8 }}>
<Row align="middle" justify="space-between">
<Col flex="auto">
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Space>
<BankOutlined style={{ fontSize: 16 }} />
<Text strong>{connection.aspspName}</Text>
{getStatusTag(connection.status, connection.isActive)}
</Space>
{connection.accounts && connection.accounts.length > 0 && (
<div style={{ marginLeft: 22, marginTop: 8 }}>
{connection.accounts.map((acc) => (
<div key={acc.accountId} style={{ marginBottom: 4, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Text>{acc.name || acc.iban}</Text>
{acc.linkedAccount ? (
<>
<Tag icon={<CheckCircleOutlined />} color="success">
Koblet til {acc.linkedAccount.accountNumber} {acc.linkedAccount.name}
</Tag>
{acc.importFromDate && (
<Text type="secondary" style={{ fontSize: 12 }}>
(fra {formatDate(acc.importFromDate)})
</Text>
)}
{canLink && (
<Button
size="small"
type="link"
icon={<LinkOutlined />}
onClick={() => onLinkAccount(connection.id, acc)}
>
Ændr
</Button>
)}
</>
) : (
<>
<Tag color="warning">Ikke koblet</Tag>
{canLink && (
<Button
size="small"
type="link"
icon={<LinkOutlined />}
onClick={() => onLinkAccount(connection.id, acc)}
>
Kobl til konto
</Button>
)}
</>
)}
</div>
))}
</div>
)}
{connection.validUntil && connection.isActive && (
<Text type="secondary" style={{ marginLeft: 22 }}>
Gyldig til: {formatDate(connection.validUntil)}
</Text>
)}
{connection.failureReason && (
<Text type="danger" style={{ marginLeft: 22 }}>
<ExclamationCircleOutlined /> {connection.failureReason}
</Text>
)}
<Text type="secondary" style={{ marginLeft: 22, fontSize: 12 }}>
Oprettet: {formatDate(connection.createdAt)}
</Text>
</Space>
</Col>
<Col>
<Space>
{canReconnect && (
<Button
size="small"
type="primary"
loading={isReconnecting}
icon={<ReloadOutlined />}
onClick={() => onReconnect(connection.id)}
>
Genopret
</Button>
)}
{canDisconnect && (
<Popconfirm
title={connection.status === 'established' ? 'Afbryd bankforbindelse?' : 'Fjern bankforbindelse?'}
description={connection.status === 'established'
? 'Du vil ikke længere modtage transaktioner fra denne konto.'
: 'Forbindelsen vil blive fjernet fra listen.'}
onConfirm={() => onDisconnect(connection.id)}
okText={connection.status === 'established' ? 'Afbryd' : 'Fjern'}
cancelText="Annuller"
okButtonProps={{ danger: true }}
>
<Button
size="small"
danger
loading={isDisconnecting}
icon={<DisconnectOutlined />}
>
{connection.status === 'established' ? 'Afbryd' : 'Fjern'}
</Button>
</Popconfirm>
)}
{canArchive && (
<Popconfirm
title="Arkiver bankforbindelse?"
description="Forbindelsen vil blive skjult fra listen."
onConfirm={() => onArchive(connection.id)}
okText="Arkiver"
cancelText="Annuller"
>
<Button
size="small"
loading={isArchiving}
icon={<InboxOutlined />}
>
Arkiver
</Button>
</Popconfirm>
)}
</Space>
</Col>
</Row>
</Card>
);
}

View file

@ -0,0 +1,212 @@
import { Typography, Tooltip } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined, MinusOutlined } from '@ant-design/icons';
import { formatCurrency } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import { typography, getAmountColor } from '@/styles/designTokens';
const { Text } = Typography;
export type AmountType = 'debit' | 'credit' | 'neutral' | 'auto';
export interface AmountTextProps {
/** The amount to display */
amount: number;
/** Override automatic color detection */
type?: AmountType;
/** Whether to show the sign (+/-) */
showSign?: boolean;
/** Whether to show currency suffix */
showCurrency?: boolean;
/** Currency suffix text */
currencySuffix?: string;
/** Text size variant */
size?: 'small' | 'default' | 'large';
/** Whether to use tabular (monospace) numbers */
tabular?: boolean;
/** Whether to show tooltip with full precision */
showTooltip?: boolean;
/** Show icon indicator for accessibility (not color-only) */
showIcon?: boolean;
/** Additional CSS class */
className?: string;
/** Inline style overrides */
style?: React.CSSProperties;
}
/**
* A consistent component for displaying monetary amounts.
* Automatically colors based on value (positive = credit/green, negative = debit/red).
*
* @example
* <AmountText amount={1234.56} />
* <AmountText amount={-500} type="debit" showSign />
* <AmountText amount={0} type="neutral" />
*/
export function AmountText({
amount,
type = 'auto',
showSign = false,
showCurrency = true,
currencySuffix = 'kr.',
size = 'default',
tabular = true,
showTooltip = false,
showIcon = false,
className,
style,
}: AmountTextProps) {
const getColor = (): string => {
if (type === 'auto') {
return getAmountColor(amount);
}
if (type === 'neutral') {
return accountingColors.neutral;
}
return accountingColors[type];
};
const getFontSize = (): number => {
switch (size) {
case 'small':
return typography.caption.fontSize;
case 'large':
return typography.h3.fontSize;
default:
return typography.body.fontSize;
}
};
const formatAmount = (): string => {
const formatted = formatCurrency(Math.abs(amount));
const sign = showSign && amount !== 0 ? (amount > 0 ? '+' : '-') : amount < 0 ? '-' : '';
const suffix = showCurrency ? ` ${currencySuffix}` : '';
return `${sign}${formatted}${suffix}`;
};
// Determine icon for accessibility (not relying on color alone)
const getIcon = () => {
if (!showIcon) return null;
const effectiveType = type === 'auto'
? (amount > 0 ? 'credit' : amount < 0 ? 'debit' : 'neutral')
: type;
const iconStyle = { marginRight: 4, fontSize: getFontSize() - 2 };
switch (effectiveType) {
case 'credit':
return <ArrowUpOutlined style={iconStyle} aria-label="Kredit" />;
case 'debit':
return <ArrowDownOutlined style={iconStyle} aria-label="Debet" />;
default:
return <MinusOutlined style={iconStyle} aria-label="Nul" />;
}
};
const textStyle: React.CSSProperties = {
color: getColor(),
fontSize: getFontSize(),
fontVariantNumeric: tabular ? 'tabular-nums' : undefined,
...style,
};
const content = (
<Text className={className} style={textStyle}>
{getIcon()}
{formatAmount()}
</Text>
);
if (showTooltip) {
return (
<Tooltip title={`${amount.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currencySuffix}`}>
{content}
</Tooltip>
);
}
return content;
}
/**
* Displays a balance with label
*/
export interface BalanceDisplayProps {
label: string;
amount: number;
showChange?: boolean;
change?: number;
size?: 'small' | 'default' | 'large';
}
export function BalanceDisplay({ label, amount, size = 'default' }: BalanceDisplayProps) {
return (
<div>
<Text type="secondary" style={{ fontSize: typography.caption.fontSize }}>
{label}
</Text>
<div>
<AmountText amount={amount} size={size} />
</div>
</div>
);
}
/**
* Displays debit and credit columns for double-entry bookkeeping
*/
export interface DoubleEntryAmountProps {
debit?: number;
credit?: number;
showZero?: boolean;
}
export function DoubleEntryAmount({ debit, credit, showZero = false }: DoubleEntryAmountProps) {
const showDebit = debit !== undefined && (debit !== 0 || showZero);
const showCredit = credit !== undefined && (credit !== 0 || showZero);
return (
<>
<span style={{ display: 'inline-block', minWidth: 100, textAlign: 'right' }}>
{showDebit && <AmountText amount={debit!} type="debit" showCurrency={false} />}
</span>
<span style={{ display: 'inline-block', minWidth: 100, textAlign: 'right', marginLeft: 16 }}>
{showCredit && <AmountText amount={credit!} type="credit" showCurrency={false} />}
</span>
</>
);
}
/**
* Displays a running balance with optional delta indicator
*/
export interface RunningBalanceProps {
balance: number;
previousBalance?: number;
}
export function RunningBalance({ balance, previousBalance }: RunningBalanceProps) {
const delta = previousBalance !== undefined ? balance - previousBalance : undefined;
return (
<span className="tabular-nums">
<AmountText amount={balance} type="auto" />
{delta !== undefined && delta !== 0 && (
<Text
type="secondary"
style={{
fontSize: typography.small.fontSize,
marginLeft: 4,
color: delta > 0 ? accountingColors.credit : accountingColors.debit,
}}
>
({delta > 0 ? '+' : ''}
{formatCurrency(delta)})
</Text>
)}
</span>
);
}
export default AmountText;

View file

@ -0,0 +1,292 @@
import { useState, useCallback } from 'react';
import { Upload, Button, List, Typography, Space, Tooltip } from 'antd';
import { showSuccess, showError } from '@/lib/errorHandling';
import {
UploadOutlined,
FileOutlined,
FilePdfOutlined,
FileImageOutlined,
DeleteOutlined,
EyeOutlined,
DownloadOutlined,
} from '@ant-design/icons';
import type { UploadFile, UploadProps } from 'antd/es/upload/interface';
import { spacing } from '@/styles/designTokens';
const { Text } = Typography;
export interface Attachment {
id: string;
fileName: string;
fileType: string;
fileSize: number;
uploadedAt: string;
uploadedBy?: string;
url?: string;
}
interface AttachmentUploadProps {
/** List of existing attachments */
attachments?: Attachment[];
/** Callback when files are uploaded */
onUpload?: (files: File[]) => Promise<Attachment[]>;
/** Callback when an attachment is removed */
onRemove?: (attachmentId: string) => Promise<void>;
/** Callback when an attachment is viewed */
onView?: (attachment: Attachment) => void;
/** Maximum number of attachments allowed */
maxCount?: number;
/** Maximum file size in bytes (default 10MB) */
maxFileSize?: number;
/** Allowed file types */
accept?: string;
/** Whether the component is disabled */
disabled?: boolean;
/** Whether uploads are required (shows warning if empty) */
required?: boolean;
/** Compact mode for inline display */
compact?: boolean;
}
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const getFileIcon = (fileType: string) => {
if (fileType.includes('pdf')) return <FilePdfOutlined style={{ color: '#ff4d4f' }} />;
if (fileType.includes('image')) return <FileImageOutlined style={{ color: '#1890ff' }} />;
return <FileOutlined style={{ color: '#8c8c8c' }} />;
};
/**
* Bilag (Document/Attachment) upload component.
* Supports drag-drop, multiple file upload, and preview.
* Required by Bogforingsloven § 6 for document retention.
*/
export function AttachmentUpload({
attachments = [],
onUpload,
onRemove,
onView,
maxCount = 10,
maxFileSize = 10 * 1024 * 1024, // 10MB default
accept = '.pdf,.png,.jpg,.jpeg,.gif,.doc,.docx,.xls,.xlsx',
disabled = false,
required = false,
compact = false,
}: AttachmentUploadProps) {
const [uploading, setUploading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const handleUpload = useCallback(async () => {
if (!onUpload || fileList.length === 0) return;
setUploading(true);
try {
const files = fileList
.filter((f) => f.originFileObj)
.map((f) => f.originFileObj as File);
await onUpload(files);
setFileList([]);
showSuccess(`${files.length} bilag uploadet`);
} catch (error) {
showError('Fejl ved upload af bilag');
console.error('Upload error:', error);
} finally {
setUploading(false);
}
}, [fileList, onUpload]);
const handleRemove = useCallback(
async (attachment: Attachment) => {
if (!onRemove) return;
try {
await onRemove(attachment.id);
showSuccess('Bilag slettet');
} catch (error) {
showError('Fejl ved sletning af bilag');
console.error('Remove error:', error);
}
},
[onRemove]
);
const uploadProps: UploadProps = {
multiple: true,
accept,
disabled: disabled || attachments.length >= maxCount,
fileList,
beforeUpload: (file) => {
// Validate file size
if (file.size > maxFileSize) {
showError(`Filen "${file.name}" er for stor (max ${formatFileSize(maxFileSize)})`);
return Upload.LIST_IGNORE;
}
// Check max count
if (attachments.length + fileList.length >= maxCount) {
showError(`Maksimalt ${maxCount} bilag tilladt`);
return Upload.LIST_IGNORE;
}
return false; // Don't auto-upload
},
onChange: ({ fileList: newFileList }) => {
setFileList(newFileList);
},
onRemove: (file) => {
setFileList((prev) => prev.filter((f) => f.uid !== file.uid));
},
};
if (compact) {
return (
<Space size="small" wrap>
<Upload {...uploadProps} showUploadList={false}>
<Button
icon={<UploadOutlined />}
size="small"
disabled={disabled || attachments.length >= maxCount}
aria-label="Upload bilag"
>
Bilag ({attachments.length})
</Button>
</Upload>
{fileList.length > 0 && (
<Button
type="primary"
size="small"
loading={uploading}
onClick={handleUpload}
disabled={disabled}
>
Upload {fileList.length}
</Button>
)}
</Space>
);
}
return (
<div>
{/* Upload area */}
<Upload.Dragger
{...uploadProps}
style={{ marginBottom: attachments.length > 0 || fileList.length > 0 ? spacing.md : 0 }}
>
<p className="ant-upload-drag-icon">
<UploadOutlined style={{ fontSize: 32, color: '#1890ff' }} />
</p>
<p className="ant-upload-text">
Klik eller traek filer hertil for at uploade bilag
</p>
<p className="ant-upload-hint">
Understotter PDF, billeder og Office-dokumenter (max {formatFileSize(maxFileSize)})
</p>
</Upload.Dragger>
{/* Pending uploads */}
{fileList.length > 0 && (
<div style={{ marginBottom: spacing.md }}>
<Space style={{ marginBottom: spacing.sm }}>
<Text type="secondary">{fileList.length} fil(er) klar til upload</Text>
<Button
type="primary"
size="small"
loading={uploading}
onClick={handleUpload}
disabled={disabled}
>
Upload alle
</Button>
</Space>
</div>
)}
{/* Required warning */}
{required && attachments.length === 0 && fileList.length === 0 && (
<Text type="warning" style={{ display: 'block', marginBottom: spacing.sm }}>
Bilag er pakraevet iht. Bogforingsloven § 6
</Text>
)}
{/* Existing attachments */}
{attachments.length > 0 && (
<List
size="small"
dataSource={attachments}
renderItem={(attachment) => (
<List.Item
style={{ padding: `${spacing.xs}px 0` }}
actions={[
onView && (
<Tooltip title="Se bilag" key="view">
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => onView(attachment)}
aria-label={`Se ${attachment.fileName}`}
/>
</Tooltip>
),
attachment.url && (
<Tooltip title="Download" key="download">
<Button
type="text"
size="small"
icon={<DownloadOutlined />}
href={attachment.url}
target="_blank"
aria-label={`Download ${attachment.fileName}`}
/>
</Tooltip>
),
onRemove && !disabled && (
<Tooltip title="Slet bilag" key="remove">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleRemove(attachment)}
aria-label={`Slet ${attachment.fileName}`}
/>
</Tooltip>
),
].filter(Boolean)}
>
<List.Item.Meta
avatar={getFileIcon(attachment.fileType)}
title={
<Text ellipsis style={{ maxWidth: 200 }}>
{attachment.fileName}
</Text>
}
description={
<Text type="secondary" style={{ fontSize: 12 }}>
{formatFileSize(attachment.fileSize)}
{attachment.uploadedAt && ` - ${new Date(attachment.uploadedAt).toLocaleDateString('da-DK')}`}
</Text>
}
/>
</List.Item>
)}
/>
)}
{/* Max count info */}
{attachments.length > 0 && (
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: spacing.xs }}>
{attachments.length} af {maxCount} bilag
</Text>
)}
</div>
);
}
export default AttachmentUpload;

View file

@ -0,0 +1,177 @@
import { ReactNode } from 'react';
import { Modal, Typography, Space } from 'antd';
import {
ExclamationCircleOutlined,
WarningOutlined,
DeleteOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons';
import { colors } from '@/styles/designTokens';
import { useResponsiveModal } from '@/hooks/useResponsiveModal';
const { Text, Paragraph } = Typography;
export type ConfirmationType = 'danger' | 'warning' | 'info' | 'confirm';
interface ConfirmationModalProps {
/** Modal open state */
open: boolean;
/** Close handler */
onClose: () => void;
/** Confirm handler */
onConfirm: () => void | Promise<void>;
/** Modal title */
title: string;
/** Main message to display */
message: ReactNode;
/** Optional additional details */
details?: ReactNode;
/** Type of confirmation (affects styling) */
type?: ConfirmationType;
/** Text for confirm button */
confirmText?: string;
/** Text for cancel button */
cancelText?: string;
/** Loading state for confirm button */
loading?: boolean;
/** Disabled state for confirm button */
disabled?: boolean;
}
const TYPE_CONFIG = {
danger: {
icon: <DeleteOutlined />,
color: colors.error,
okType: 'primary' as const,
okDanger: true,
defaultConfirmText: 'Slet',
},
warning: {
icon: <WarningOutlined />,
color: colors.warning,
okType: 'primary' as const,
okDanger: false,
defaultConfirmText: 'Fortsæt',
},
info: {
icon: <ExclamationCircleOutlined />,
color: colors.info,
okType: 'primary' as const,
okDanger: false,
defaultConfirmText: 'OK',
},
confirm: {
icon: <QuestionCircleOutlined />,
color: colors.primary,
okType: 'primary' as const,
okDanger: false,
defaultConfirmText: 'Bekræft',
},
};
/**
* Reusable confirmation modal for destructive or important actions.
*
* @example
* <ConfirmationModal
* open={showDeleteConfirm}
* onClose={() => setShowDeleteConfirm(false)}
* onConfirm={handleDelete}
* type="danger"
* title="Slet postering?"
* message="Er du sikker på at du vil slette denne postering?"
* details="Denne handling kan ikke fortrydes."
* loading={isDeleting}
* />
*/
export function ConfirmationModal({
open,
onClose,
onConfirm,
title,
message,
details,
type = 'confirm',
confirmText,
cancelText = 'Annuller',
loading = false,
disabled = false,
}: ConfirmationModalProps) {
const config = TYPE_CONFIG[type];
const responsiveModalProps = useResponsiveModal({ size: 'small' });
const handleConfirm = async () => {
await onConfirm();
};
return (
<Modal
open={open}
onCancel={onClose}
onOk={handleConfirm}
title={
<Space>
<span style={{ color: config.color }}>{config.icon}</span>
{title}
</Space>
}
okText={confirmText || config.defaultConfirmText}
cancelText={cancelText}
okType={config.okType}
okButtonProps={{
danger: config.okDanger,
loading,
disabled,
}}
{...responsiveModalProps}
destroyOnHidden
>
<div style={{ paddingTop: 8 }}>
{typeof message === 'string' ? (
<Paragraph style={{ marginBottom: details ? 8 : 0 }}>{message}</Paragraph>
) : (
message
)}
{details && (
<Text type="secondary" style={{ fontSize: 13 }}>
{details}
</Text>
)}
</div>
</Modal>
);
}
/**
* Helper function to show a quick confirmation dialog.
* Uses Ant Design's Modal.confirm under the hood.
*/
export function showConfirmation({
title,
content,
type = 'confirm',
onOk,
okText,
}: {
title: string;
content: ReactNode;
type?: ConfirmationType;
onOk: () => void | Promise<void>;
okText?: string;
}) {
const config = TYPE_CONFIG[type];
Modal.confirm({
title,
icon: <span style={{ color: config.color, marginRight: 8 }}>{config.icon}</span>,
content,
okText: okText || config.defaultConfirmText,
cancelText: 'Annuller',
okType: config.okType,
okButtonProps: { danger: config.okDanger },
onOk,
centered: true,
});
}
export default ConfirmationModal;

View file

@ -0,0 +1,35 @@
import { Alert } from 'antd';
import { InfoCircleOutlined } from '@ant-design/icons';
export interface DemoDataDisclaimerProps {
/** Custom message to display. Defaults to standard Danish demo data message. */
message?: string;
/** Whether to show the icon. Defaults to true. */
showIcon?: boolean;
/** Additional CSS styles */
style?: React.CSSProperties;
}
/**
* A subtle disclaimer banner indicating that displayed data is for demonstration only.
* Use this on pages that show static/mock data that will be replaced with real data.
*/
export function DemoDataDisclaimer({
message = 'Disse data er kun til demonstration',
showIcon = true,
style,
}: DemoDataDisclaimerProps) {
return (
<Alert
type="info"
message={message}
showIcon={showIcon}
icon={showIcon ? <InfoCircleOutlined /> : undefined}
banner
style={{
marginBottom: 16,
...style,
}}
/>
);
}

View file

@ -0,0 +1,241 @@
import { ReactNode } from 'react';
import { Button, Typography, Space } from 'antd';
import {
InboxOutlined,
FileSearchOutlined,
PlusOutlined,
FilterOutlined,
BankOutlined,
FileTextOutlined,
UserOutlined,
FileDoneOutlined,
FileExclamationOutlined,
} from '@ant-design/icons';
import { spacing, typography, grays } from '@/styles/designTokens';
const { Text, Paragraph } = Typography;
export type EmptyStateVariant =
| 'default'
| 'search'
| 'filter'
| 'no-data'
| 'transactions'
| 'documents'
| 'accounts'
| 'customers'
| 'invoices'
| 'creditNotes';
export interface EmptyStateProps {
/** Variant determines the icon and default messaging */
variant?: EmptyStateVariant;
/** Custom icon override */
icon?: ReactNode;
/** Main title text */
title?: string;
/** Description text */
description?: string;
/** Primary action button */
primaryAction?: {
label: string;
onClick: () => void;
icon?: ReactNode;
};
/** Secondary action button */
secondaryAction?: {
label: string;
onClick: () => void;
};
/** Compact mode for use in smaller containers */
compact?: boolean;
/** Custom content to render instead of default */
children?: ReactNode;
}
const variantConfig: Record<
EmptyStateVariant,
{
icon: ReactNode;
title: string;
description: string;
}
> = {
default: {
icon: <InboxOutlined style={{ fontSize: 48, color: grays.gray5 }} />,
title: 'Ingen data',
description: 'Der er ingen data at vise.',
},
search: {
icon: <FileSearchOutlined style={{ fontSize: 48, color: grays.gray5 }} />,
title: 'Ingen resultater',
description: 'Prøv at ændre dine søgekriterier.',
},
filter: {
icon: <FilterOutlined style={{ fontSize: 48, color: grays.gray5 }} />,
title: 'Ingen resultater',
description: 'Ingen poster matcher de valgte filtre.',
},
'no-data': {
icon: <InboxOutlined style={{ fontSize: 48, color: grays.gray5 }} />,
title: 'Kom i gang',
description: 'Start med at oprette din første post.',
},
transactions: {
icon: <BankOutlined style={{ fontSize: 48, color: grays.gray5 }} />,
title: 'Ingen transaktioner',
description: 'Der er ingen transaktioner i den valgte periode.',
},
documents: {
icon: <FileTextOutlined style={{ fontSize: 48, color: grays.gray5 }} />,
title: 'Ingen dokumenter',
description: 'Der er ingen dokumenter at vise.',
},
accounts: {
icon: <BankOutlined style={{ fontSize: 48, color: grays.gray5 }} />,
title: 'Ingen konti',
description: 'Der er ingen konti oprettet endnu.',
},
customers: {
icon: <UserOutlined style={{ fontSize: 48, color: grays.gray5 }} />,
title: 'Ingen kunder',
description: 'Der er ingen kunder oprettet endnu.',
},
invoices: {
icon: <FileDoneOutlined style={{ fontSize: 48, color: grays.gray5 }} />,
title: 'Ingen fakturaer',
description: 'Der er ingen fakturaer oprettet endnu.',
},
creditNotes: {
icon: <FileExclamationOutlined style={{ fontSize: 48, color: grays.gray5 }} />,
title: 'Ingen kreditnotaer',
description: 'Der er ingen kreditnotaer oprettet endnu.',
},
};
/**
* A consistent empty state component for use throughout the application.
*
* @example
* <EmptyState variant="search" />
*
* @example
* <EmptyState
* variant="no-data"
* title="Ingen fakturaer"
* description="Du har ikke oprettet nogen fakturaer endnu."
* primaryAction={{
* label: 'Opret faktura',
* onClick: () => setShowModal(true),
* icon: <PlusOutlined />
* }}
* />
*/
export function EmptyState({
variant = 'default',
icon,
title,
description,
primaryAction,
secondaryAction,
compact = false,
children,
}: EmptyStateProps) {
const config = variantConfig[variant];
const containerStyle: React.CSSProperties = {
padding: compact ? spacing.lg : spacing.xl,
textAlign: 'center',
};
const iconStyle: React.CSSProperties = {
marginBottom: compact ? spacing.md : spacing.lg,
};
if (children) {
return <div style={containerStyle}>{children}</div>;
}
return (
<div style={containerStyle}>
<div style={iconStyle}>{icon || config.icon}</div>
<Text
strong
style={{
display: 'block',
fontSize: compact ? typography.body.fontSize : typography.h3.fontSize,
marginBottom: spacing.xs,
}}
>
{title || config.title}
</Text>
<Paragraph
type="secondary"
style={{
fontSize: compact ? typography.caption.fontSize : typography.body.fontSize,
marginBottom: primaryAction || secondaryAction ? spacing.lg : 0,
}}
>
{description || config.description}
</Paragraph>
{(primaryAction || secondaryAction) && (
<Space>
{primaryAction && (
<Button
type="primary"
icon={primaryAction.icon || <PlusOutlined />}
onClick={primaryAction.onClick}
>
{primaryAction.label}
</Button>
)}
{secondaryAction && (
<Button onClick={secondaryAction.onClick}>{secondaryAction.label}</Button>
)}
</Space>
)}
</div>
);
}
/**
* Compact empty state for use in tables and small containers
*/
export function TableEmptyState({
searchTerm,
onClear,
}: {
searchTerm?: string;
onClear?: () => void;
}) {
if (searchTerm) {
return (
<EmptyState
variant="search"
compact
title={`Ingen resultater for "${searchTerm}"`}
secondaryAction={onClear ? { label: 'Ryd søgning', onClick: onClear } : undefined}
/>
);
}
return <EmptyState variant="no-data" compact />;
}
/**
* Empty state specifically for filtered views
*/
export function FilterEmptyState({ onClearFilters }: { onClearFilters?: () => void }) {
return (
<EmptyState
variant="filter"
compact
secondaryAction={onClearFilters ? { label: 'Ryd filtre', onClick: onClearFilters } : undefined}
/>
);
}
export default EmptyState;

View file

@ -0,0 +1,150 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { Result, Button, Typography, Space } from 'antd';
import { ReloadOutlined, HomeOutlined } from '@ant-design/icons';
const { Paragraph, Text } = Typography;
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
/**
* Error boundary component for catching and displaying React errors.
* Provides a user-friendly fallback UI and error recovery options.
*
* @example
* <ErrorBoundary>
* <MyComponent />
* </ErrorBoundary>
*
* @example
* <ErrorBoundary
* fallback={<div>Noget gik galt</div>}
* onError={(error) => logErrorToService(error)}
* >
* <MyComponent />
* </ErrorBoundary>
*/
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
this.setState({ errorInfo });
this.props.onError?.(error, errorInfo);
// Log error to console in development
if (process.env.NODE_ENV === 'development') {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
}
handleReload = (): void => {
window.location.reload();
};
handleGoHome = (): void => {
window.location.href = '/';
};
handleRetry = (): void => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<Result
status="error"
title="Noget gik galt"
subTitle="Der opstod en uventet fejl. Vi beklager ulejligheden."
extra={
<Space>
<Button type="primary" icon={<ReloadOutlined />} onClick={this.handleRetry}>
Prøv igen
</Button>
<Button icon={<HomeOutlined />} onClick={this.handleGoHome}>
til forsiden
</Button>
</Space>
}
>
{process.env.NODE_ENV === 'development' && this.state.error && (
<div style={{ textAlign: 'left', marginTop: 24 }}>
<Paragraph>
<Text strong style={{ color: '#cf1322' }}>
Fejlbesked:
</Text>
</Paragraph>
<Paragraph>
<pre
style={{
background: '#f5f5f5',
padding: 12,
borderRadius: 4,
overflow: 'auto',
maxHeight: 200,
fontSize: 12,
}}
>
{this.state.error.message}
</pre>
</Paragraph>
{this.state.errorInfo && (
<>
<Paragraph>
<Text strong>Stack trace:</Text>
</Paragraph>
<Paragraph>
<pre
style={{
background: '#f5f5f5',
padding: 12,
borderRadius: 4,
overflow: 'auto',
maxHeight: 300,
fontSize: 11,
}}
>
{this.state.errorInfo.componentStack}
</pre>
</Paragraph>
</>
)}
</div>
)}
</Result>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View file

@ -0,0 +1,172 @@
import { useState, useCallback, useRef, type ReactNode, type DragEvent } from 'react';
import { Typography } from 'antd';
import { CloudUploadOutlined } from '@ant-design/icons';
const { Text, Title } = Typography;
interface FullPageDropZoneProps {
children: ReactNode;
onDrop: (files: File[]) => void;
accept?: string[];
disabled?: boolean;
maxFileSize?: number;
}
const DEFAULT_ACCEPT = ['application/pdf', 'image/png', 'image/jpeg', 'image/jpg'];
const DEFAULT_MAX_SIZE = 10 * 1024 * 1024; // 10MB
/**
* Full-page drop zone wrapper component.
* Shows an overlay when dragging files over the page.
*/
export function FullPageDropZone({
children,
onDrop,
accept = DEFAULT_ACCEPT,
disabled = false,
maxFileSize = DEFAULT_MAX_SIZE,
}: FullPageDropZoneProps) {
const [isDragging, setIsDragging] = useState(false);
const dragCounter = useRef(0);
const isValidFile = useCallback(
(file: File): boolean => {
// Check file type
const isValidType = accept.some((type) => {
if (type.includes('*')) {
// Handle wildcards like 'image/*'
const baseType = type.split('/')[0];
return file.type.startsWith(baseType + '/');
}
return file.type === type;
});
if (!isValidType) {
return false;
}
// Check file size
if (file.size > maxFileSize) {
return false;
}
return true;
},
[accept, maxFileSize]
);
const handleDragEnter = useCallback(
(e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounter.current++;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragging(true);
}
},
[disabled]
);
const handleDragLeave = useCallback(
(e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounter.current--;
if (dragCounter.current === 0) {
setIsDragging(false);
}
},
[disabled]
);
const handleDragOver = useCallback(
(e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
// Set the drop effect
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
},
[disabled]
);
const handleDrop = useCallback(
(e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounter.current = 0;
if (disabled) return;
const files = Array.from(e.dataTransfer.files);
const validFiles = files.filter(isValidFile);
if (validFiles.length > 0) {
onDrop(validFiles);
}
},
[disabled, isValidFile, onDrop]
);
return (
<div
style={{ position: 'relative', minHeight: '100%' }}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{children}
{/* Overlay when dragging */}
{isDragging && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(24, 144, 255, 0.1)',
border: '3px dashed #1890ff',
borderRadius: 8,
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<CloudUploadOutlined
style={{
fontSize: 64,
color: '#1890ff',
marginBottom: 16,
}}
/>
<Title level={3} style={{ color: '#1890ff', margin: 0 }}>
Slip dokumentet her
</Title>
<Text type="secondary" style={{ marginTop: 8 }}>
PDF, PNG eller JPG (maks 10MB)
</Text>
</div>
)}
</div>
);
}
export default FullPageDropZone;

View file

@ -0,0 +1,145 @@
import React, { useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate, useLocation } from 'react-router-dom';
import { useHotkeyStore, useHotkeysEnabled } from '@/stores/hotkeyStore';
import { shortcuts, navigationRoutes } from '@/lib/keyboardShortcuts';
import { CommandPalette } from './CommandPalette';
import { ShortcutsHelpModal } from './ShortcutsHelpModal';
interface HotkeyProviderProps {
children: React.ReactNode;
}
/**
* HotkeyProvider - Global keyboard shortcut handler
* Wraps the application and provides keyboard navigation
*/
export function HotkeyProvider({ children }: HotkeyProviderProps) {
const navigate = useNavigate();
const location = useLocation();
const hotkeysEnabled = useHotkeysEnabled();
const {
openCommandPalette,
closeCommandPalette,
commandPaletteOpen,
openShortcutsHelp,
closeShortcutsHelp,
shortcutsHelpOpen,
addRecentCommand,
} = useHotkeyStore();
// Helper to check if we're in an input field
const isInputFocused = useCallback(() => {
const activeElement = document.activeElement;
if (!activeElement) return false;
const tagName = activeElement.tagName.toLowerCase();
const isInput = tagName === 'input' || tagName === 'textarea';
const isContentEditable = activeElement.getAttribute('contenteditable') === 'true';
return isInput || isContentEditable;
}, []);
// Command palette toggle (Cmd+K)
useHotkeys(
shortcuts.openCommandPalette.keys,
(e) => {
e.preventDefault();
if (hotkeysEnabled) {
openCommandPalette();
}
},
{
enableOnFormTags: false,
enableOnContentEditable: false,
}
);
// Shortcuts help (Cmd+/)
useHotkeys(
shortcuts.showShortcuts.keys,
(e) => {
e.preventDefault();
if (hotkeysEnabled && !commandPaletteOpen) {
openShortcutsHelp();
}
},
{
enableOnFormTags: false,
enableOnContentEditable: false,
}
);
// Escape to close modals
useHotkeys(
'escape',
() => {
if (commandPaletteOpen) {
closeCommandPalette();
} else if (shortcutsHelpOpen) {
closeShortcutsHelp();
}
},
{
enableOnFormTags: true,
}
);
// Navigation shortcuts (G then X pattern)
// These use sequence keys
const navigationShortcuts = Object.entries(navigationRoutes);
navigationShortcuts.forEach(([shortcutId, route]) => {
const shortcut = shortcuts[shortcutId];
if (shortcut) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useHotkeys(
shortcut.keys,
(e) => {
e.preventDefault();
if (hotkeysEnabled && !commandPaletteOpen && !isInputFocused()) {
navigate(route);
addRecentCommand({ id: shortcutId, label: shortcut.label });
}
},
{
enableOnFormTags: false,
enableOnContentEditable: false,
}
);
}
});
// Handle command execution from palette
const handleCommandExecute = useCallback(
(commandId: string, route?: string) => {
if (route) {
navigate(route);
}
const shortcut = shortcuts[commandId];
if (shortcut) {
addRecentCommand({ id: commandId, label: shortcut.label });
}
},
[navigate, addRecentCommand]
);
return (
<>
{children}
<CommandPalette
open={commandPaletteOpen}
onClose={closeCommandPalette}
onExecute={handleCommandExecute}
currentPath={location.pathname}
/>
<ShortcutsHelpModal
open={shortcutsHelpOpen}
onClose={closeShortcutsHelp}
/>
</>
);
}
export default HotkeyProvider;

View file

@ -0,0 +1,123 @@
// ISODatePicker - DatePicker that works with ISO-8601 date strings
//
// This component wraps Ant Design's DatePicker to provide a consistent
// interface that always uses ISO-8601 date strings (YYYY-MM-DD) for values,
// while displaying dates in Danish format (DD-MM-YYYY) to users.
//
// Benefits:
// - No manual format conversion needed
// - Consistent date format across frontend/backend
// - Prevents timezone-related bugs
// - Type-safe string values instead of dayjs objects
import { DatePicker } from 'antd';
import type { DatePickerProps } from 'antd';
import type { Dayjs } from 'dayjs';
import { useMemo, useCallback } from 'react';
import { formatDateISO, parseISODate } from '@/lib/formatters';
const { RangePicker } = DatePicker;
// Display format for Danish users
const DISPLAY_FORMAT = 'DD-MM-YYYY';
// =====================================================
// ISODatePicker - Single date selection
// =====================================================
export interface ISODatePickerProps
extends Omit<DatePickerProps, 'value' | 'onChange' | 'format'> {
/** ISO-8601 date string (YYYY-MM-DD) */
value?: string | null;
/** Callback with ISO-8601 date string */
onChange?: (value: string | null) => void;
/** Display format override (default: DD-MM-YYYY) */
displayFormat?: string;
}
export function ISODatePicker({
value,
onChange,
displayFormat = DISPLAY_FORMAT,
...props
}: ISODatePickerProps) {
// Convert ISO string to dayjs for display
const dayjsValue = useMemo(() => parseISODate(value), [value]);
// Convert dayjs back to ISO string on change
const handleChange = useCallback(
(date: Dayjs | null) => {
if (onChange) {
onChange(date ? formatDateISO(date.toDate()) : null);
}
},
[onChange]
);
return (
<DatePicker
{...props}
value={dayjsValue}
onChange={handleChange}
format={displayFormat}
/>
);
}
// =====================================================
// ISODateRangePicker - Date range selection
// =====================================================
type RangePickerProps = React.ComponentProps<typeof RangePicker>;
export interface ISODateRangePickerProps
extends Omit<RangePickerProps, 'value' | 'onChange' | 'format'> {
/** ISO-8601 date strings tuple [startDate, endDate] */
value?: [string | null, string | null] | null;
/** Callback with ISO-8601 date strings tuple */
onChange?: (value: [string, string] | null) => void;
/** Display format override (default: DD-MM-YYYY) */
displayFormat?: string;
}
export function ISODateRangePicker({
value,
onChange,
displayFormat = DISPLAY_FORMAT,
...props
}: ISODateRangePickerProps) {
// Convert ISO strings to dayjs for display
const dayjsValue = useMemo((): [Dayjs | null, Dayjs | null] | null => {
if (!value) return null;
return [parseISODate(value[0]), parseISODate(value[1])];
}, [value]);
// Convert dayjs back to ISO strings on change
const handleChange = useCallback(
(dates: [Dayjs | null, Dayjs | null] | null) => {
if (onChange) {
if (dates && dates[0] && dates[1]) {
onChange([
formatDateISO(dates[0].toDate()),
formatDateISO(dates[1].toDate()),
]);
} else {
onChange(null);
}
}
},
[onChange]
);
return (
<RangePicker
{...props}
value={dayjsValue}
onChange={handleChange}
format={displayFormat}
/>
);
}
// Default export for convenience
export default ISODatePicker;

View file

@ -0,0 +1,113 @@
import { ReactNode } from 'react';
import { Typography, Space, Breadcrumb, Grid } from 'antd';
import { HomeOutlined } from '@ant-design/icons';
import { Link } from 'react-router-dom';
import { spacing } from '@/styles/designTokens';
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
export interface BreadcrumbItem {
title: string;
path?: string;
}
export interface PageHeaderProps {
/** Page title */
title: string;
/** Optional subtitle or description */
subtitle?: string;
/** Breadcrumb items (path is optional for current page) */
breadcrumbs?: BreadcrumbItem[];
/** Extra content to render on the right side (buttons, filters, etc.) */
extra?: ReactNode;
/** Whether to show home in breadcrumbs */
showHome?: boolean;
}
/**
* Consistent page header component with title, breadcrumbs, and optional actions.
*
* @example
* <PageHeader
* title="Kassekladde"
* subtitle="Opret og rediger kladde-posteringer"
* breadcrumbs={[{ title: 'Bogføring', path: '/bogforing' }, { title: 'Kassekladde' }]}
* extra={<Button type="primary">Ny postering</Button>}
* />
*/
export function PageHeader({
title,
subtitle,
breadcrumbs,
extra,
showHome = true,
}: PageHeaderProps) {
const screens = useBreakpoint();
const isMobile = !screens.md;
const breadcrumbItems = breadcrumbs
? [
...(showHome
? [
{
title: (
<Link to="/">
<HomeOutlined />
</Link>
),
},
]
: []),
...breadcrumbs.map((item) => ({
title: item.path ? <Link to={item.path}>{item.title}</Link> : item.title,
})),
]
: undefined;
return (
<div
style={{
marginBottom: spacing.lg,
}}
>
{/* Breadcrumbs */}
{breadcrumbItems && breadcrumbItems.length > 0 && (
<Breadcrumb
items={breadcrumbItems}
style={{ marginBottom: spacing.sm }}
/>
)}
{/* Title row */}
<div
style={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
justifyContent: 'space-between',
alignItems: isMobile ? 'flex-start' : 'center',
gap: isMobile ? spacing.sm : spacing.md,
}}
>
<div>
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
{title}
</Title>
{subtitle && (
<Text type="secondary" style={{ marginTop: spacing.xs }}>
{subtitle}
</Text>
)}
</div>
{extra && (
<Space wrap size="small">
{extra}
</Space>
)}
</div>
</div>
);
}
export default PageHeader;

View file

@ -0,0 +1,223 @@
// PeriodFilter - Reusable component for filtering by date periods
import { useState, useMemo } from 'react';
import { Space, Select, DatePicker, Typography } from 'antd';
import { CalendarOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import quarterOfYear from 'dayjs/plugin/quarterOfYear';
import { usePeriodStore } from '@/stores/periodStore';
import { spacing } from '@/styles/designTokens';
dayjs.extend(quarterOfYear);
const { RangePicker } = DatePicker;
const { Text } = Typography;
export type PeriodPreset =
| 'today'
| 'yesterday'
| 'this_week'
| 'last_week'
| 'this_month'
| 'last_month'
| 'this_quarter'
| 'last_quarter'
| 'this_year'
| 'last_year'
| 'custom';
export interface DateRange {
startDate: Dayjs;
endDate: Dayjs;
}
export interface PeriodFilterProps {
value?: PeriodPreset;
dateRange?: DateRange;
onChange?: (preset: PeriodPreset, dateRange: DateRange) => void;
showLabel?: boolean;
size?: 'small' | 'middle' | 'large';
}
const presetOptions: { value: PeriodPreset; label: string }[] = [
{ value: 'today', label: 'I dag' },
{ value: 'yesterday', label: 'I går' },
{ value: 'this_week', label: 'Denne uge' },
{ value: 'last_week', label: 'Sidste uge' },
{ value: 'this_month', label: 'Denne måned' },
{ value: 'last_month', label: 'Sidste måned' },
{ value: 'this_quarter', label: 'Dette kvartal' },
{ value: 'last_quarter', label: 'Sidste kvartal' },
{ value: 'this_year', label: 'Dette regnskabsår' },
{ value: 'last_year', label: 'Sidste regnskabsår' },
{ value: 'custom', label: 'Vælg datoer...' },
];
export function getDateRangeForPreset(preset: PeriodPreset, fiscalYearStartMonth?: number): DateRange {
const today = dayjs();
const fyStart = fiscalYearStartMonth || 1; // Default to January
// Helper to get fiscal year start date
const getFiscalYearStart = (year: number) => {
const fy = dayjs().year(year).month(fyStart - 1).startOf('month');
// If we're before the fiscal year start month, use previous year
if (today.isBefore(dayjs().month(fyStart - 1).startOf('month'))) {
return fy.subtract(1, 'year');
}
return fy;
};
switch (preset) {
case 'today':
return { startDate: today.startOf('day'), endDate: today.endOf('day') };
case 'yesterday':
return {
startDate: today.subtract(1, 'day').startOf('day'),
endDate: today.subtract(1, 'day').endOf('day'),
};
case 'this_week':
return {
startDate: today.startOf('week'),
endDate: today.endOf('week'),
};
case 'last_week':
return {
startDate: today.subtract(1, 'week').startOf('week'),
endDate: today.subtract(1, 'week').endOf('week'),
};
case 'this_month':
return {
startDate: today.startOf('month'),
endDate: today.endOf('month'),
};
case 'last_month':
return {
startDate: today.subtract(1, 'month').startOf('month'),
endDate: today.subtract(1, 'month').endOf('month'),
};
case 'this_quarter':
return {
startDate: today.startOf('quarter'),
endDate: today.endOf('quarter'),
};
case 'last_quarter':
return {
startDate: today.subtract(1, 'quarter').startOf('quarter'),
endDate: today.subtract(1, 'quarter').endOf('quarter'),
};
case 'this_year': {
const fyStartDate = getFiscalYearStart(today.year());
return {
startDate: fyStartDate,
endDate: fyStartDate.add(1, 'year').subtract(1, 'day'),
};
}
case 'last_year': {
const fyStartDate = getFiscalYearStart(today.year()).subtract(1, 'year');
return {
startDate: fyStartDate,
endDate: fyStartDate.add(1, 'year').subtract(1, 'day'),
};
}
case 'custom':
default:
return { startDate: today.startOf('month'), endDate: today.endOf('month') };
}
}
export function PeriodFilter({
value = 'this_month',
dateRange: externalDateRange,
onChange,
showLabel = true,
size = 'middle',
}: PeriodFilterProps) {
const { currentFiscalYear } = usePeriodStore();
const [selectedPreset, setSelectedPreset] = useState<PeriodPreset>(value);
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
const fiscalYearStartMonth = currentFiscalYear?.startDate
? dayjs(currentFiscalYear.startDate).month() + 1
: 1;
const currentDateRange = useMemo(() => {
if (externalDateRange) return externalDateRange;
if (selectedPreset === 'custom' && customRange) {
return { startDate: customRange[0], endDate: customRange[1] };
}
return getDateRangeForPreset(selectedPreset, fiscalYearStartMonth);
}, [selectedPreset, customRange, externalDateRange, fiscalYearStartMonth]);
const handlePresetChange = (newPreset: PeriodPreset) => {
setSelectedPreset(newPreset);
if (newPreset !== 'custom') {
const newRange = getDateRangeForPreset(newPreset, fiscalYearStartMonth);
onChange?.(newPreset, newRange);
}
};
const handleCustomRangeChange = (dates: [Dayjs, Dayjs] | null) => {
setCustomRange(dates);
if (dates) {
onChange?.('custom', { startDate: dates[0], endDate: dates[1] });
}
};
const formatDateRange = () => {
const { startDate, endDate } = currentDateRange;
if (startDate.isSame(endDate, 'day')) {
return startDate.format('D. MMM YYYY');
}
if (startDate.isSame(endDate, 'year')) {
return `${startDate.format('D. MMM')} - ${endDate.format('D. MMM YYYY')}`;
}
return `${startDate.format('D. MMM YYYY')} - ${endDate.format('D. MMM YYYY')}`;
};
return (
<Space size={spacing.sm}>
{showLabel && (
<Space size={4}>
<CalendarOutlined />
<Text type="secondary">Periode:</Text>
</Space>
)}
<Select
value={selectedPreset}
onChange={handlePresetChange}
options={presetOptions}
style={{ minWidth: 160 }}
size={size}
/>
{selectedPreset === 'custom' && (
<RangePicker
value={customRange}
onChange={(dates) => handleCustomRangeChange(dates as [Dayjs, Dayjs] | null)}
format="DD-MM-YYYY"
size={size}
allowClear={false}
/>
)}
{selectedPreset !== 'custom' && (
<Text type="secondary" style={{ marginLeft: spacing.xs }}>
{formatDateRange()}
</Text>
)}
</Space>
);
}
export default PeriodFilter;

View file

@ -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<TooltipProps, 'title'> {
/** 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:
* <ShortcutTooltip shortcutId="newInvoice" label="Ny faktura">
* <Button>Ny faktura</Button>
* </ShortcutTooltip>
*
* Or with direct keys:
* <ShortcutTooltip shortcutKeys="mod+i" label="Ny faktura">
* <Button>Ny faktura</Button>
* </ShortcutTooltip>
*/
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 = (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{shortcutLabel && <span>{shortcutLabel}</span>}
<div style={{ display: 'flex', gap: 4 }}>
{formattedKeys.split(' ').map((key, i) => (
<Tag
key={i}
style={{
margin: 0,
padding: '0 6px',
fontSize: 11,
fontFamily: 'monospace',
backgroundColor: 'rgba(255, 255, 255, 0.15)',
border: 'none',
borderRadius: 3,
color: 'inherit',
}}
>
{key}
</Tag>
))}
</div>
</div>
);
return (
<Tooltip title={tooltipContent} {...tooltipProps}>
{children}
</Tooltip>
);
}
/**
* 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 (
<span
style={{
display: 'inline-flex',
gap: 2,
marginLeft: 8,
...style,
}}
>
{formattedKeys.split(' ').map((key, i) => (
<Tag
key={i}
style={{
margin: 0,
padding: '0 4px',
fontSize: 10,
fontFamily: 'monospace',
backgroundColor: token.colorFillSecondary,
border: 'none',
borderRadius: 3,
color: token.colorTextSecondary,
lineHeight: '16px',
}}
>
{key}
</Tag>
))}
</span>
);
}
export default ShortcutTooltip;

View file

@ -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 (
<Modal
open={open}
onCancel={onClose}
footer={null}
title="Tastaturgenveje"
centered
width={600}
styles={{
body: { maxHeight: '70vh', overflowY: 'auto' },
}}
>
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
Brug disse genveje til at navigere hurtigere i applikationen.
</Text>
{categoryOrder.map((category, index) => {
const shortcuts = groupedShortcuts[category];
if (!shortcuts || shortcuts.length === 0) return null;
return (
<div key={category}>
{index > 0 && <Divider style={{ margin: '16px 0' }} />}
<Title level={5} style={{ marginBottom: 12 }}>
{categoryNames[category]}
</Title>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{shortcuts.map((shortcut) => (
<ShortcutRow key={shortcut.id} shortcut={shortcut} />
))}
</div>
</div>
);
})}
<Divider style={{ margin: '16px 0' }} />
<Text type="secondary" style={{ fontSize: 12 }}>
Tip: Tryk <Tag style={{ margin: '0 4px' }}>K</Tag> for at abne
kommandopaletten og hurtigt navigere til enhver side.
</Text>
</Modal>
);
}
interface ShortcutRowProps {
shortcut: ShortcutDefinition;
}
function ShortcutRow({ shortcut }: ShortcutRowProps) {
const { token } = useToken();
const formattedKeys = formatShortcut(shortcut.keys);
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
backgroundColor: token.colorFillQuaternary,
borderRadius: 6,
}}
>
<div>
<Text>{shortcut.label}</Text>
{shortcut.description && (
<Text
type="secondary"
style={{ display: 'block', fontSize: 12, marginTop: 2 }}
>
{shortcut.description}
</Text>
)}
</div>
<div style={{ display: 'flex', gap: 4 }}>
{formattedKeys.split(' ').map((key, i) => (
<Tag
key={i}
style={{
margin: 0,
padding: '2px 8px',
fontSize: 12,
fontFamily: 'monospace',
fontWeight: 500,
backgroundColor: token.colorBgContainer,
border: `1px solid ${token.colorBorder}`,
borderRadius: 4,
boxShadow: `0 1px 0 ${token.colorBorderSecondary}`,
}}
>
{key}
</Tag>
))}
</div>
</div>
);
}
export default ShortcutsHelpModal;

View file

@ -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 ? <SkeletonLoader type="table" rows={5} /> : <MyTable />}
*/
export function SkeletonLoader({
type = 'page',
rows = 5,
cards = 3,
avatar = false,
}: SkeletonLoaderProps) {
switch (type) {
case 'table':
return (
<div>
{/* Table header */}
<Skeleton.Input active style={{ width: '100%', marginBottom: spacing.md }} />
{/* Table rows */}
{Array.from({ length: rows }).map((_, i) => (
<Skeleton.Input
key={i}
active
style={{ width: '100%', marginBottom: spacing.xs, height: 40 }}
/>
))}
</div>
);
case 'card':
return (
<Row gutter={[spacing.md, spacing.md]}>
{Array.from({ length: cards }).map((_, i) => (
<Col key={i} xs={24} sm={12} lg={8}>
<Card>
<Skeleton active avatar={avatar} paragraph={{ rows: 2 }} />
</Card>
</Col>
))}
</Row>
);
case 'form':
return (
<Space direction="vertical" style={{ width: '100%' }} size="large">
{Array.from({ length: rows }).map((_, i) => (
<div key={i}>
<Skeleton.Input active size="small" style={{ width: 100, marginBottom: spacing.xs }} />
<Skeleton.Input active style={{ width: '100%' }} />
</div>
))}
<Skeleton.Button active style={{ width: 120 }} />
</Space>
);
case 'list':
return (
<Space direction="vertical" style={{ width: '100%' }} size="small">
{Array.from({ length: rows }).map((_, i) => (
<Skeleton
key={i}
active
avatar={avatar}
paragraph={{ rows: 1, width: '80%' }}
title={{ width: '40%' }}
/>
))}
</Space>
);
case 'page':
default:
return (
<div>
{/* Page header */}
<div style={{ marginBottom: spacing.lg }}>
<Skeleton.Input active style={{ width: 200, marginBottom: spacing.sm }} />
<Skeleton.Input active style={{ width: 300 }} />
</div>
{/* Content cards */}
<Row gutter={[spacing.md, spacing.md]} style={{ marginBottom: spacing.lg }}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Skeleton active paragraph={{ rows: 1 }} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Skeleton active paragraph={{ rows: 1 }} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Skeleton active paragraph={{ rows: 1 }} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Skeleton active paragraph={{ rows: 1 }} />
</Card>
</Col>
</Row>
{/* Main content area */}
<Card>
<Skeleton active paragraph={{ rows: 6 }} />
</Card>
</div>
);
}
}
/**
* Inline skeleton for text content
*/
export function TextSkeleton({ width = 100 }: { width?: number | string }) {
return <Skeleton.Input active size="small" style={{ width, verticalAlign: 'middle' }} />;
}
/**
* Skeleton for amount/number display
*/
export function AmountSkeleton() {
return <Skeleton.Input active size="small" style={{ width: 80, verticalAlign: 'middle' }} />;
}
export default SkeletonLoader;

View file

@ -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
* <StatisticCard
* title="Likviditet"
* value={1234567.89}
* icon={<BankOutlined />}
* 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 ? <RiseOutlined /> : <FallOutlined />;
};
const formatValue = (val: number | string) => {
if (isCurrency) {
return formatCurrency(val as number);
}
return val;
};
return (
<Card size={size} loading={loading}>
<Statistic
title={title}
value={value}
precision={precision}
prefix={icon}
suffix={suffix}
valueStyle={getValueStyle()}
formatter={isCurrency ? (v) => formatValue(v as number) : undefined}
/>
{(change !== undefined || footer) && (
<div style={{ marginTop: spacing.sm }}>
{change !== undefined && (
<Tag color={getChangeColor()} icon={getChangeIcon()}>
{change >= 0 ? '+' : ''}
{(change * 100).toFixed(1)}% {changeLabel}
</Tag>
)}
{footer && <div style={{ marginTop: change !== undefined ? spacing.xs : 0 }}>{footer}</div>}
</div>
)}
</Card>
);
}
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 (
<Space size={4}>
{tags.map((tag, index) => (
<Tag key={index} color={tag.color} icon={tag.icon}>
{tag.count !== undefined ? `${tag.count} ` : ''}
{tag.label}
</Tag>
))}
</Space>
);
}
/**
* Responsive column spans for StatisticCard grids
* Use with Ant Design's Col component
*/
export const statisticCardColSpan = colSpans.kpiCard;
export default StatisticCard;

View file

@ -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: <CheckCircleOutlined />,
defaultText: 'Aktiv',
},
inactive: {
color: 'default',
icon: <MinusCircleOutlined />,
defaultText: 'Inaktiv',
},
pending: {
color: 'orange',
icon: <ClockCircleOutlined />,
defaultText: 'Afventer',
},
error: {
color: 'red',
icon: <CloseCircleOutlined />,
defaultText: 'Fejl',
},
success: {
color: 'green',
icon: <CheckCircleOutlined />,
defaultText: 'Succces',
},
warning: {
color: 'orange',
icon: <ExclamationCircleOutlined />,
defaultText: 'Advarsel',
},
info: {
color: 'blue',
icon: <ExclamationCircleOutlined />,
defaultText: 'Info',
},
draft: {
color: 'default',
icon: <MinusCircleOutlined />,
defaultText: 'Kladde',
},
closed: {
color: 'default',
icon: <LockOutlined />,
defaultText: 'Lukket',
},
locked: {
color: 'default',
icon: <LockOutlined />,
defaultText: 'Låst',
},
processing: {
color: 'blue',
icon: <SyncOutlined spin />,
defaultText: 'Behandles',
},
};
/**
* A consistent badge/tag component for displaying status.
*
* @example
* <StatusBadge status="active" />
* <StatusBadge status="pending" text="Afventer godkendelse" />
* <StatusBadge status="error" showIcon tooltip="Fejl ved behandling" />
*/
export function StatusBadge({
status,
text,
showIcon = true,
icon,
tooltip,
size = 'default',
dot = false,
}: StatusBadgeProps) {
const config = statusConfig[status];
if (dot) {
const badge = <Badge status={status === 'active' || status === 'success' ? 'success' : status === 'error' ? 'error' : status === 'pending' || status === 'warning' ? 'warning' : status === 'processing' ? 'processing' : 'default'} text={text || config.defaultText} />;
return tooltip ? <Tooltip title={tooltip}>{badge}</Tooltip> : badge;
}
const tag = (
<Tag
color={config.color}
icon={showIcon ? (icon || config.icon) : undefined}
style={size === 'small' ? { fontSize: 11, padding: '0 4px', lineHeight: '18px' } : undefined}
>
{text || config.defaultText}
</Tag>
);
return tooltip ? <Tooltip title={tooltip}>{tag}</Tooltip> : tag;
}
/**
* Fiscal year specific status badge
*/
export type FiscalYearStatus = 'active' | 'closed' | 'future';
const fiscalYearStatusConfig: Record<FiscalYearStatus, { status: StatusType; text: string }> = {
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 <StatusBadge status={config.status} text={config.text} />;
}
/**
* Account status badge
*/
export type AccountStatus = 'active' | 'inactive' | 'locked';
export function AccountStatusBadge({ status }: { status: AccountStatus }) {
return <StatusBadge status={status} />;
}
/**
* Transaction status badge
*/
export type TransactionStatus = 'posted' | 'pending' | 'draft' | 'cancelled';
const transactionStatusConfig: Record<TransactionStatus, { status: StatusType; text: string }> = {
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 <StatusBadge status={config.status} text={config.text} />;
}
/**
* 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 (
<Tag color={statusConfig[status].color}>
{count} {label}
</Tag>
);
}
export default StatusBadge;

View file

@ -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';

View file

@ -0,0 +1,126 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
interface UseAutoSaveOptions<T> {
/** Data to auto-save */
data: T;
/** Function to save the data */
onSave: (data: T) => Promise<void>;
/** 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<void>;
/** 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<T>({
data,
onSave,
debounceMs = 2000,
enabled = true,
onSaveStart,
onSaveSuccess,
onSaveError,
}: UseAutoSaveOptions<T>): UseAutoSaveReturn {
const [isDirty, setIsDirty] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Keep a ref to the latest data for saving
const dataRef = useRef(data);
const initialDataRef = useRef<string | null>(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,
};
}

View file

@ -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;

View file

@ -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(

View file

@ -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 <Modal {...modalProps} title="My Modal">...</Modal>
*/
export function useResponsiveModal(options: ResponsiveModalOptions = {}): Partial<ModalProps> {
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;

View file

@ -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<string, string> = {
// 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<NotificationArgsProps>
): 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',
});
}

View file

@ -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';

View file

@ -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<ListReadModelTypesResponse>(LIST_READ_MODEL_TYPES);
return response.listReadModelTypes;
},
enabled: isAdmin,
});
// Repopulate mutation
const repopulateMutation = useMutation({
mutationFn: async (variables: { aggregateId: string; readModelType: string }) => {
return graphqlClient.request<RepopulateReadModelResponse>(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 (
<Result
status="403"
icon={<LockOutlined />}
title="Adgang nægtet"
subTitle="Du har ikke adgang til admin-området. Kun autoriserede administratorer kan tilgå denne side."
extra={
<Text type="secondary">
Logget ind som: {user?.email || 'Ukendt'}
</Text>
}
/>
);
}
return (
<div>
{/* Header */}
<div style={{ marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>
<ToolOutlined /> Administration
</Title>
<Text type="secondary">Systemværktøjer til fejlfinding og vedligeholdelse</Text>
</div>
<Alert
message="Advarsel"
description="Disse værktøjer er beregnet til fejlfinding og kan påvirke systemets tilstand. Brug dem med forsigtighed."
type="warning"
showIcon
style={{ marginBottom: 24 }}
/>
{/* Read Model Repair */}
<Card title="Read Model Repair">
<Paragraph type="secondary">
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 grund af inkonsistent tilstand.
</Paragraph>
<Divider />
{typesLoading ? (
<Spin tip="Henter read model typer..." />
) : (
<Form
form={form}
layout="vertical"
style={{ maxWidth: 600 }}
>
<Form.Item
name="readModelType"
label="Read Model Type"
rules={[{ required: true, message: 'Vælg en read model type' }]}
>
<Select
placeholder="Vælg read model type"
options={readModelTypes?.map(type => ({
value: type,
label: type,
}))}
/>
</Form.Item>
<Form.Item
name="aggregateId"
label="Aggregate ID"
rules={[{ required: true, message: 'Indtast aggregate ID' }]}
extra="F.eks. 'journalentrydraft-abc123-def456-...' eller 'company-xyz789-...'"
>
<Input
placeholder="journalentrydraft-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
style={{ fontFamily: 'monospace' }}
/>
</Form.Item>
<Space>
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={handleRepopulate}
loading={repopulateMutation.isPending}
>
Genopbyg Read Model
</Button>
<Button onClick={() => { form.resetFields(); setLastResult(null); }}>
Nulstil
</Button>
</Space>
</Form>
)}
{lastResult && (
<Alert
style={{ marginTop: 16 }}
message={lastResult.success ? 'Succes' : 'Fejl'}
description={lastResult.message}
type={lastResult.success ? 'success' : 'error'}
showIcon
/>
)}
<Divider />
<Title level={5}>Tilgængelige Read Model Typer</Title>
<Paragraph type="secondary" style={{ fontSize: 12 }}>
{readModelTypes?.join(', ') || 'Indlæser...'}
</Paragraph>
</Card>
{/* Background Jobs Dashboard */}
<Card title="Baggrundsjobs" style={{ marginTop: 24 }}>
<Paragraph type="secondary">
Hangfire dashboard giver overblik over baggrundsjobs, køer, og fejlede opgaver i systemet.
</Paragraph>
<Button
type="primary"
icon={<DashboardOutlined />}
href={`${BACKEND_BASE_URL}/hangfire`}
target="_blank"
rel="noopener noreferrer"
>
Åbn Hangfire Dashboard
</Button>
</Card>
</div>
);
}

View file

@ -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<CompanyFormValues>();
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<Partial<CompanyFormValues>>({});
// 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: <ShopOutlined />,
},
{
title: 'Regnskab',
icon: <BankOutlined />,
},
{
title: 'Moms',
icon: <BankOutlined />,
},
{
title: 'Bekræft',
icon: <CheckCircleOutlined />,
},
];
const renderStepContent = () => {
switch (currentStep) {
case 0:
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Title level={4}>Fortæl os om din virksomhed</Title>
<Text type="secondary">
Vi bruger disse oplysninger til at oprette din kontoplan og konfigurere systemet.
</Text>
</div>
<Form.Item
name="name"
label="Virksomhedsnavn"
rules={[{ required: true, message: 'Indtast virksomhedsnavn' }]}
>
<Input
size="large"
placeholder="F.eks. Min Virksomhed ApS"
prefix={<ShopOutlined />}
/>
</Form.Item>
<Form.Item
name="cvr"
label="CVR-nummer"
extra="Valgfrit - kan tilføjes senere"
rules={[
{
pattern: /^\d{8}$/,
message: 'CVR-nummer skal være 8 cifre',
},
{
validator: (_, value) => {
if (!value || value.length !== 8) return Promise.resolve();
if (!validateCVRModulus11(value)) {
return Promise.reject('Ugyldigt CVR-nummer (modulus 11 check fejlet)');
}
return Promise.resolve();
},
},
]}
>
<Input
size="large"
placeholder="12345678"
maxLength={8}
/>
</Form.Item>
</Space>
);
case 1:
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Title level={4}>Regnskabsindstillinger</Title>
<Text type="secondary">
Konfigurer dine grundlæggende regnskabsindstillinger.
</Text>
</div>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="country"
label="Land"
rules={[{ required: true, message: 'Vælg land' }]}
>
<Select size="large" placeholder="Vælg land">
<Select.Option value="DK">Danmark</Select.Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="currency"
label="Valuta"
rules={[{ required: true, message: 'Vælg valuta' }]}
>
<Select size="large" placeholder="Vælg valuta">
<Select.Option value="DKK">DKK - Danske kroner</Select.Option>
<Select.Option value="EUR">EUR - Euro</Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item
name="fiscalYearStartMonth"
label="Regnskabsår starter"
rules={[{ required: true, message: 'Vælg startmåned' }]}
extra="De fleste virksomheder bruger januar (kalenderår)"
>
<Select
size="large"
placeholder="Vælg måned"
options={monthOptions}
/>
</Form.Item>
</Space>
);
case 2:
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Title level={4}>Momsregistrering</Title>
<Text type="secondary">
Er din virksomhed momsregistreret hos SKAT?
</Text>
</div>
<Form.Item
name="vatRegistered"
label="Momsregistreret"
rules={[{ required: true, message: 'Angiv om virksomheden er momsregistreret' }]}
>
<Select size="large" placeholder="Vælg">
<Select.Option value={true}>Ja, virksomheden er momsregistreret</Select.Option>
<Select.Option value={false}>Nej, ikke momsregistreret</Select.Option>
</Select>
</Form.Item>
{vatRegistered && (
<Form.Item
name="vatPeriodFrequency"
label="Momsperiode"
rules={[{ required: true, message: 'Vælg momsperiode' }]}
>
<Select
size="large"
placeholder="Vælg momsperiode"
options={vatPeriodOptions}
/>
</Form.Item>
)}
{vatRegistered === false && (
<Alert
message="Ikke momsregistreret"
description="Du kan altid registrere virksomheden for moms senere via SKAT's hjemmeside."
type="info"
showIcon
/>
)}
</Space>
);
case 3:
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Title level={4}>Bekræft dine oplysninger</Title>
<Text type="secondary">
Gennemgå dine oplysninger før vi opretter virksomheden.
</Text>
</div>
<Card size="small">
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<Text type="secondary">Virksomhedsnavn</Text>
<br />
<Text strong style={{ fontSize: 16 }}>{formSnapshot.name || '-'}</Text>
</div>
<Divider style={{ margin: '12px 0' }} />
{formSnapshot.cvr && (
<>
<div>
<Text type="secondary">CVR-nummer</Text>
<br />
<Text strong>{formSnapshot.cvr}</Text>
</div>
<Divider style={{ margin: '12px 0' }} />
</>
)}
<Row gutter={24}>
<Col span={8}>
<Text type="secondary">Land</Text>
<br />
<Text strong>{formSnapshot.country === 'DK' ? 'Danmark' : formSnapshot.country || '-'}</Text>
</Col>
<Col span={8}>
<Text type="secondary">Valuta</Text>
<br />
<Text strong>{formSnapshot.currency || '-'}</Text>
</Col>
<Col span={8}>
<Text type="secondary">Regnskabsår starter</Text>
<br />
<Text strong>
{monthOptions.find((m) => m.value === formSnapshot.fiscalYearStartMonth)?.label || '-'}
</Text>
</Col>
</Row>
<Divider style={{ margin: '12px 0' }} />
<div>
<Text type="secondary">Momsregistrering</Text>
<br />
<Text strong>
{formSnapshot.vatRegistered
? `Ja - ${vatPeriodOptions.find((v) => v.value === formSnapshot.vatPeriodFrequency)?.label || 'Vælg periode'}`
: 'Ikke momsregistreret'}
</Text>
</div>
</Space>
</Card>
<Alert
message="Klar til at starte"
description="Når du opretter virksomheden, genereres automatisk en dansk standardkontoplan med alle nødvendige konti."
type="success"
showIcon
/>
</Space>
);
case 4:
return (
<Result
status="success"
icon={<RocketOutlined style={{ color: colors.primary }} />}
title={`${createdCompanyName} er oprettet!`}
subTitle="Din virksomhed er klar til brug. Vi har oprettet en komplet dansk kontoplan for dig."
extra={[
<Button
type="primary"
size="large"
key="dashboard"
onClick={handleGoToDashboard}
>
til dashboard
</Button>,
]}
/>
);
default:
return null;
}
};
return (
<div
style={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
}}
>
<Card
style={{
maxWidth: 600,
width: '100%',
borderRadius: 16,
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}}
>
{currentStep < 4 && (
<>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<ShopOutlined style={{ fontSize: 48, color: colors.primary }} />
<Title level={2} style={{ marginTop: 16, marginBottom: 8 }}>
Velkommen til Books
</Title>
<Paragraph type="secondary">
Lad os oprette din første virksomhed
</Paragraph>
</div>
<Steps
current={currentStep}
items={steps}
style={{ marginBottom: 32 }}
size="small"
/>
</>
)}
<Form
form={form}
layout="vertical"
preserve={true}
initialValues={{
country: 'DK',
currency: 'DKK',
fiscalYearStartMonth: 1,
vatRegistered: false,
}}
>
{renderStepContent()}
</Form>
{currentStep < 4 && (
<div style={{ marginTop: 32, display: 'flex', justifyContent: 'space-between' }}>
{currentStep > 0 ? (
<Button size="large" onClick={handleBack} icon={<ArrowLeftOutlined />}>
Tilbage
</Button>
) : (
<div />
)}
{currentStep < 3 ? (
<Button type="primary" size="large" onClick={handleNext}>
Næste <ArrowRightOutlined />
</Button>
) : (
<Button
type="primary"
size="large"
onClick={handleSubmit}
loading={createCompany.isPending}
>
Opret virksomhed
</Button>
)}
</div>
)}
</Card>
</div>
);
}

View file

@ -0,0 +1,209 @@
import { useState } from 'react';
import {
Typography,
Card,
Row,
Col,
Form,
Select,
Button,
Tag,
Alert,
Spin,
} from 'antd';
import { showSuccess, showError } from '@/lib/errorHandling';
import { DownloadOutlined, FileTextOutlined } from '@ant-design/icons';
import { useCompany } from '@/hooks/useCompany';
import { useFiscalYears } from '@/api/queries/fiscalYearQueries';
import { useExportSaft, downloadSaftFile } from '@/api/mutations/saftMutations';
import { formatDate } from '@/lib/formatters';
import { spacing } from '@/styles/designTokens';
const { Title, Text, Paragraph } = Typography;
export default function Eksport() {
const { company } = useCompany();
const { data: fiscalYears, isLoading: fiscalYearsLoading } = useFiscalYears(company?.id);
const exportSaft = useExportSaft();
const [selectedFiscalYear, setSelectedFiscalYear] = useState<string>();
const handleExportSaft = async () => {
if (!company?.id || !selectedFiscalYear) {
showError('Vælg venligst et regnskabsår');
return;
}
try {
const result = await exportSaft.mutateAsync({
companyId: company.id,
fiscalYearId: selectedFiscalYear,
});
if (result.success) {
downloadSaftFile(result);
showSuccess(`SAF-T fil downloadet: ${result.fileName}`);
} else {
showError(result.errorMessage || 'Kunne ikke generere SAF-T fil');
}
} catch (error) {
showError('Der opstod en fejl under eksport');
console.error('SAF-T export error:', error);
}
};
if (fiscalYearsLoading) {
return (
<div style={{ textAlign: 'center', padding: spacing.xl }}>
<Spin size="large" />
</div>
);
}
const selectedYear = fiscalYears?.find(fy => fy.id === selectedFiscalYear);
return (
<div>
<Title level={4}>Eksporter data</Title>
<Paragraph type="secondary">
Eksporter regnskabsdata til forskellige formater for compliance og rapportering.
</Paragraph>
<Row gutter={[spacing.lg, spacing.lg]}>
{/* SAF-T Export Card */}
<Col xs={24} md={12}>
<Card
title={
<span>
<FileTextOutlined style={{ marginRight: 8 }} />
SAF-T (Standard Audit File for Tax)
</span>
}
>
<Paragraph type="secondary">
Eksporter regnskabsdata i SAF-T format til SKAT.
Dette er det danske standardformat for digital indberetning af regnskabsdata.
</Paragraph>
<Alert
message="Lovkrav fra 1. januar 2027"
description="SAF-T eksport bliver obligatorisk for alle virksomheder med omsætning over 300.000 DKK."
type="info"
showIcon
style={{ marginBottom: spacing.md }}
/>
<Form layout="vertical">
<Form.Item
label="Regnskabsår"
required
help={selectedYear ? `${formatDate(selectedYear.startDate)} - ${formatDate(selectedYear.endDate)}` : undefined}
>
<Select
placeholder="Vælg regnskabsår"
value={selectedFiscalYear}
onChange={setSelectedFiscalYear}
loading={fiscalYearsLoading}
options={fiscalYears?.map(fy => ({
value: fy.id,
label: (
<span>
{fy.name}
{fy.status === 'locked' && (
<Tag color="red" style={{ marginLeft: 8 }}>Låst</Tag>
)}
{fy.status === 'closed' && (
<Tag color="orange" style={{ marginLeft: 8 }}>Afsluttet</Tag>
)}
</span>
),
}))}
style={{ width: '100%' }}
/>
</Form.Item>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleExportSaft}
loading={exportSaft.isPending}
disabled={!selectedFiscalYear}
block
>
Download SAF-T XML
</Button>
</Form>
<div style={{ marginTop: spacing.md }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Filen inkluderer: Kontoplan, kunder, leverandører og alle posteringer for perioden.
</Text>
</div>
</Card>
</Col>
{/* CSV Export Card (Coming Soon) */}
<Col xs={24} md={12}>
<Card
title={
<span>
<FileTextOutlined style={{ marginRight: 8 }} />
CSV Eksport
</span>
}
extra={<Tag>Kommer snart</Tag>}
>
<Paragraph type="secondary">
Eksporter kontoplan, posteringer eller kunder som CSV-filer til brug i regneark.
</Paragraph>
<Button disabled block>
Ikke tilgængelig endnu
</Button>
</Card>
</Col>
{/* PDF Report Card (Coming Soon) */}
<Col xs={24} md={12}>
<Card
title={
<span>
<FileTextOutlined style={{ marginRight: 8 }} />
Årsrapport (PDF)
</span>
}
extra={<Tag>Kommer snart</Tag>}
>
<Paragraph type="secondary">
Generér årsrapport i PDF format med resultatopgørelse og balance.
</Paragraph>
<Button disabled block>
Ikke tilgængelig endnu
</Button>
</Card>
</Col>
{/* Backup Card (Coming Soon) */}
<Col xs={24} md={12}>
<Card
title={
<span>
<FileTextOutlined style={{ marginRight: 8 }} />
Fuld backup
</span>
}
extra={<Tag>Kommer snart</Tag>}
>
<Paragraph type="secondary">
Download en komplet backup af alle virksomhedens data i JSON format.
</Paragraph>
<Button disabled block>
Ikke tilgængelig endnu
</Button>
</Card>
</Col>
</Row>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -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<string, string> = {
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<string, string> = {
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<Invoice | null>(null);
const [editingLine, setEditingLine] = useState<InvoiceLine | null>(null);
const [searchText, setSearchText] = useState('');
const [statusFilter, setStatusFilter] = useState<InvoiceStatus | 'all'>('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<Invoice> = [
{
title: 'Kreditnotanr.',
dataIndex: 'invoiceNumber',
key: 'invoiceNumber',
width: 130,
sorter: (a, b) => a.invoiceNumber.localeCompare(b.invoiceNumber),
render: (value: string) => <Text code>{value}</Text>,
},
{
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) => <AmountText amount={-value} />,
},
{
title: 'Restbeløb',
dataIndex: 'amountRemaining',
key: 'amountRemaining',
width: 120,
align: 'right',
render: (value: number, record: Invoice) =>
record.status === 'voided' ? '-' : <AmountText amount={-value} />,
},
{
title: 'Orig. faktura',
dataIndex: 'originalInvoiceNumber',
key: 'originalInvoiceNumber',
width: 120,
render: (value: string | undefined) => (value ? <Text code>{value}</Text> : '-'),
},
{
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) => (
<Tag color={statusColors[value]}>{statusLabels[value]}</Tag>
),
},
{
title: '',
key: 'actions',
width: 80,
align: 'center',
render: (_: unknown, record: Invoice) => (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handleViewCreditNote(record)}
/>
),
},
];
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.lg,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Kreditnotaer
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateCreditNote}>
Ny kreditnota
</Button>
</div>
{/* Error State */}
{error && (
<Alert
message="Fejl ved indlæsning af kreditnotaer"
description={error.message}
type="error"
showIcon
style={{ marginBottom: spacing.lg }}
action={
<Button size="small" onClick={() => refetch()}>
Prøv igen
</Button>
}
/>
)}
{/* Statistics */}
<Row gutter={[spacing.lg, spacing.lg]} style={{ marginBottom: spacing.lg }}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="Kreditnotaer i alt" value={stats.total} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Kladder"
value={stats.draft}
valueStyle={{ color: '#8c8c8c' }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Ikke anvendt"
value={stats.unapplied}
precision={2}
valueStyle={{ color: accountingColors.credit }}
formatter={(value) => formatCurrency(value as number)}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Samlet værdi"
value={stats.totalValue}
precision={2}
valueStyle={{ color: accountingColors.debit }}
formatter={(value) => formatCurrency(value as number)}
/>
</Card>
</Col>
</Row>
{/* Filters */}
<Card size="small" style={{ marginBottom: spacing.lg }}>
<Space wrap>
<Input
placeholder="Søg kreditnota..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 250 }}
allowClear
/>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{ width: 170 }}
options={[
{ value: 'all', label: 'Alle status' },
{ value: 'draft', label: 'Kladde' },
{ value: 'issued', label: 'Udstedt' },
{ value: 'partially_applied', label: 'Delvist anvendt' },
{ value: 'fully_applied', label: 'Fuldt anvendt' },
{ value: 'voided', label: 'Annulleret' },
]}
/>
{customerIdFilter && (
<Tag closable onClose={() => navigate('/kreditnotaer')}>
Filtreret kunde
</Tag>
)}
</Space>
</Card>
{/* Credit Note Table */}
<Card size="small">
{loading ? (
<Spin
tip="Indlæser kreditnotaer..."
style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}
>
<div style={{ minHeight: 200 }} />
</Spin>
) : filteredCreditNotes.length > 0 ? (
<Table
dataSource={filteredCreditNotes}
columns={columns}
rowKey="id"
size="small"
pagination={{ pageSize: 20, showSizeChanger: true }}
/>
) : (
<EmptyState
variant="creditNotes"
title="Ingen kreditnotaer"
description={
searchText
? 'Ingen kreditnotaer matcher din søgning'
: 'Opret din første kreditnota'
}
primaryAction={
!searchText
? {
label: 'Opret kreditnota',
onClick: handleCreateCreditNote,
icon: <PlusOutlined />,
}
: undefined
}
/>
)}
</Card>
{/* Create Credit Note Modal */}
<Modal
title="Opret kreditnota"
open={isCreateModalOpen}
onCancel={() => setIsCreateModalOpen(false)}
onOk={handleSubmitCreate}
okText="Opret"
cancelText="Annuller"
confirmLoading={createCreditNoteMutation.isPending}
>
<Form form={createForm} layout="vertical">
<Form.Item
name="customerId"
label="Kunde"
rules={[{ required: true, message: 'Vælg kunde' }]}
>
<Select
showSearch
placeholder="Vælg kunde"
optionFilterProp="children"
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={customers.map((c: Customer) => ({
value: c.id,
label: `${c.customerNumber} - ${c.name}`,
}))}
/>
</Form.Item>
<Form.Item name="creditNoteDate" label="Kreditnotadato">
<DatePicker style={{ width: '100%' }} format="DD-MM-YYYY" />
</Form.Item>
<Form.Item name="originalInvoiceId" label="Original faktura (valgfri)">
<Select
showSearch
allowClear
placeholder="Vælg faktura der krediteres"
optionFilterProp="children"
/>
</Form.Item>
<Form.Item name="reason" label="Årsag">
<Input.TextArea rows={2} placeholder="Årsag til kreditering" />
</Form.Item>
</Form>
</Modal>
{/* Credit Note Detail Drawer */}
<Drawer
title={
selectedCreditNote && (
<Space>
<FileTextOutlined />
<span>Kreditnota {selectedCreditNote.invoiceNumber}</span>
<Tag color={statusColors[selectedCreditNote.status] || 'default'}>
{statusLabels[selectedCreditNote.status] || selectedCreditNote.status}
</Tag>
</Space>
)
}
placement="right"
width={700}
open={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setSelectedCreditNote(null);
}}
extra={
selectedCreditNote && (
<Space>
{selectedCreditNote.status === 'draft' && (
<>
<Button icon={<PlusOutlined />} onClick={handleAddLine}>
Tilføj linje
</Button>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleIssueCreditNote}
loading={issueCreditNoteMutation.isPending}
disabled={selectedCreditNote.lines.length === 0}
>
Udsted
</Button>
</>
)}
{['issued', 'partially_applied'].includes(selectedCreditNote.status) && (
<>
<Button icon={<LinkOutlined />} onClick={handleApplyCreditNote}>
Anvend faktura
</Button>
<Button danger icon={<StopOutlined />} onClick={handleVoidCreditNote}>
Annuller
</Button>
</>
)}
</Space>
)
}
>
{selectedCreditNote && (
<div>
<Descriptions column={2} size="small" bordered style={{ marginBottom: spacing.lg }}>
<Descriptions.Item label="Kunde" span={2}>
{selectedCreditNote.customerName}
</Descriptions.Item>
<Descriptions.Item label="Kreditnotadato">
{selectedCreditNote.invoiceDate ? formatDate(selectedCreditNote.invoiceDate) : '-'}
</Descriptions.Item>
<Descriptions.Item label="Original faktura">
{selectedCreditNote.originalInvoiceNumber || '-'}
</Descriptions.Item>
{selectedCreditNote.creditReason && (
<Descriptions.Item label="Årsag" span={2}>
{selectedCreditNote.creditReason}
</Descriptions.Item>
)}
</Descriptions>
<Title level={5}>Linjer</Title>
{selectedCreditNote.lines.length > 0 ? (
<List
size="small"
bordered
dataSource={selectedCreditNote.lines}
renderItem={(line: InvoiceLine) => (
<List.Item
actions={
selectedCreditNote.status === 'draft'
? [
<Button
key="edit"
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => handleEditLine(line)}
/>,
<Popconfirm
key="delete"
title="Fjern linje?"
onConfirm={() => handleRemoveLine(line.lineNumber)}
okText="Ja"
cancelText="Nej"
>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
/>
</Popconfirm>,
]
: undefined
}
>
<List.Item.Meta
title={line.description}
description={
<Space>
<span>
{line.quantity} x {formatCurrency(line.unitPrice)}
</span>
<Tag>{line.vatCode}</Tag>
</Space>
}
/>
<AmountText amount={-line.amountTotal} style={{ fontWeight: 'bold' }} />
</List.Item>
)}
/>
) : (
<Alert
message="Ingen linjer endnu"
description="Tilføj linjer for at kunne udstede kreditnotaen."
type="info"
showIcon
/>
)}
<Divider />
<Row gutter={16}>
<Col span={12} />
<Col span={12}>
<div style={{ textAlign: 'right' }}>
<div style={{ marginBottom: 4 }}>
<Text type="secondary">Beløb ex. moms: </Text>
<Text>{formatCurrency(-selectedCreditNote.amountExVat)}</Text>
</div>
<div style={{ marginBottom: 4 }}>
<Text type="secondary">Moms: </Text>
<Text>{formatCurrency(-selectedCreditNote.amountVat)}</Text>
</div>
<div style={{ marginBottom: 4 }}>
<Text strong>Total: </Text>
<Text strong style={{ fontSize: 16, color: accountingColors.credit }}>
{formatCurrency(-selectedCreditNote.amountTotal)}
</Text>
</div>
{selectedCreditNote.amountApplied > 0 && (
<div style={{ marginBottom: 4 }}>
<Text type="secondary">Anvendt: </Text>
<Text>{formatCurrency(-selectedCreditNote.amountApplied)}</Text>
</div>
)}
{selectedCreditNote.amountRemaining > 0 &&
selectedCreditNote.status !== 'voided' && (
<div>
<Text type="secondary">Restbeløb: </Text>
<Text strong style={{ color: accountingColors.credit }}>
{formatCurrency(-selectedCreditNote.amountRemaining)}
</Text>
</div>
)}
</div>
</Col>
</Row>
</div>
)}
</Drawer>
{/* Add/Edit Line Modal */}
<Modal
title={editingLine ? 'Rediger linje' : 'Tilføj linje'}
open={isLineModalOpen}
onCancel={() => {
setIsLineModalOpen(false);
setEditingLine(null);
lineForm.resetFields();
}}
onOk={handleSubmitLine}
okText="Gem"
cancelText="Annuller"
confirmLoading={addLineMutation.isPending || updateLineMutation.isPending}
>
<Form form={lineForm} layout="vertical">
<Form.Item
name="description"
label="Beskrivelse"
rules={[{ required: true, message: 'Indtast beskrivelse' }]}
>
<Input placeholder="Vare eller ydelse der krediteres" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="quantity"
label="Antal"
rules={[{ required: true, message: 'Indtast antal' }]}
>
<InputNumber min={0.01} step={1} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="unitPrice"
label="Enhedspris"
rules={[{ required: true, message: 'Indtast pris' }]}
>
<InputNumber
min={0}
step={0.01}
style={{ width: '100%' }}
/>
</Form.Item>
</Col>
</Row>
<Form.Item
name="vatCode"
label="Momskode"
rules={[{ required: true, message: 'Vælg momskode' }]}
>
<Select
options={[
{ value: 'S25', label: 'S25 - 25% moms' },
{ value: 'U0', label: 'U0 - Momsfrit' },
{ value: 'UEU', label: 'UEU - EU-salg' },
{ value: 'UEXP', label: 'UEXP - Eksport' },
]}
/>
</Form.Item>
</Form>
</Modal>
{/* Apply Credit Note Modal */}
<Modal
title="Anvend kreditnota på faktura"
open={isApplyModalOpen}
onCancel={() => setIsApplyModalOpen(false)}
onOk={handleSubmitApply}
okText="Anvend"
cancelText="Annuller"
confirmLoading={applyCreditNoteMutation.isPending}
>
<Form form={applyForm} layout="vertical">
<Form.Item
name="invoiceId"
label="Faktura"
rules={[{ required: true, message: 'Vælg faktura' }]}
>
<Select
showSearch
placeholder="Vælg faktura"
optionFilterProp="children"
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={openInvoices.map((i) => ({
value: i.id,
label: `${i.invoiceNumber} - ${i.customerName} (${formatCurrency(i.amountRemaining)})`,
}))}
/>
</Form.Item>
<Form.Item
name="amount"
label="Beløb"
rules={[{ required: true, message: 'Indtast beløb' }]}
>
<InputNumber
min={0.01}
max={selectedCreditNote?.amountRemaining}
style={{ width: '100%' }}
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, '.')}
parser={(value) => value!.replace(/\./g, '') as unknown as number}
addonAfter="DKK"
/>
</Form.Item>
</Form>
</Modal>
{/* Void Modal */}
<Modal
title="Annuller kreditnota"
open={isVoidModalOpen}
onCancel={() => setIsVoidModalOpen(false)}
onOk={handleSubmitVoid}
okText="Annuller kreditnota"
okButtonProps={{ danger: true }}
cancelText="Fortryd"
confirmLoading={voidMutation.isPending}
>
<Alert
message="Advarsel"
description="At annullere kreditnotaen vil tilbageføre alle bogførte posteringer. Denne handling kan ikke fortrydes."
type="warning"
showIcon
style={{ marginBottom: spacing.lg }}
/>
<Form form={voidForm} layout="vertical">
<Form.Item
name="reason"
label="Årsag til annullering"
rules={[{ required: true, message: 'Angiv årsag' }]}
>
<Input.TextArea rows={3} placeholder="Beskriv hvorfor kreditnotaen annulleres" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View file

@ -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<Customer | null>(null);
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(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<Customer> = [
{
title: 'Kundenr.',
dataIndex: 'customerNumber',
key: 'customerNumber',
width: 100,
sorter: (a, b) => a.customerNumber.localeCompare(b.customerNumber),
render: (value: string) => <Text code>{value}</Text>,
},
{
title: 'Navn',
dataIndex: 'name',
key: 'name',
sorter: (a, b) => a.name.localeCompare(b.name),
render: (value: string, record: Customer) => (
<Space>
{record.customerType === 'Business' ? <ShopOutlined /> : <UserOutlined />}
<Text strong>{value}</Text>
</Space>
),
},
{
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) => <Tag>{value} dage</Tag>,
},
{
title: 'Status',
dataIndex: 'isActive',
key: 'isActive',
width: 100,
align: 'center',
render: (value: boolean) => (
<StatusBadge status={value ? 'active' : 'inactive'} />
),
},
{
title: 'Handlinger',
key: 'actions',
width: 150,
align: 'center',
render: (_: unknown, record: Customer) => (
<Space size="small">
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handleView(record)}
/>
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
<Popconfirm
title={record.isActive ? 'Deaktiver kunde?' : 'Genaktiver kunde?'}
description={
record.isActive
? 'Kunden vil ikke kunne vælges ved oprettelse af fakturaer.'
: 'Kunden kan igen bruges til fakturaer.'
}
onConfirm={() => handleToggleActive(record)}
okText="Ja"
cancelText="Nej"
>
<Button
type="text"
size="small"
danger={record.isActive}
icon={record.isActive ? <StopOutlined /> : <CheckOutlined />}
/>
</Popconfirm>
</Space>
),
},
];
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.lg,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Kunder
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<ShortcutTooltip shortcutId="newCustomer">
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
Ny kunde
</Button>
</ShortcutTooltip>
</div>
{/* Error State */}
{error && (
<Alert
message="Fejl ved indlæsning af kunder"
description={error.message}
type="error"
showIcon
style={{ marginBottom: spacing.lg }}
action={
<Button size="small" onClick={() => refetch()}>
Prøv igen
</Button>
}
/>
)}
{/* Statistics */}
<Row gutter={[spacing.lg, spacing.lg]} style={{ marginBottom: spacing.lg }}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="Kunder i alt" value={stats.total} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="Aktive" value={stats.active} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Erhverv"
value={stats.business}
prefix={<ShopOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Privat"
value={stats.private}
prefix={<UserOutlined />}
/>
</Card>
</Col>
</Row>
{/* Filters */}
<Card size="small" style={{ marginBottom: spacing.lg }}>
<Space wrap>
<Input
placeholder="Søg kunde..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 250 }}
allowClear
/>
<Select
value={showInactive ? 'all' : 'active'}
onChange={(value) => setShowInactive(value === 'all')}
style={{ width: 150 }}
options={[
{ value: 'active', label: 'Kun aktive' },
{ value: 'all', label: 'Alle kunder' },
]}
/>
</Space>
</Card>
{/* Customer Table */}
<Card size="small">
{loading ? (
<Spin tip="Indlæser kunder..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
<div style={{ minHeight: 200 }} />
</Spin>
) : filteredCustomers.length > 0 ? (
<Table
dataSource={filteredCustomers}
columns={columns}
rowKey="id"
size="small"
pagination={{ pageSize: 20, showSizeChanger: true }}
/>
) : (
<EmptyState
variant="customers"
title="Ingen kunder"
description={searchText ? 'Ingen kunder matcher din søgning' : 'Opret din første kunde for at komme i gang'}
primaryAction={
!searchText
? {
label: 'Opret kunde',
onClick: handleCreate,
icon: <PlusOutlined />,
}
: undefined
}
/>
)}
</Card>
{/* Create/Edit Modal */}
<Modal
title={editingCustomer ? 'Rediger kunde' : 'Opret kunde'}
open={isModalOpen}
onCancel={() => {
setIsModalOpen(false);
setEditingCustomer(null);
form.resetFields();
}}
onOk={handleSubmit}
okText="Gem"
cancelText="Annuller"
confirmLoading={createCustomerMutation.isPending || updateCustomerMutation.isPending}
width={600}
>
<Form form={form} layout="vertical">
<Form.Item
name="customerType"
label="Kundetype"
rules={[{ required: true, message: 'Vælg kundetype' }]}
>
<Select
disabled={!!editingCustomer}
options={[
{ value: 'Business', label: 'Erhverv' },
{ value: 'Private', label: 'Privat' },
]}
/>
</Form.Item>
<Form.Item
name="name"
label="Navn"
rules={[{ required: true, message: 'Indtast kundenavn' }]}
>
<Input placeholder="Firmanavn eller fulde navn" />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) => prev.customerType !== curr.customerType}
>
{({ getFieldValue }) =>
getFieldValue('customerType') === 'Business' && (
<Form.Item
name="cvr"
label="CVR-nummer"
rules={[
{ required: true, message: 'CVR er påkrævet for erhvervskunder' },
{ pattern: /^\d{8}$/, message: 'CVR skal være 8 cifre' },
{
validator: (_, value) => {
if (!value || value.length !== 8) return Promise.resolve();
if (!validateCVRModulus11(value)) {
return Promise.reject('Ugyldigt CVR-nummer (modulus 11 check fejlet)');
}
return Promise.resolve();
},
},
]}
>
<Input placeholder="12345678" maxLength={8} />
</Form.Item>
)
}
</Form.Item>
<Row gutter={16}>
<Col span={24}>
<Form.Item name="address" label="Adresse">
<Input placeholder="Gade og husnummer" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="postalCode" label="Postnummer">
<Input placeholder="1234" maxLength={10} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="city" label="By">
<Input placeholder="København" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="country" label="Land" initialValue="DK">
<Select
showSearch
options={[
{ value: 'DK', label: 'Danmark' },
{ value: 'SE', label: 'Sverige' },
{ value: 'NO', label: 'Norge' },
{ value: 'DE', label: 'Tyskland' },
{ value: 'GB', label: 'Storbritannien' },
]}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="email"
label="Email"
rules={[{ type: 'email', message: 'Indtast gyldig email' }]}
>
<Input placeholder="kunde@firma.dk" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="phone" label="Telefon">
<Input placeholder="+45 12 34 56 78" />
</Form.Item>
</Col>
</Row>
<Form.Item
name="paymentTermsDays"
label="Betalingsbetingelser (dage)"
initialValue={30}
>
<Select
options={[
{ value: 0, label: 'Kontant' },
{ value: 8, label: '8 dage netto' },
{ value: 14, label: '14 dage netto' },
{ value: 30, label: '30 dage netto' },
{ value: 60, label: '60 dage netto' },
]}
/>
</Form.Item>
</Form>
</Modal>
{/* Customer Detail Drawer */}
<Drawer
title={selectedCustomer?.name}
placement="right"
width={500}
open={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setSelectedCustomer(null);
}}
extra={
selectedCustomer && (
<Button icon={<EditOutlined />} onClick={() => {
setIsDrawerOpen(false);
handleEdit(selectedCustomer);
}}>
Rediger
</Button>
)
}
>
{selectedCustomer && (
<div>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="Kundenummer">
<Text code>{selectedCustomer.customerNumber}</Text>
</Descriptions.Item>
<Descriptions.Item label="Kundetype">
<Tag icon={selectedCustomer.customerType === 'Business' ? <ShopOutlined /> : <UserOutlined />}>
{selectedCustomer.customerType === 'Business' ? 'Erhverv' : 'Privat'}
</Tag>
</Descriptions.Item>
{selectedCustomer.cvr && (
<Descriptions.Item label="CVR">{selectedCustomer.cvr}</Descriptions.Item>
)}
<Descriptions.Item label="Status">
<StatusBadge status={selectedCustomer.isActive ? 'active' : 'inactive'} />
</Descriptions.Item>
<Descriptions.Item label="Adresse">
{selectedCustomer.address || '-'}
{selectedCustomer.postalCode && `, ${selectedCustomer.postalCode}`}
{selectedCustomer.city && ` ${selectedCustomer.city}`}
</Descriptions.Item>
<Descriptions.Item label="Email">{selectedCustomer.email || '-'}</Descriptions.Item>
<Descriptions.Item label="Telefon">{selectedCustomer.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="Betalingsbetingelser">
{selectedCustomer.paymentTermsDays} dage
</Descriptions.Item>
<Descriptions.Item label="Oprettet">
{formatDate(selectedCustomer.createdAt)}
</Descriptions.Item>
</Descriptions>
<div style={{ marginTop: spacing.xl }}>
<Title level={5}>Handlinger</Title>
<Space direction="vertical" style={{ width: '100%' }}>
<ShortcutTooltip shortcutId="newInvoice">
<Button
type="primary"
icon={<FileTextOutlined />}
block
disabled={!selectedCustomer.isActive}
onClick={handleCreateInvoice}
>
Opret fakturakladde
</Button>
</ShortcutTooltip>
<Button block onClick={() => {
setIsDrawerOpen(false);
navigate(`/fakturaer?customer=${selectedCustomer.id}`);
}}>
Se fakturaer
</Button>
<Button block onClick={() => {
setIsDrawerOpen(false);
navigate(`/kreditnotaer?customer=${selectedCustomer.id}`);
}}>
Se kreditnotaer
</Button>
</Space>
</div>
</div>
)}
</Drawer>
</div>
);
}

View file

@ -0,0 +1,996 @@
import { useState, useMemo } from 'react';
import {
Typography,
Button,
Card,
Table,
Space,
Tag,
Modal,
Form,
Input,
Select,
Spin,
Alert,
Drawer,
Descriptions,
Row,
Col,
Statistic,
DatePicker,
Divider,
List,
Checkbox,
Radio,
Tooltip,
} from 'antd';
import { showSuccess, showError, showWarning } from '@/lib/errorHandling';
import {
PlusOutlined,
SearchOutlined,
EyeOutlined,
CheckOutlined,
StopOutlined,
FileTextOutlined,
ShoppingCartOutlined,
FileDoneOutlined,
BarcodeOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { useCompany } from '@/hooks/useCompany';
import { useCurrentFiscalYear } from '@/stores/periodStore';
import { useOrders } from '@/api/queries/orderQueries';
import { useActiveCustomers, type Customer } from '@/api/queries/customerQueries';
import { useActiveProducts, type Product } from '@/api/queries/productQueries';
import {
useCreateOrder,
useAddOrderLine,
useConfirmOrder,
useCancelOrder,
useConvertOrderToInvoice,
type CreateOrderInput,
type AddOrderLineInput,
type CancelOrderInput,
type InvoiceOrderLinesInput,
} from '@/api/mutations/orderMutations';
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';
import type { Order, OrderLine, OrderStatus } from '@/types/order';
import { ORDER_STATUS_LABELS, ORDER_STATUS_COLORS } from '@/types/order';
const { Title, Text } = Typography;
export default function Ordrer() {
const { company } = useCompany();
const currentFiscalYear = useCurrentFiscalYear();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isAddLineModalOpen, setIsAddLineModalOpen] = useState(false);
const [isCancelModalOpen, setIsCancelModalOpen] = useState(false);
const [isConvertModalOpen, setIsConvertModalOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
const [selectedLinesToInvoice, setSelectedLinesToInvoice] = useState<number[]>([]);
const [searchText, setSearchText] = useState('');
const [statusFilter, setStatusFilter] = useState<OrderStatus | 'all'>('all');
const [addLineMode, setAddLineMode] = useState<'product' | 'freetext'>('product');
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
const [createForm] = Form.useForm();
const [addLineForm] = Form.useForm();
const [cancelForm] = Form.useForm();
// Fetch orders
const {
data: orders = [],
isLoading: loading,
error,
refetch,
} = useOrders(company?.id);
// Fetch customers for dropdown
const { data: customers = [] } = useActiveCustomers(company?.id);
// Fetch products for dropdown
const { data: products = [] } = useActiveProducts(company?.id);
// Mutations
const createOrderMutation = useCreateOrder();
const addOrderLineMutation = useAddOrderLine();
const confirmOrderMutation = useConfirmOrder();
const cancelOrderMutation = useCancelOrder();
const convertToInvoiceMutation = useConvertOrderToInvoice();
// Filter orders
const filteredOrders = useMemo(() => {
return orders.filter((order) => {
const matchesSearch =
searchText === '' ||
order.orderNumber.toLowerCase().includes(searchText.toLowerCase()) ||
order.customerName.toLowerCase().includes(searchText.toLowerCase());
const matchesStatus = statusFilter === 'all' || order.status === statusFilter;
return matchesSearch && matchesStatus;
});
}, [orders, searchText, statusFilter]);
// Statistics
const stats = useMemo(() => {
const total = orders.length;
const drafts = orders.filter((o) => o.status === 'draft').length;
const confirmed = orders.filter((o) => o.status === 'confirmed').length;
const totalValue = orders
.filter((o) => o.status !== 'cancelled')
.reduce((sum, o) => sum + o.amountTotal, 0);
const invoicedValue = orders.reduce((sum, o) => sum + (o.amountTotal - (o.uninvoicedAmount ?? 0)), 0);
return { total, drafts, confirmed, totalValue, invoicedValue };
}, [orders]);
const handleCreateOrder = () => {
createForm.resetFields();
createForm.setFieldsValue({
orderDate: dayjs(),
});
setIsCreateModalOpen(true);
};
const handleSubmitCreate = async () => {
if (!company || !currentFiscalYear) {
showError('Virksomhed eller regnskabsaar ikke valgt');
return;
}
try {
const values = await createForm.validateFields();
const input: CreateOrderInput = {
companyId: company.id,
fiscalYearId: currentFiscalYear.id,
customerId: values.customerId,
orderDate: values.orderDate?.toISOString(),
expectedDeliveryDate: values.expectedDeliveryDate?.toISOString(),
notes: values.notes || undefined,
reference: values.reference || undefined,
};
const result = await createOrderMutation.mutateAsync(input);
showSuccess('Ordre oprettet');
setIsCreateModalOpen(false);
createForm.resetFields();
setSelectedOrder(result);
setIsDrawerOpen(true);
} catch (err) {
if (err instanceof Error) {
showError(err);
}
}
};
const handleViewOrder = (order: Order) => {
setSelectedOrder(order);
setIsDrawerOpen(true);
};
const handleOpenAddLineModal = () => {
addLineForm.resetFields();
addLineForm.setFieldsValue({
quantity: 1,
vatCode: 'S25',
});
setAddLineMode('product');
setSelectedProductId(null);
setIsAddLineModalOpen(true);
};
const handleProductSelect = (productId: string) => {
setSelectedProductId(productId);
const product = products.find((p: Product) => p.id === productId);
if (product) {
addLineForm.setFieldsValue({
description: product.name,
unitPrice: product.unitPrice,
unit: product.unit || 'stk',
vatCode: product.vatCode || 'S25',
});
}
};
const handleSubmitAddLine = async () => {
if (!selectedOrder) return;
try {
const values = await addLineForm.validateFields();
const input: AddOrderLineInput = {
orderId: selectedOrder.id,
productId: addLineMode === 'product' && selectedProductId ? selectedProductId : undefined,
description: values.description,
quantity: Number(values.quantity),
unitPrice: Number(values.unitPrice),
unit: values.unit || undefined,
discountPercent: values.discountPercent ? Number(values.discountPercent) : undefined,
vatCode: values.vatCode,
};
const updatedOrder = await addOrderLineMutation.mutateAsync(input);
showSuccess('Linje tilføjet');
setIsAddLineModalOpen(false);
addLineForm.resetFields();
setSelectedProductId(null);
setSelectedOrder(updatedOrder);
} catch (err) {
if (err instanceof Error) {
showError(err);
}
}
};
const handleConfirmOrder = async () => {
if (!selectedOrder) return;
if (selectedOrder.lines.length === 0) {
showWarning('Tilfoej mindst en linje foer bekraeftelse');
return;
}
try {
await confirmOrderMutation.mutateAsync(selectedOrder.id);
showSuccess('Ordre bekraeftet');
// Refresh would happen via query invalidation
} catch (err) {
if (err instanceof Error) {
showError(err);
}
}
};
const handleOpenCancelModal = () => {
cancelForm.resetFields();
setIsCancelModalOpen(true);
};
const handleSubmitCancel = async () => {
if (!selectedOrder) return;
try {
const values = await cancelForm.validateFields();
const input: CancelOrderInput = {
orderId: selectedOrder.id,
reason: values.reason,
};
await cancelOrderMutation.mutateAsync(input);
showSuccess('Ordre annulleret');
setIsCancelModalOpen(false);
cancelForm.resetFields();
} catch (err) {
if (err instanceof Error) {
showError(err);
}
}
};
const handleOpenConvertModal = () => {
if (!selectedOrder) return;
// Pre-select uninvoiced lines
const uninvoicedLines = selectedOrder.lines
.filter((line) => !line.isInvoiced)
.map((line) => line.lineNumber);
setSelectedLinesToInvoice(uninvoicedLines);
setIsConvertModalOpen(true);
};
const handleSubmitConvert = async () => {
if (!selectedOrder || selectedLinesToInvoice.length === 0) {
showWarning('Vaelg mindst en linje at fakturere');
return;
}
try {
const input: InvoiceOrderLinesInput = {
orderId: selectedOrder.id,
lineNumbers: selectedLinesToInvoice,
};
const invoice = await convertToInvoiceMutation.mutateAsync(input);
showSuccess(`Faktura ${invoice.invoiceNumber} oprettet fra ordre`);
setIsConvertModalOpen(false);
setSelectedLinesToInvoice([]);
// Refresh the selected order to show updated invoice tracking
refetch();
} catch (err) {
if (err instanceof Error) {
showError(err);
}
}
};
// Check if order can show the convert to invoice button
const canShowConvertToInvoice = (order: Order): boolean => {
if (order.status === 'cancelled' || order.status === 'fully_invoiced') {
return false;
}
// Show for draft (disabled) and confirmed/partially_invoiced (enabled)
return order.status === 'draft' || order.lines.some((line) => !line.isInvoiced);
};
// Check if convert to invoice button should be disabled
const isConvertToInvoiceDisabled = (order: Order): boolean => {
if (order.status === 'draft') {
return true; // Must confirm order first
}
return !order.lines.some((line) => !line.isInvoiced);
};
const columns: ColumnsType<Order> = [
{
title: 'Ordrenr.',
dataIndex: 'orderNumber',
key: 'orderNumber',
width: 140,
sorter: (a, b) => a.orderNumber.localeCompare(b.orderNumber),
render: (value: string) => <Text code>{value}</Text>,
},
{
title: 'Kunde',
dataIndex: 'customerName',
key: 'customerName',
sorter: (a, b) => a.customerName.localeCompare(b.customerName),
ellipsis: true,
},
{
title: 'Dato',
dataIndex: 'orderDate',
key: 'orderDate',
width: 100,
sorter: (a, b) => (a.orderDate || '').localeCompare(b.orderDate || ''),
render: (value: string | undefined) => (value ? formatDate(value) : '-'),
},
{
title: 'Forventet levering',
dataIndex: 'expectedDeliveryDate',
key: 'expectedDeliveryDate',
width: 130,
render: (value: string | undefined) => (value ? formatDate(value) : '-'),
},
{
title: 'Beloeb',
dataIndex: 'amountTotal',
key: 'amountTotal',
width: 120,
align: 'right',
sorter: (a, b) => a.amountTotal - b.amountTotal,
render: (value: number) => <AmountText amount={value} />,
},
{
title: 'Faktureret',
dataIndex: 'amountInvoiced',
key: 'amountInvoiced',
width: 120,
align: 'right',
render: (value: number, record: Order) =>
record.status === 'cancelled' ? '-' : <AmountText amount={value} />,
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 140,
align: 'center',
filters: [
{ text: 'Kladde', value: 'draft' },
{ text: 'Bekraeftet', value: 'confirmed' },
{ text: 'Delvist faktureret', value: 'partially_invoiced' },
{ text: 'Fuldt faktureret', value: 'fully_invoiced' },
{ text: 'Annulleret', value: 'cancelled' },
],
onFilter: (value, record) => record.status === value,
render: (value: OrderStatus) => (
<Tag color={ORDER_STATUS_COLORS[value]}>{ORDER_STATUS_LABELS[value]}</Tag>
),
},
{
title: '',
key: 'actions',
width: 80,
align: 'center',
render: (_: unknown, record: Order) => (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handleViewOrder(record)}
/>
),
},
];
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.lg,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Ordrer
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateOrder}>
Ny ordre
</Button>
</div>
{/* Error State */}
{error && (
<Alert
message="Fejl ved indlaesning af ordrer"
description={error.message}
type="error"
showIcon
style={{ marginBottom: spacing.lg }}
action={
<Button size="small" onClick={() => refetch()}>
Proev igen
</Button>
}
/>
)}
{/* Statistics */}
<Row gutter={[spacing.lg, spacing.lg]} style={{ marginBottom: spacing.lg }}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="Ordrer i alt" value={stats.total} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Kladder"
value={stats.drafts}
valueStyle={{ color: '#8c8c8c' }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Bekraeftede"
value={stats.confirmed}
valueStyle={{ color: accountingColors.credit }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Samlet vaerdi"
value={stats.totalValue}
precision={2}
valueStyle={{ color: accountingColors.credit }}
formatter={(value) => formatCurrency(value as number)}
/>
</Card>
</Col>
</Row>
{/* Filters */}
<Card size="small" style={{ marginBottom: spacing.lg }}>
<Space wrap>
<Input
placeholder="Soeg ordre..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 250 }}
allowClear
/>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{ width: 180 }}
options={[
{ value: 'all', label: 'Alle status' },
{ value: 'draft', label: 'Kladde' },
{ value: 'confirmed', label: 'Bekraeftet' },
{ value: 'partially_invoiced', label: 'Delvist faktureret' },
{ value: 'fully_invoiced', label: 'Fuldt faktureret' },
{ value: 'cancelled', label: 'Annulleret' },
]}
/>
</Space>
</Card>
{/* Order Table */}
<Card size="small">
{loading ? (
<Spin tip="Indlaeser ordrer..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
<div style={{ minHeight: 200 }} />
</Spin>
) : filteredOrders.length > 0 ? (
<Table
dataSource={filteredOrders}
columns={columns}
rowKey="id"
size="small"
pagination={{ pageSize: 20, showSizeChanger: true }}
/>
) : (
<EmptyState
variant="default"
title="Ingen ordrer"
description={searchText ? 'Ingen ordrer matcher din soegning' : 'Opret din foerste ordre'}
primaryAction={
!searchText
? {
label: 'Opret ordre',
onClick: handleCreateOrder,
icon: <PlusOutlined />,
}
: undefined
}
/>
)}
</Card>
{/* Create Order Modal */}
<Modal
title="Opret ordre"
open={isCreateModalOpen}
onCancel={() => setIsCreateModalOpen(false)}
onOk={handleSubmitCreate}
okText="Opret"
cancelText="Annuller"
confirmLoading={createOrderMutation.isPending}
>
<Form form={createForm} layout="vertical">
<Form.Item
name="customerId"
label="Kunde"
rules={[{ required: true, message: 'Vaelg kunde' }]}
>
<Select
showSearch
placeholder="Vaelg kunde"
optionFilterProp="children"
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={customers.map((c: Customer) => ({
value: c.id,
label: `${c.customerNumber} - ${c.name}`,
}))}
/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="orderDate" label="Ordredato">
<DatePicker style={{ width: '100%' }} format="DD-MM-YYYY" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="expectedDeliveryDate" label="Forventet levering">
<DatePicker style={{ width: '100%' }} format="DD-MM-YYYY" />
</Form.Item>
</Col>
</Row>
<Form.Item name="reference" label="Reference">
<Input placeholder="Projektnavn, tilbudsnr., etc." />
</Form.Item>
<Form.Item name="notes" label="Bemaerkninger">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
{/* Order Detail Drawer */}
<Drawer
title={
selectedOrder && (
<Space>
<ShoppingCartOutlined />
<span>Ordre {selectedOrder.orderNumber}</span>
<Tag color={ORDER_STATUS_COLORS[selectedOrder.status]}>
{ORDER_STATUS_LABELS[selectedOrder.status]}
</Tag>
</Space>
)
}
placement="right"
width={700}
open={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setSelectedOrder(null);
}}
extra={
selectedOrder && (
<Space>
{selectedOrder.status === 'draft' && (
<>
<Button
icon={<PlusOutlined />}
onClick={handleOpenAddLineModal}
loading={addOrderLineMutation.isPending}
>
Tilfoej linje
</Button>
<Button
type="primary"
icon={<CheckOutlined />}
onClick={handleConfirmOrder}
loading={confirmOrderMutation.isPending}
disabled={selectedOrder.lines.length === 0}
>
Bekraeft
</Button>
</>
)}
{canShowConvertToInvoice(selectedOrder) && (
<Tooltip
title={selectedOrder.status === 'draft' ? 'Bekraeft ordren foerst' : undefined}
>
<Button
type="primary"
icon={<FileDoneOutlined />}
onClick={handleOpenConvertModal}
disabled={isConvertToInvoiceDisabled(selectedOrder)}
>
Opret faktura
</Button>
</Tooltip>
)}
{selectedOrder.status !== 'cancelled' && selectedOrder.status !== 'fully_invoiced' && (
<Button danger icon={<StopOutlined />} onClick={handleOpenCancelModal}>
Annuller
</Button>
)}
</Space>
)
}
>
{selectedOrder && (
<div>
<Descriptions column={2} size="small" bordered style={{ marginBottom: spacing.lg }}>
<Descriptions.Item label="Kunde" span={2}>
{selectedOrder.customerName}
</Descriptions.Item>
<Descriptions.Item label="Ordredato">
{selectedOrder.orderDate ? formatDate(selectedOrder.orderDate) : '-'}
</Descriptions.Item>
<Descriptions.Item label="Forventet levering">
{selectedOrder.expectedDeliveryDate ? formatDate(selectedOrder.expectedDeliveryDate) : '-'}
</Descriptions.Item>
{selectedOrder.reference && (
<Descriptions.Item label="Reference" span={2}>
{selectedOrder.reference}
</Descriptions.Item>
)}
</Descriptions>
<Title level={5}>Linjer</Title>
{selectedOrder.lines.length > 0 ? (
<List
size="small"
bordered
dataSource={selectedOrder.lines}
renderItem={(line: OrderLine) => {
const linkedProduct = line.productId
? products.find((p: Product) => p.id === line.productId)
: null;
return (
<List.Item>
<List.Item.Meta
title={
<Space>
<span>{line.description}</span>
{line.productId && (
<Tag color="purple" icon={<BarcodeOutlined />}>
{linkedProduct?.productNumber || 'Produkt'}
</Tag>
)}
{line.isInvoiced && (
<Tag color="green" icon={<FileTextOutlined />}>
Faktureret
</Tag>
)}
</Space>
}
description={
<Space>
<span>
{line.quantity} {line.unit || 'stk'} x {formatCurrency(line.unitPrice)}
</span>
{line.discountPercent > 0 && (
<Tag color="orange">-{line.discountPercent}%</Tag>
)}
<Tag>{line.vatCode}</Tag>
{line.isInvoiced && line.invoicedAt && (
<Tag color="blue">
Faktureret: {dayjs(line.invoicedAt).format('DD/MM/YYYY')}
</Tag>
)}
</Space>
}
/>
<AmountText amount={line.amountTotal} style={{ fontWeight: 'bold' }} />
</List.Item>
);
}}
/>
) : (
<Alert
message="Ingen linjer endnu"
description="Tilfoej linjer for at kunne bekraefte ordren."
type="info"
showIcon
/>
)}
<Divider />
<Row gutter={16}>
<Col span={12}>
{selectedOrder.notes && (
<>
<Text type="secondary">Bemaerkninger:</Text>
<p>{selectedOrder.notes}</p>
</>
)}
{selectedOrder.cancelledReason && (
<>
<Text type="secondary">Annulleringsaarsag:</Text>
<p style={{ color: 'red' }}>{selectedOrder.cancelledReason}</p>
</>
)}
</Col>
<Col span={12}>
<div style={{ textAlign: 'right' }}>
<div style={{ marginBottom: 4 }}>
<Text type="secondary">Beloeb ex. moms: </Text>
<Text>{formatCurrency(selectedOrder.amountExVat)}</Text>
</div>
<div style={{ marginBottom: 4 }}>
<Text type="secondary">Moms: </Text>
<Text>{formatCurrency(selectedOrder.amountVat)}</Text>
</div>
<div style={{ marginBottom: 4 }}>
<Text strong>Total: </Text>
<Text strong style={{ fontSize: 16 }}>
{formatCurrency(selectedOrder.amountTotal)}
</Text>
</div>
{(selectedOrder.uninvoicedAmount ?? selectedOrder.amountTotal) < selectedOrder.amountTotal && (
<div style={{ marginBottom: 4 }}>
<Text type="secondary">Faktureret: </Text>
<Text type="success">{formatCurrency(selectedOrder.amountTotal - (selectedOrder.uninvoicedAmount ?? 0))}</Text>
</div>
)}
{(selectedOrder.uninvoicedAmount ?? 0) > 0 &&
selectedOrder.status !== 'cancelled' && (
<div>
<Text type="secondary">Resterende: </Text>
<Text type="warning" strong>
{formatCurrency(selectedOrder.uninvoicedAmount ?? 0)}
</Text>
</div>
)}
</div>
</Col>
</Row>
</div>
)}
</Drawer>
{/* Cancel Order Modal */}
<Modal
title="Annuller ordre"
open={isCancelModalOpen}
onCancel={() => setIsCancelModalOpen(false)}
onOk={handleSubmitCancel}
okText="Annuller ordre"
okButtonProps={{ danger: true }}
cancelText="Fortryd"
confirmLoading={cancelOrderMutation.isPending}
>
<Alert
message="Advarsel"
description="At annullere ordren kan ikke fortrydes. Eventuelle delfaktureringer forbliver uaendrede."
type="warning"
showIcon
style={{ marginBottom: spacing.lg }}
/>
<Form form={cancelForm} layout="vertical">
<Form.Item
name="reason"
label="Aarsag til annullering"
rules={[{ required: true, message: 'Angiv aarsag' }]}
>
<Input.TextArea rows={3} placeholder="Beskriv hvorfor ordren annulleres" />
</Form.Item>
</Form>
</Modal>
{/* Add Line Modal */}
<Modal
title="Tilfoej linje"
open={isAddLineModalOpen}
onCancel={() => {
setIsAddLineModalOpen(false);
setSelectedProductId(null);
}}
onOk={handleSubmitAddLine}
okText="Tilfoej"
cancelText="Annuller"
confirmLoading={addOrderLineMutation.isPending}
width={550}
>
<Form form={addLineForm} layout="vertical">
<Form.Item label="Linjetype" style={{ marginBottom: spacing.md }}>
<Radio.Group
value={addLineMode}
onChange={(e) => {
setAddLineMode(e.target.value);
setSelectedProductId(null);
addLineForm.resetFields();
addLineForm.setFieldsValue({
quantity: 1,
vatCode: 'S25',
});
}}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="product">Vaelg produkt</Radio.Button>
<Radio.Button value="freetext">Fritekst</Radio.Button>
</Radio.Group>
</Form.Item>
{addLineMode === 'product' && (
<Form.Item
label="Produkt"
required
validateStatus={addLineMode === 'product' && !selectedProductId ? 'error' : undefined}
help={addLineMode === 'product' && !selectedProductId ? 'Vaelg et produkt' : undefined}
>
<Select
showSearch
placeholder="Soeg efter produkt..."
optionFilterProp="children"
value={selectedProductId}
onChange={handleProductSelect}
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={products.map((p: Product) => ({
value: p.id,
label: `${p.productNumber || ''} ${p.name}`.trim(),
}))}
/>
</Form.Item>
)}
<Form.Item
name="description"
label="Beskrivelse"
rules={[{ required: true, message: 'Angiv beskrivelse' }]}
>
<Input
placeholder="Vare eller ydelse"
disabled={addLineMode === 'product' && !!selectedProductId}
/>
</Form.Item>
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="quantity"
label="Antal"
rules={[{ required: true, message: 'Angiv antal' }]}
>
<Input type="number" min={0} step={1} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="unit" label="Enhed">
<Input
placeholder="stk"
disabled={addLineMode === 'product' && !!selectedProductId}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="unitPrice"
label="Enhedspris"
rules={[{ required: true, message: 'Angiv pris' }]}
>
<Input
type="number"
min={0}
step={0.01}
disabled={addLineMode === 'product' && !!selectedProductId}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="discountPercent" label="Rabat (%)">
<Input type="number" min={0} max={100} step={1} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="vatCode"
label="Momskode"
rules={[{ required: true, message: 'Vaelg momskode' }]}
>
<Select
disabled={addLineMode === 'product' && !!selectedProductId}
options={[
{ value: 'S25', label: 'S25 - Salgsmoms 25%' },
{ value: 'S0', label: 'S0 - Momsfrit salg' },
]}
/>
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
{/* Convert to Invoice Modal */}
<Modal
title="Opret faktura fra ordre"
open={isConvertModalOpen}
onCancel={() => {
setIsConvertModalOpen(false);
setSelectedLinesToInvoice([]);
}}
onOk={handleSubmitConvert}
okText="Opret faktura"
cancelText="Annuller"
confirmLoading={convertToInvoiceMutation.isPending}
width={600}
>
<Alert
message="Vaelg linjer til fakturering"
description="Vaelg hvilke ordrelinjer der skal inkluderes i fakturaen. Du kan fakturere delvist og oprette flere fakturaer senere."
type="info"
showIcon
style={{ marginBottom: spacing.lg }}
/>
{selectedOrder && (
<List
size="small"
bordered
dataSource={selectedOrder.lines.filter((line) => !line.isInvoiced)}
renderItem={(line: OrderLine) => (
<List.Item>
<Checkbox
checked={selectedLinesToInvoice.includes(line.lineNumber)}
onChange={(e) => {
if (e.target.checked) {
setSelectedLinesToInvoice([...selectedLinesToInvoice, line.lineNumber]);
} else {
setSelectedLinesToInvoice(
selectedLinesToInvoice.filter((n) => n !== line.lineNumber)
);
}
}}
>
<Space direction="vertical" size={0}>
<Text>{line.description}</Text>
<Text type="secondary">
{line.quantity} {line.unit || 'stk'} x{' '}
{formatCurrency(line.unitPrice)} ={' '}
{formatCurrency(line.amountTotal)}
</Text>
</Space>
</Checkbox>
</List.Item>
)}
/>
)}
</Modal>
</div>
);
}

View file

@ -0,0 +1,575 @@
import { useState, useMemo } from 'react';
import {
Typography,
Button,
Card,
Table,
Space,
Tag,
Modal,
Form,
Input,
InputNumber,
Select,
AutoComplete,
Spin,
Alert,
Drawer,
Descriptions,
Popconfirm,
Row,
Col,
Statistic,
} from 'antd';
import { showSuccess, showError } from '@/lib/errorHandling';
import {
PlusOutlined,
EditOutlined,
SearchOutlined,
EyeOutlined,
StopOutlined,
CheckOutlined,
ShoppingOutlined,
} from '@ant-design/icons';
import { useCompany } from '@/hooks/useCompany';
import { useProducts, useManufacturers, type Product } from '@/api/queries/productQueries';
import {
useCreateProduct,
useUpdateProduct,
useDeactivateProduct,
useReactivateProduct,
type CreateProductInput,
type UpdateProductInput,
} from '@/api/mutations/productMutations';
import { formatDate, formatCurrency } 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;
// VAT code options
const vatCodeOptions = [
{ value: 'U25', label: 'Udgående moms 25% (U25)' },
{ value: 'UEU', label: 'EU-salg uden moms (UEU)' },
{ value: 'UEXP', label: 'Eksport uden moms (UEXP)' },
{ value: 'INGEN', label: 'Ingen moms (INGEN)' },
];
// Common unit options
const unitOptions = [
{ value: 'stk', label: 'stk' },
{ value: 'time', label: 'time' },
{ value: 'dag', label: 'dag' },
{ value: 'måned', label: 'måned' },
{ value: 'kg', label: 'kg' },
{ value: 'm', label: 'm' },
{ value: 'm2', label: 'm2' },
{ value: 'm3', label: 'm3' },
{ value: 'l', label: 'l' },
{ value: 'pakke', label: 'pakke' },
];
export default function Produkter() {
const { company } = useCompany();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [searchText, setSearchText] = useState('');
const [showInactive, setShowInactive] = useState(false);
const [manufacturerSearch, setManufacturerSearch] = useState('');
const [form] = Form.useForm();
// Fetch products using the new hook
const {
data: products = [],
isLoading: loading,
error,
} = useProducts(company?.id);
// Fetch manufacturers for autocomplete
const { data: manufacturers = [] } = useManufacturers(company?.id, manufacturerSearch);
// Mutations using new hooks
const createProductMutation = useCreateProduct();
const updateProductMutation = useUpdateProduct();
const deactivateProductMutation = useDeactivateProduct();
const reactivateProductMutation = useReactivateProduct();
// Filter products
const filteredProducts = useMemo(() => {
return products.filter((product) => {
const searchLower = searchText.toLowerCase();
const matchesSearch =
searchText === '' ||
product.name.toLowerCase().includes(searchLower) ||
(product.productNumber && product.productNumber.includes(searchText)) ||
(product.description && product.description.toLowerCase().includes(searchLower)) ||
(product.ean && product.ean.includes(searchText)) ||
(product.manufacturer && product.manufacturer.toLowerCase().includes(searchLower));
const matchesStatus = showInactive || product.isActive;
return matchesSearch && matchesStatus;
});
}, [products, searchText, showInactive]);
// Statistics
const stats = useMemo(() => {
const active = products.filter((p) => p.isActive).length;
const totalValue = products
.filter((p) => p.isActive)
.reduce((sum, p) => sum + p.unitPrice, 0);
return { total: products.length, active, totalValue };
}, [products]);
const handleCreate = () => {
setEditingProduct(null);
form.resetFields();
form.setFieldsValue({ vatCode: 'U25', unitPrice: 0, unit: 'stk' });
setIsModalOpen(true);
};
const handleEdit = (product: Product) => {
setEditingProduct(product);
form.setFieldsValue({
productNumber: product.productNumber,
name: product.name,
description: product.description,
unitPrice: product.unitPrice,
vatCode: product.vatCode,
unit: product.unit,
ean: product.ean,
manufacturer: product.manufacturer,
});
setIsModalOpen(true);
};
const handleView = (product: Product) => {
setSelectedProduct(product);
setIsDrawerOpen(true);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
if (editingProduct) {
const input: UpdateProductInput = {
id: editingProduct.id,
productNumber: values.productNumber || undefined,
name: values.name,
description: values.description || undefined,
unitPrice: values.unitPrice,
vatCode: values.vatCode,
unit: values.unit || undefined,
ean: values.ean || undefined,
manufacturer: values.manufacturer || undefined,
};
await updateProductMutation.mutateAsync(input);
showSuccess('Produkt opdateret');
} else {
const input: CreateProductInput = {
companyId: company!.id,
productNumber: values.productNumber || undefined,
name: values.name,
description: values.description || undefined,
unitPrice: values.unitPrice,
vatCode: values.vatCode,
unit: values.unit || undefined,
ean: values.ean || undefined,
manufacturer: values.manufacturer || undefined,
};
await createProductMutation.mutateAsync(input);
showSuccess('Produkt oprettet');
}
setIsModalOpen(false);
setEditingProduct(null);
form.resetFields();
} catch (err) {
if (err instanceof Error) {
showError(err);
}
}
};
const handleToggleActive = async (product: Product) => {
try {
if (product.isActive) {
await deactivateProductMutation.mutateAsync(product.id);
showSuccess('Produkt deaktiveret');
} else {
await reactivateProductMutation.mutateAsync(product.id);
showSuccess('Produkt genaktiveret');
}
} catch (err) {
if (err instanceof Error) {
showError(err);
}
}
};
const columns: ColumnsType<Product> = [
{
title: 'Produktnr.',
dataIndex: 'productNumber',
key: 'productNumber',
width: 120,
sorter: (a, b) => (a.productNumber || '').localeCompare(b.productNumber || ''),
render: (value: string) => value ? <Text code>{value}</Text> : <Text type="secondary">-</Text>,
},
{
title: 'Navn',
dataIndex: 'name',
key: 'name',
sorter: (a, b) => a.name.localeCompare(b.name),
},
{
title: 'Beskrivelse',
dataIndex: 'description',
key: 'description',
ellipsis: true,
render: (value: string) => value || <Text type="secondary">-</Text>,
},
{
title: 'EAN',
dataIndex: 'ean',
key: 'ean',
width: 140,
render: (value: string) => value ? <Text code>{value}</Text> : <Text type="secondary">-</Text>,
},
{
title: 'Producent',
dataIndex: 'manufacturer',
key: 'manufacturer',
width: 150,
sorter: (a, b) => (a.manufacturer || '').localeCompare(b.manufacturer || ''),
render: (value: string) => value || <Text type="secondary">-</Text>,
},
{
title: 'Pris',
dataIndex: 'unitPrice',
key: 'unitPrice',
width: 120,
align: 'right',
sorter: (a, b) => a.unitPrice - b.unitPrice,
render: (value: number) => formatCurrency(value),
},
{
title: 'Enhed',
dataIndex: 'unit',
key: 'unit',
width: 80,
render: (value: string) => value || <Text type="secondary">-</Text>,
},
{
title: 'Moms',
dataIndex: 'vatCode',
key: 'vatCode',
width: 80,
render: (value: string) => <Tag>{value}</Tag>,
},
{
title: 'Status',
dataIndex: 'isActive',
key: 'isActive',
width: 100,
render: (isActive: boolean) => (
<StatusBadge
status={isActive ? 'success' : 'error'}
text={isActive ? 'Aktiv' : 'Inaktiv'}
/>
),
},
{
title: '',
key: 'actions',
width: 120,
render: (_: unknown, record: Product) => (
<Space size="small">
<Button type="text" icon={<EyeOutlined />} onClick={() => handleView(record)} />
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
<Popconfirm
title={record.isActive ? 'Deaktiver produkt?' : 'Genaktiver produkt?'}
onConfirm={() => handleToggleActive(record)}
okText="Ja"
cancelText="Nej"
>
<Button
type="text"
danger={record.isActive}
icon={record.isActive ? <StopOutlined /> : <CheckOutlined />}
/>
</Popconfirm>
</Space>
),
},
];
if (loading) {
return (
<div style={{ textAlign: 'center', padding: spacing.xl }}>
<Spin size="large" />
</div>
);
}
if (error) {
return (
<Alert
message="Fejl ved indlæsning af produkter"
description={error.message}
type="error"
showIcon
/>
);
}
return (
<div>
<div style={{ marginBottom: spacing.lg, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={4} style={{ margin: 0 }}>
Produkter
</Title>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
Opret produkt
</Button>
</div>
{/* Statistics */}
<Row gutter={spacing.md} style={{ marginBottom: spacing.lg }}>
<Col xs={24} sm={8}>
<Card>
<Statistic title="Totalt antal" value={stats.total} prefix={<ShoppingOutlined />} />
</Card>
</Col>
<Col xs={24} sm={8}>
<Card>
<Statistic title="Aktive produkter" value={stats.active} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col xs={24} sm={8}>
<Card>
<Statistic
title="Gns. pris (aktive)"
value={stats.active > 0 ? stats.totalValue / stats.active : 0}
precision={2}
suffix="DKK"
/>
</Card>
</Col>
</Row>
{/* Search and filters */}
<Card style={{ marginBottom: spacing.md }}>
<Space wrap>
<Input
placeholder="Søg efter navn, produktnummer..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ width: 300 }}
/>
<Button
type={showInactive ? 'primary' : 'default'}
onClick={() => setShowInactive(!showInactive)}
>
{showInactive ? 'Skjul inaktive' : 'Vis inaktive'}
</Button>
</Space>
</Card>
{/* Products table */}
{filteredProducts.length === 0 && searchText === '' && !showInactive ? (
<EmptyState
icon={<ShoppingOutlined style={{ fontSize: 48 }} />}
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: <PlusOutlined />,
}}
/>
) : (
<Card>
<Table
columns={columns}
dataSource={filteredProducts}
rowKey="id"
pagination={{
pageSize: 20,
showSizeChanger: true,
showTotal: (total) => `${total} produkter`,
}}
/>
</Card>
)}
{/* Create/Edit Modal */}
<Modal
title={editingProduct ? 'Rediger produkt' : 'Opret produkt'}
open={isModalOpen}
onCancel={() => {
setIsModalOpen(false);
setEditingProduct(null);
form.resetFields();
}}
onOk={handleSubmit}
okText={editingProduct ? 'Gem' : 'Opret'}
cancelText="Annuller"
confirmLoading={createProductMutation.isPending || updateProductMutation.isPending}
width={600}
>
<Form form={form} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item name="productNumber" label="Produktnummer">
<Input placeholder="F.eks. P001 (valgfrit)" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="name"
label="Navn"
rules={[{ required: true, message: 'Indtast produktnavn' }]}
>
<Input placeholder="Produktnavn" />
</Form.Item>
</Col>
</Row>
<Form.Item name="description" label="Beskrivelse">
<Input.TextArea rows={2} placeholder="Beskrivelse (kopieres til fakturalinje)" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="ean" label="EAN/Stregkode">
<Input placeholder="EAN-13 eller anden stregkode" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="manufacturer" label="Producent">
<AutoComplete
allowClear
placeholder="Indtast eller vælg producent"
onSearch={setManufacturerSearch}
options={manufacturers.map(m => ({ value: m, label: m }))}
filterOption={(inputValue, option) =>
option?.value.toLowerCase().includes(inputValue.toLowerCase()) ?? false
}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="unitPrice"
label="Enhedspris (ekskl. moms)"
rules={[{ required: true, message: 'Indtast pris' }]}
>
<InputNumber
style={{ width: '100%' }}
min={0}
step={0.01}
precision={2}
addonAfter="DKK"
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="vatCode"
label="Momskode"
rules={[{ required: true, message: 'Vælg momskode' }]}
>
<Select options={vatCodeOptions} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="unit" label="Enhed">
<Select options={unitOptions} allowClear placeholder="Vælg enhed" />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
{/* View Drawer */}
<Drawer
title="Produktdetaljer"
open={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setSelectedProduct(null);
}}
width={500}
>
{selectedProduct && (
<div>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="Produktnummer">
{selectedProduct.productNumber || '-'}
</Descriptions.Item>
<Descriptions.Item label="Navn">{selectedProduct.name}</Descriptions.Item>
<Descriptions.Item label="Beskrivelse">
{selectedProduct.description || '-'}
</Descriptions.Item>
<Descriptions.Item label="Enhedspris">
{formatCurrency(selectedProduct.unitPrice)}
</Descriptions.Item>
<Descriptions.Item label="Enhed">{selectedProduct.unit || '-'}</Descriptions.Item>
<Descriptions.Item label="Momskode">
<Tag>{selectedProduct.vatCode}</Tag>
</Descriptions.Item>
<Descriptions.Item label="EAN">
{selectedProduct.ean || '-'}
</Descriptions.Item>
<Descriptions.Item label="Producent">
{selectedProduct.manufacturer || '-'}
</Descriptions.Item>
<Descriptions.Item label="Status">
<StatusBadge
status={selectedProduct.isActive ? 'success' : 'error'}
text={selectedProduct.isActive ? 'Aktiv' : 'Inaktiv'}
/>
</Descriptions.Item>
<Descriptions.Item label="Oprettet">
{formatDate(selectedProduct.createdAt)}
</Descriptions.Item>
<Descriptions.Item label="Opdateret">
{formatDate(selectedProduct.updatedAt)}
</Descriptions.Item>
</Descriptions>
<div style={{ marginTop: spacing.lg }}>
<Space>
<Button icon={<EditOutlined />} onClick={() => handleEdit(selectedProduct)}>
Rediger
</Button>
<Popconfirm
title={
selectedProduct.isActive ? 'Deaktiver dette produkt?' : 'Genaktiver dette produkt?'
}
onConfirm={() => handleToggleActive(selectedProduct)}
okText="Ja"
cancelText="Nej"
>
<Button danger={selectedProduct.isActive}>
{selectedProduct.isActive ? 'Deaktiver' : 'Genaktiver'}
</Button>
</Popconfirm>
</Space>
</div>
</div>
)}
</Drawer>
</div>
);
}

View file

@ -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: <ExclamationCircleOutlined />,
content: (
<div>
<p>Er du sikker , at du vil slette din konto?</p>
<p><Text type="danger">Denne handling kan ikke fortrydes.</Text></p>
</div>
),
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: (
<span>
<UserOutlined /> Profil
</span>
),
children: (
<Card>
<div style={{ marginBottom: spacing.xl }}>
<div style={{ display: 'flex', alignItems: 'center', gap: spacing.lg }}>
<Avatar size={80} icon={<UserOutlined />} src={mockUser.avatar} />
<div>
<Upload {...uploadProps}>
<Button icon={<UploadOutlined />}>Skift profilbillede</Button>
</Upload>
<Text type="secondary" style={{ display: 'block', marginTop: spacing.xs }}>
JPG eller PNG, max 2MB
</Text>
</div>
</div>
</div>
<Form
form={profileForm}
layout="vertical"
initialValues={{
name: mockUser.name,
email: mockUser.email,
phone: mockUser.phone,
}}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="name"
label="Fulde navn"
rules={[{ required: true, message: 'Indtast dit navn' }]}
>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="email"
label="Email"
rules={[
{ required: true, message: 'Indtast din email' },
{ type: 'email', message: 'Indtast en gyldig email' },
]}
>
<Input />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="phone" label="Telefon">
<Input />
</Form.Item>
</Col>
</Row>
<div style={{ textAlign: 'right' }}>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSaveProfile}>
Gem profil
</Button>
</div>
</Form>
</Card>
),
},
{
key: 'security',
label: (
<span>
<LockOutlined /> Sikkerhed
</span>
),
children: (
<Card>
<Title level={5}>Skift adgangskode</Title>
<Text type="secondary" style={{ display: 'block', marginBottom: spacing.lg }}>
Vi anbefaler at bruge en stærk adgangskode, som du ikke bruger andre steder.
</Text>
{!isChangingPassword ? (
<Button onClick={() => setIsChangingPassword(true)}>
Skift adgangskode
</Button>
) : (
<Form form={passwordForm} layout="vertical" style={{ maxWidth: 400 }}>
<Form.Item
name="currentPassword"
label="Nuværende adgangskode"
rules={[{ required: true, message: 'Indtast din nuværende adgangskode' }]}
>
<Input.Password />
</Form.Item>
<Form.Item
name="newPassword"
label="Ny adgangskode"
rules={[
{ required: true, message: 'Indtast en ny adgangskode' },
{ min: 8, message: 'Adgangskoden skal være mindst 8 tegn' },
]}
>
<Input.Password />
</Form.Item>
<Form.Item
name="confirmPassword"
label="Bekræft ny adgangskode"
rules={[{ required: true, message: 'Bekræft din nye adgangskode' }]}
>
<Input.Password />
</Form.Item>
<div style={{ display: 'flex', gap: spacing.sm }}>
<Button type="primary" onClick={handleChangePassword}>
Gem ny adgangskode
</Button>
<Button onClick={() => setIsChangingPassword(false)}>
Annuller
</Button>
</div>
</Form>
)}
<Divider />
<Title level={5}>To-faktor-godkendelse</Title>
<Text type="secondary" style={{ display: 'block', marginBottom: spacing.md }}>
Tilføj et ekstra lag af sikkerhed til din konto.
</Text>
<Button disabled>Aktiver 2FA (kommer snart)</Button>
<Divider />
<Title level={5} style={{ color: '#ff4d4f' }}>Farezone</Title>
<Text type="secondary" style={{ display: 'block', marginBottom: spacing.md }}>
Når du sletter din konto, mister du al adgang til virksomheder og data.
</Text>
<Button danger onClick={handleDeleteAccount}>
Slet min konto
</Button>
</Card>
),
},
{
key: 'notifications',
label: (
<span>
<BellOutlined /> Notifikationer
</span>
),
children: (
<Card>
<Form
form={notificationForm}
layout="vertical"
initialValues={mockUser.notifications}
>
<Title level={5}>Email-notifikationer</Title>
<Form.Item
name="email"
valuePropName="checked"
style={{ marginBottom: spacing.sm }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Text strong>Generelle emails</Text>
<Text type="secondary" style={{ display: 'block' }}>
Modtag vigtige opdateringer om din konto
</Text>
</div>
<Switch defaultChecked={mockUser.notifications.email} />
</div>
</Form.Item>
<Form.Item
name="weeklyReport"
valuePropName="checked"
style={{ marginBottom: spacing.sm }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Text strong>Ugentlig rapport</Text>
<Text type="secondary" style={{ display: 'block' }}>
en opsummering af ugens bogføring hver mandag
</Text>
</div>
<Switch defaultChecked={mockUser.notifications.weeklyReport} />
</div>
</Form.Item>
<Form.Item
name="deadlineReminders"
valuePropName="checked"
style={{ marginBottom: spacing.sm }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Text strong>Påmindelser om frister</Text>
<Text type="secondary" style={{ display: 'block' }}>
besked når momsfrister eller andre deadlines nærmer sig
</Text>
</div>
<Switch defaultChecked={mockUser.notifications.deadlineReminders} />
</div>
</Form.Item>
<Divider />
<Title level={5}>Browser-notifikationer</Title>
<Form.Item
name="browser"
valuePropName="checked"
style={{ marginBottom: spacing.sm }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Text strong>Push-notifikationer</Text>
<Text type="secondary" style={{ display: 'block' }}>
Modtag notifikationer direkte i browseren
</Text>
</div>
<Switch defaultChecked={mockUser.notifications.browser} />
</div>
</Form.Item>
<div style={{ textAlign: 'right', marginTop: spacing.lg }}>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSaveNotifications}>
Gem notifikationsindstillinger
</Button>
</div>
</Form>
</Card>
),
},
{
key: 'preferences',
label: (
<span>
<GlobalOutlined /> Præferencer
</span>
),
children: (
<Card>
<Form
layout="vertical"
initialValues={{
language: mockUser.language,
timezone: mockUser.timezone,
}}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="language" label="Sprog">
<Select
options={[
{ value: 'da', label: 'Dansk' },
{ value: 'en', label: 'English' },
]}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="timezone" label="Tidszone">
<Select
options={[
{ value: 'Europe/Copenhagen', label: 'København (GMT+1)' },
{ value: 'Europe/London', label: 'London (GMT)' },
{ value: 'America/New_York', label: 'New York (GMT-5)' },
]}
/>
</Form.Item>
</Col>
</Row>
<Divider />
<Title level={5}>Visningsindstillinger</Title>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="dateFormat" label="Datoformat">
<Select
defaultValue="dd-mm-yyyy"
options={[
{ value: 'dd-mm-yyyy', label: 'DD-MM-YYYY (31-12-2025)' },
{ value: 'mm-dd-yyyy', label: 'MM-DD-YYYY (12-31-2025)' },
{ value: 'yyyy-mm-dd', label: 'YYYY-MM-DD (2025-12-31)' },
]}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="numberFormat" label="Talformat">
<Select
defaultValue="danish"
options={[
{ value: 'danish', label: '1.234,56 (dansk)' },
{ value: 'english', label: '1,234.56 (engelsk)' },
]}
/>
</Form.Item>
</Col>
</Row>
<div style={{ textAlign: 'right' }}>
<Button type="primary" icon={<SaveOutlined />}>
Gem præferencer
</Button>
</div>
</Form>
</Card>
),
},
];
return (
<div>
{/* Header */}
<div style={{ marginBottom: spacing.lg }}>
<Title level={4} style={{ margin: 0 }}>
Min profil
</Title>
<Text type="secondary">Administrer dine personlige indstillinger</Text>
</div>
<Tabs items={tabItems} />
</div>
);
}

View file

@ -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<RecentCommand, 'timestamp'>) => void;
clearRecentCommands: () => void;
}
const MAX_RECENT_COMMANDS = 5;
export const useHotkeyStore = create<HotkeyState>()(
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);

View file

@ -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;
};

View file

@ -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<OrderStatus, string> = {
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<OrderStatus, string> = {
draft: 'default',
confirmed: 'processing',
partially_invoiced: 'warning',
fully_invoiced: 'success',
cancelled: 'error',
};

View file

@ -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;
}

View file

@ -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"}
{"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"}