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:
parent
1f75c5d791
commit
381156ade7
71 changed files with 16898 additions and 5 deletions
44
.beads/.gitignore
vendored
Normal file
44
.beads/.gitignore
vendored
Normal 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
81
.beads/README.md
Normal 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
62
.beads/config.yaml
Normal 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
|
||||
0
.beads/interactions.jsonl
Normal file
0
.beads/interactions.jsonl
Normal file
4
.beads/metadata.json
Normal file
4
.beads/metadata.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"database": "beads.db",
|
||||
"jsonl_export": "issues.jsonl"
|
||||
}
|
||||
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
# Use bd merge for beads JSONL files
|
||||
.beads/issues.jsonl merge=beads
|
||||
123
CLAUDE.md
Normal file
123
CLAUDE.md
Normal 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
|
||||
|
||||
173
frontend/src/api/documentProcessing.ts
Normal file
173
frontend/src/api/documentProcessing.ts
Normal 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 };
|
||||
}
|
||||
83
frontend/src/api/mutations/accountMutations.ts
Normal file
83
frontend/src/api/mutations/accountMutations.ts
Normal 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 }) });
|
||||
},
|
||||
});
|
||||
}
|
||||
295
frontend/src/api/mutations/bankConnectionMutations.ts
Normal file
295
frontend/src/api/mutations/bankConnectionMutations.ts
Normal 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 }),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
257
frontend/src/api/mutations/companyMutations.ts
Normal file
257
frontend/src/api/mutations/companyMutations.ts
Normal 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') });
|
||||
},
|
||||
});
|
||||
}
|
||||
222
frontend/src/api/mutations/customerMutations.ts
Normal file
222
frontend/src/api/mutations/customerMutations.ts
Normal 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 }),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
203
frontend/src/api/mutations/draftMutations.ts
Normal file
203
frontend/src/api/mutations/draftMutations.ts
Normal 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 }),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
214
frontend/src/api/mutations/fiscalYearMutations.ts
Normal file
214
frontend/src/api/mutations/fiscalYearMutations.ts
Normal 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 }),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
610
frontend/src/api/mutations/invoiceMutations.ts
Normal file
610
frontend/src/api/mutations/invoiceMutations.ts
Normal 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 }),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
443
frontend/src/api/mutations/orderMutations.ts
Normal file
443
frontend/src/api/mutations/orderMutations.ts
Normal 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 }),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
211
frontend/src/api/mutations/productMutations.ts
Normal file
211
frontend/src/api/mutations/productMutations.ts
Normal 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 }),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
71
frontend/src/api/mutations/saftMutations.ts
Normal file
71
frontend/src/api/mutations/saftMutations.ts
Normal 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);
|
||||
}
|
||||
235
frontend/src/api/queries/accountQueries.ts
Normal file
235
frontend/src/api/queries/accountQueries.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
182
frontend/src/api/queries/bankConnectionQueries.ts
Normal file
182
frontend/src/api/queries/bankConnectionQueries.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
274
frontend/src/api/queries/bankTransactionQueries.ts
Normal file
274
frontend/src/api/queries/bankTransactionQueries.ts
Normal 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',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
152
frontend/src/api/queries/companyQueries.ts
Normal file
152
frontend/src/api/queries/companyQueries.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
174
frontend/src/api/queries/customerQueries.ts
Normal file
174
frontend/src/api/queries/customerQueries.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
||||
114
frontend/src/api/queries/draftQueries.ts
Normal file
114
frontend/src/api/queries/draftQueries.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
137
frontend/src/api/queries/fiscalYearQueries.ts
Normal file
137
frontend/src/api/queries/fiscalYearQueries.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
397
frontend/src/api/queries/invoiceQueries.ts
Normal file
397
frontend/src/api/queries/invoiceQueries.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
369
frontend/src/api/queries/orderQueries.ts
Normal file
369
frontend/src/api/queries/orderQueries.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
150
frontend/src/api/queries/productQueries.ts
Normal file
150
frontend/src/api/queries/productQueries.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
81
frontend/src/api/queries/vatQueries.ts
Normal file
81
frontend/src/api/queries/vatQueries.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
114
frontend/src/components/auth/CompanyGuard.tsx
Normal file
114
frontend/src/components/auth/CompanyGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
345
frontend/src/components/company/UserAccessManager.tsx
Normal file
345
frontend/src/components/company/UserAccessManager.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
259
frontend/src/components/kassekladde/BalanceImpactPanel.tsx
Normal file
259
frontend/src/components/kassekladde/BalanceImpactPanel.tsx
Normal 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 på 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;
|
||||
698
frontend/src/components/settings/BankConnectionsTab.tsx
Normal file
698
frontend/src/components/settings/BankConnectionsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
212
frontend/src/components/shared/AmountText.tsx
Normal file
212
frontend/src/components/shared/AmountText.tsx
Normal 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;
|
||||
292
frontend/src/components/shared/AttachmentUpload.tsx
Normal file
292
frontend/src/components/shared/AttachmentUpload.tsx
Normal 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;
|
||||
177
frontend/src/components/shared/ConfirmationModal.tsx
Normal file
177
frontend/src/components/shared/ConfirmationModal.tsx
Normal 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;
|
||||
35
frontend/src/components/shared/DemoDataDisclaimer.tsx
Normal file
35
frontend/src/components/shared/DemoDataDisclaimer.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
241
frontend/src/components/shared/EmptyState.tsx
Normal file
241
frontend/src/components/shared/EmptyState.tsx
Normal 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;
|
||||
150
frontend/src/components/shared/ErrorBoundary.tsx
Normal file
150
frontend/src/components/shared/ErrorBoundary.tsx
Normal 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}>
|
||||
Gå 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;
|
||||
172
frontend/src/components/shared/FullPageDropZone.tsx
Normal file
172
frontend/src/components/shared/FullPageDropZone.tsx
Normal 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;
|
||||
145
frontend/src/components/shared/HotkeyProvider.tsx
Normal file
145
frontend/src/components/shared/HotkeyProvider.tsx
Normal 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;
|
||||
123
frontend/src/components/shared/ISODatePicker.tsx
Normal file
123
frontend/src/components/shared/ISODatePicker.tsx
Normal 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;
|
||||
113
frontend/src/components/shared/PageHeader.tsx
Normal file
113
frontend/src/components/shared/PageHeader.tsx
Normal 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;
|
||||
223
frontend/src/components/shared/PeriodFilter.tsx
Normal file
223
frontend/src/components/shared/PeriodFilter.tsx
Normal 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;
|
||||
140
frontend/src/components/shared/ShortcutTooltip.tsx
Normal file
140
frontend/src/components/shared/ShortcutTooltip.tsx
Normal 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;
|
||||
135
frontend/src/components/shared/ShortcutsHelpModal.tsx
Normal file
135
frontend/src/components/shared/ShortcutsHelpModal.tsx
Normal 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;
|
||||
141
frontend/src/components/shared/SkeletonLoader.tsx
Normal file
141
frontend/src/components/shared/SkeletonLoader.tsx
Normal 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;
|
||||
143
frontend/src/components/shared/StatisticCard.tsx
Normal file
143
frontend/src/components/shared/StatisticCard.tsx
Normal 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;
|
||||
207
frontend/src/components/shared/StatusBadge.tsx
Normal file
207
frontend/src/components/shared/StatusBadge.tsx
Normal 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;
|
||||
56
frontend/src/components/shared/index.ts
Normal file
56
frontend/src/components/shared/index.ts
Normal 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';
|
||||
126
frontend/src/hooks/useAutoSave.ts
Normal file
126
frontend/src/hooks/useAutoSave.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
130
frontend/src/hooks/usePageHotkeys.ts
Normal file
130
frontend/src/hooks/usePageHotkeys.ts
Normal 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;
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
93
frontend/src/hooks/useResponsiveModal.ts
Normal file
93
frontend/src/hooks/useResponsiveModal.ts
Normal 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;
|
||||
225
frontend/src/lib/errorHandling.ts
Normal file
225
frontend/src/lib/errorHandling.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
243
frontend/src/pages/Admin.tsx
Normal file
243
frontend/src/pages/Admin.tsx
Normal 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 på 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>
|
||||
);
|
||||
}
|
||||
501
frontend/src/pages/CompanySetupWizard.tsx
Normal file
501
frontend/src/pages/CompanySetupWizard.tsx
Normal 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}
|
||||
>
|
||||
Gå 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>
|
||||
);
|
||||
}
|
||||
209
frontend/src/pages/Eksport.tsx
Normal file
209
frontend/src/pages/Eksport.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1023
frontend/src/pages/Fakturaer.tsx
Normal file
1023
frontend/src/pages/Fakturaer.tsx
Normal file
File diff suppressed because it is too large
Load diff
947
frontend/src/pages/Kreditnotaer.tsx
Normal file
947
frontend/src/pages/Kreditnotaer.tsx
Normal 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 på 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 på 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>
|
||||
);
|
||||
}
|
||||
661
frontend/src/pages/Kunder.tsx
Normal file
661
frontend/src/pages/Kunder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
996
frontend/src/pages/Ordrer.tsx
Normal file
996
frontend/src/pages/Ordrer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
575
frontend/src/pages/Produkter.tsx
Normal file
575
frontend/src/pages/Produkter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
472
frontend/src/pages/UserSettings.tsx
Normal file
472
frontend/src/pages/UserSettings.tsx
Normal 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 på, 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' }}>
|
||||
Få 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' }}>
|
||||
Få 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>
|
||||
);
|
||||
}
|
||||
118
frontend/src/stores/hotkeyStore.ts
Normal file
118
frontend/src/stores/hotkeyStore.ts
Normal 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);
|
||||
374
frontend/src/styles/designTokens.ts
Normal file
374
frontend/src/styles/designTokens.ts
Normal 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;
|
||||
};
|
||||
74
frontend/src/types/order.ts
Normal file
74
frontend/src/types/order.ts
Normal 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',
|
||||
};
|
||||
20
frontend/src/types/product.ts
Normal file
20
frontend/src/types/product.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue