Full product audit: fix security, compliance, UX, and wire broken features
Security (Phase 1): - Add authentication middleware on /graphql endpoint - Filter company queries by user access (prevent IDOR) - Add role-based authorization on mutations (owner/accountant) - Reduce API key cache TTL from 24h to 5 minutes - Hide exception details in production GraphQL errors - Fix RBAC in frontend companyStore (was hardcoded) Wiring broken features (Phase 2): - Wire Kassekladde submit/void/copy to GraphQL mutations - Wire Kontooversigt account creation to createAccount mutation - Wire Settings save to updateCompany mutation - Wire CreateFiscalYearModal and CloseFiscalYearWizard to mutations - Replace Momsindberetning mock data with real useVatReport query - Remove Dashboard hardcoded percentages and fake VAT deadline - Fix Kreditnotaer invoice selector to use real data - Fix mutation retry from 1 to 0 (prevent duplicate operations) Accounting compliance (Phase 3): - Add balanced entry validation (debit==credit) in JournalEntryDraftAggregate - Add fiscal year boundary enforcement (status, date range checks) - Add PostedAt timestamp to posted events (Bogføringsloven §7) - Add account number uniqueness check within company - Add fiscal year overlap and gap checks - Add sequential invoice auto-numbering - Fix InvoiceLine VAT rate to use canonical VatCodes - Fix SAF-T account type mapping (financial → Expense) - Add DraftLine validation (cannot have both debit and credit > 0) UX improvements (Phase 4): - Fix Danish character encoding across 15+ files (ø, æ, å) - Deploy DemoDataDisclaimer on pages with mock/incomplete data - Adopt PageHeader component universally across all pages - Standardize active/inactive filtering to Switch pattern - Fix dead buttons in Header (Help, Notifications) - Remove hardcoded mock data from Settings - Fix Sidebar controlled state and Kontooversigt navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
effb06fc44
commit
8e05171b66
49 changed files with 1537 additions and 1192 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { GraphQLClient } from 'graphql-request';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { useCompanyStore } from '@/stores/companyStore';
|
||||
|
||||
// GraphQL endpoint - configure based on environment
|
||||
const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql';
|
||||
|
|
@ -26,8 +27,8 @@ export const queryClient = new QueryClient({
|
|||
refetchOnWindowFocus: true,
|
||||
},
|
||||
mutations: {
|
||||
// Retry mutations once
|
||||
retry: 1,
|
||||
// Never retry mutations - non-idempotent operations could create duplicates
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -38,7 +39,16 @@ export async function fetchGraphQL<TData, TVariables extends Record<string, unkn
|
|||
variables?: TVariables
|
||||
): Promise<TData> {
|
||||
try {
|
||||
const data = await graphqlClient.request<TData>(query, variables);
|
||||
// Get active company from store (outside React)
|
||||
const activeCompany = useCompanyStore.getState().activeCompany;
|
||||
|
||||
// Build headers with company ID if available
|
||||
const headers: Record<string, string> = {};
|
||||
if (activeCompany?.id) {
|
||||
headers['X-Company-Id'] = activeCompany.id;
|
||||
}
|
||||
|
||||
const data = await graphqlClient.request<TData>(query, variables, headers);
|
||||
return data;
|
||||
} catch (error) {
|
||||
// Log error for debugging
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ export async function processDocument(
|
|||
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('AI_UNAVAILABLE', 'AI-tjenesten er midlertidigt utilgængelig');
|
||||
}
|
||||
throw new DocumentProcessingApiError('UNKNOWN_ERROR', `Serverfejl: ${response.status}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ function transformAccount(acc: AccountResponse): Account {
|
|||
description: acc.description,
|
||||
vatCode: acc.vatCodeId,
|
||||
isActive: acc.isActive,
|
||||
isSystemAccount: acc.isSystemAccount,
|
||||
balance: 0, // Not returned from backend yet
|
||||
createdAt: acc.createdAt,
|
||||
updatedAt: acc.updatedAt,
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export default function CompanyGuard({ children }: CompanyGuardProps) {
|
|||
}
|
||||
// Note: Users with existing companies CAN access the wizard to create more
|
||||
}
|
||||
}, [companies, isLoading, navigate, location.pathname]);
|
||||
}, [companies, isLoading, navigate]); // Note: location.pathname intentionally omitted to prevent infinite loop
|
||||
|
||||
// Reset navigation ref when companies change (user created a company)
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export function DocumentUploadModal({
|
|||
message.success('Bogfoert!');
|
||||
onConfirm();
|
||||
} catch (err) {
|
||||
message.error('Kunne ikke bogfoere. Proev igen.');
|
||||
message.error('Kunne ikke bogføre. Prøv igen.');
|
||||
} finally {
|
||||
setIsPosting(false);
|
||||
}
|
||||
|
|
@ -270,7 +270,7 @@ export function DocumentUploadModal({
|
|||
Annuller
|
||||
</Button>,
|
||||
<Button key="draft" onClick={handleSaveAsDraft}>
|
||||
Tilfoej til kladde
|
||||
Tilføj til kladde
|
||||
</Button>,
|
||||
<Button
|
||||
key="post"
|
||||
|
|
@ -451,7 +451,7 @@ function ExtractedInfoSection({
|
|||
render: (val?: number) => (val != null ? formatCurrency(val) : '-'),
|
||||
},
|
||||
{
|
||||
title: 'Beloeb',
|
||||
title: 'Beløb',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
align: 'right' as const,
|
||||
|
|
@ -526,7 +526,7 @@ function ExtractedInfoSection({
|
|||
<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>
|
||||
<Text type="secondary">Beløb ekskl. moms</Text>
|
||||
<AmountText amount={extraction.amountExVat} />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -551,7 +551,7 @@ function ExtractedInfoSection({
|
|||
extraction.amountExVat != null || extraction.vatAmount != null ? 4 : 0,
|
||||
}}
|
||||
>
|
||||
<Text strong>Beloeb inkl. moms</Text>
|
||||
<Text strong>Beløb inkl. moms</Text>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ const STATUS_CONFIG: Record<FiscalYear['status'], {
|
|||
open: {
|
||||
color: 'success',
|
||||
icon: <CheckCircleOutlined />,
|
||||
label: 'Aben',
|
||||
label: 'Åben',
|
||||
},
|
||||
closed: {
|
||||
color: 'warning',
|
||||
|
|
@ -40,7 +40,7 @@ const STATUS_CONFIG: Record<FiscalYear['status'], {
|
|||
locked: {
|
||||
color: 'error',
|
||||
icon: <LockOutlined />,
|
||||
label: 'Last',
|
||||
label: 'Låst',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -84,16 +84,19 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
|
|||
if (fiscalYearsData.length > 0) {
|
||||
setFiscalYears(fiscalYearsData);
|
||||
|
||||
// Get current value without adding to dependencies to avoid infinite loop
|
||||
const current = usePeriodStore.getState().currentFiscalYear;
|
||||
|
||||
// Validate currentFiscalYear belongs to this company's data
|
||||
const isValid = currentFiscalYear &&
|
||||
fiscalYearsData.some(fy => fy.id === currentFiscalYear.id);
|
||||
const isValid = current &&
|
||||
fiscalYearsData.some(fy => fy.id === current.id);
|
||||
|
||||
if (!isValid) {
|
||||
const openYear = fiscalYearsData.find(y => y.status === 'open');
|
||||
setCurrentFiscalYear(openYear || fiscalYearsData[0]);
|
||||
}
|
||||
}
|
||||
}, [fiscalYearsData, currentFiscalYear, setFiscalYears, setCurrentFiscalYear]);
|
||||
}, [fiscalYearsData, setFiscalYears, setCurrentFiscalYear]);
|
||||
|
||||
const handleFiscalYearChange = (yearId: string) => {
|
||||
const year = fiscalYears.find((y) => y.id === yearId);
|
||||
|
|
@ -146,7 +149,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
|
|||
style={{ minWidth: 200 }}
|
||||
optionLabelProp="label"
|
||||
popupMatchSelectWidth={false}
|
||||
dropdownRender={(menu) => (
|
||||
popupRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
|
@ -157,7 +160,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
|
|||
onClick={handleCreateNew}
|
||||
size="small"
|
||||
>
|
||||
Opret nyt regnskabsar
|
||||
Opret nyt regnskabsår
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
|
|
@ -172,7 +175,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
|
|||
)}
|
||||
options={sortedYears.map((year) => ({
|
||||
value: year.id,
|
||||
label: `Regnskabsar ${year.name}`,
|
||||
label: `Regnskabsår ${year.name}`,
|
||||
year,
|
||||
}))}
|
||||
optionRender={(option) => {
|
||||
|
|
|
|||
|
|
@ -125,31 +125,27 @@ export default function Header({ isMobile = false }: HeaderProps) {
|
|||
)}
|
||||
|
||||
{/* Help */}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<QuestionCircleOutlined />}
|
||||
aria-label="Hjælp"
|
||||
title="Hjælp"
|
||||
/>
|
||||
|
||||
{/* Notifications */}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BellOutlined />}
|
||||
aria-label="Notifikationer"
|
||||
title="Notifikationer"
|
||||
/>
|
||||
|
||||
{/* Logout */}
|
||||
<Tooltip title="Log ud">
|
||||
<Tooltip title="Hjælp">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={logout}
|
||||
aria-label="Log ud"
|
||||
icon={<QuestionCircleOutlined />}
|
||||
onClick={() => window.open('https://help.books.dk', '_blank')}
|
||||
aria-label="Hjælp"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Notifications */}
|
||||
<Tooltip title="Notifikationer">
|
||||
<Badge count={0} size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BellOutlined />}
|
||||
onClick={() => navigate('/indstillinger')}
|
||||
aria-label="Notifikationer"
|
||||
/>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
|
||||
{/* User Menu */}
|
||||
<Dropdown
|
||||
menu={{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Layout, Menu } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
|
|
@ -42,7 +43,7 @@ function getItem(
|
|||
const menuItems: MenuItem[] = [
|
||||
getItem('Dashboard', '/', <DashboardOutlined />),
|
||||
|
||||
getItem('Bogfoering', 'accounting', <BookOutlined />, [
|
||||
getItem('Bogføring', 'accounting', <BookOutlined />, [
|
||||
getItem('Kassekladde', '/kassekladde', <FileTextOutlined />),
|
||||
getItem('Kontooversigt', '/kontooversigt', <AccountBookOutlined />),
|
||||
]),
|
||||
|
|
@ -61,7 +62,7 @@ const menuItems: MenuItem[] = [
|
|||
|
||||
getItem('Rapportering', 'reporting', <PercentageOutlined />, [
|
||||
getItem('Momsindberetning', '/momsindberetning', <PercentageOutlined />),
|
||||
getItem('Loenforstaelse', '/loenforstaelse', <TeamOutlined />),
|
||||
getItem('Lønforståelse', '/loenforstaelse', <TeamOutlined />),
|
||||
getItem('Eksport', '/eksport', <ExportOutlined />),
|
||||
]),
|
||||
|
||||
|
|
@ -99,6 +100,17 @@ interface SidebarMenuProps {
|
|||
export function SidebarMenu({ onNavigate }: SidebarMenuProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [openKeys, setOpenKeys] = useState<string[]>(getOpenKeys(location.pathname));
|
||||
|
||||
// Update openKeys when location changes
|
||||
useEffect(() => {
|
||||
const newOpenKeys = getOpenKeys(location.pathname);
|
||||
setOpenKeys((prev) => {
|
||||
// Merge: keep existing open keys but ensure the current path's group is open
|
||||
const merged = [...new Set([...prev, ...newOpenKeys])];
|
||||
return merged;
|
||||
});
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
if (key.startsWith('/')) {
|
||||
|
|
@ -107,6 +119,10 @@ export function SidebarMenu({ onNavigate }: SidebarMenuProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (keys: string[]) => {
|
||||
setOpenKeys(keys);
|
||||
};
|
||||
|
||||
const selectedKeys = [location.pathname];
|
||||
|
||||
return (
|
||||
|
|
@ -114,7 +130,8 @@ export function SidebarMenu({ onNavigate }: SidebarMenuProps) {
|
|||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys}
|
||||
defaultOpenKeys={getOpenKeys(location.pathname)}
|
||||
openKeys={openKeys}
|
||||
onOpenChange={handleOpenChange}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{ borderRight: 0 }}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import {
|
|||
import { formatCurrency } from '@/lib/formatters';
|
||||
import type { FiscalYear } from '@/types/periods';
|
||||
import type { Account, Transaction } from '@/types/accounting';
|
||||
import { useCloseFiscalYear } from '@/api/mutations/fiscalYearMutations';
|
||||
import { message } from 'antd';
|
||||
|
||||
const { Text, Title, Paragraph } = Typography;
|
||||
|
||||
|
|
@ -82,6 +84,8 @@ export default function CloseFiscalYearWizard({
|
|||
lockPeriod,
|
||||
} = usePeriodStore();
|
||||
|
||||
const closeFiscalYearMutation = useCloseFiscalYear();
|
||||
|
||||
// Reset wizard when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
|
|
@ -160,27 +164,41 @@ export default function CloseFiscalYearWizard({
|
|||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// 1. Close open periods if requested
|
||||
// TODO: CRITICAL ACCOUNTING ISSUE - The closing entries preview is calculated
|
||||
// in generateClosingEntries() but never actually posted to the ledger.
|
||||
// Before closing the fiscal year, these closing entries MUST be posted:
|
||||
// 1. Revenue accounts should be zeroed out to the result account
|
||||
// 2. Expense accounts should be zeroed out to the result account
|
||||
// 3. The net result should be transferred to the equity account (resultAccountId)
|
||||
// Without posting these entries, the opening balances for the next year will be incorrect.
|
||||
|
||||
// 1. Close open periods if requested (local store)
|
||||
if (closeOpenPeriods) {
|
||||
for (const period of openPeriodsInYear) {
|
||||
closePeriod(period.id, 'system');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Lock all periods in the year
|
||||
// 2. Lock all periods in the year (local store)
|
||||
for (const period of yearPeriods) {
|
||||
lockPeriod(period.id, 'system');
|
||||
}
|
||||
|
||||
// 3. Close and lock the fiscal year
|
||||
// 3. Call backend mutation to close the fiscal year
|
||||
await closeFiscalYearMutation.mutateAsync(fiscalYear.id);
|
||||
|
||||
// 4. Also update local store
|
||||
closeFiscalYear(fiscalYear.id, 'system');
|
||||
lockFiscalYear(fiscalYear.id, 'system');
|
||||
|
||||
// 4. Move to complete step
|
||||
// 5. Move to complete step
|
||||
setCurrentStep('complete');
|
||||
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl ved arsafslutning: ${error.message}`);
|
||||
}
|
||||
console.error('Failed to close fiscal year:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import {
|
|||
} from '@/lib/fiscalYear';
|
||||
import { generateAccountingPeriods } from '@/lib/periods';
|
||||
import type { FiscalYear, PeriodFrequency } from '@/types/periods';
|
||||
import { useCreateFiscalYear } from '@/api/mutations/fiscalYearMutations';
|
||||
import { message } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
|
@ -58,6 +60,7 @@ export default function CreateFiscalYearModal({
|
|||
|
||||
const { activeCompany } = useCompanyStore();
|
||||
const { fiscalYears, addFiscalYear, setPeriods, periods, setCurrentFiscalYear } = usePeriodStore();
|
||||
const createFiscalYearMutation = useCreateFiscalYear();
|
||||
|
||||
// Calculate suggested fiscal year boundaries
|
||||
useEffect(() => {
|
||||
|
|
@ -114,20 +117,18 @@ export default function CreateFiscalYearModal({
|
|||
const startDate = values.dateRange[0].format('YYYY-MM-DD');
|
||||
const endDate = values.dateRange[1].format('YYYY-MM-DD');
|
||||
|
||||
// Create fiscal year object
|
||||
const newFiscalYear: FiscalYear = {
|
||||
id: `fy-${values.name}-${Date.now()}`,
|
||||
// Call backend mutation - let the backend generate the ID
|
||||
const newFiscalYear = await createFiscalYearMutation.mutateAsync({
|
||||
companyId: activeCompany.id,
|
||||
name: values.name,
|
||||
startDate,
|
||||
endDate,
|
||||
status: 'open',
|
||||
openingBalancePosted: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
// Generate accounting periods and add required fields
|
||||
// Also update local store as a cache layer
|
||||
addFiscalYear(newFiscalYear);
|
||||
|
||||
// Generate accounting periods locally for the UI
|
||||
const generatedPeriods = generateAccountingPeriods(newFiscalYear, values.periodFrequency);
|
||||
const now = new Date().toISOString();
|
||||
const newPeriods = generatedPeriods.map((p, idx) => ({
|
||||
|
|
@ -136,9 +137,6 @@ export default function CreateFiscalYearModal({
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
// Add to store
|
||||
addFiscalYear(newFiscalYear);
|
||||
setPeriods([...periods, ...newPeriods]);
|
||||
|
||||
// Set as current if this is the first or most recent
|
||||
|
|
@ -153,6 +151,9 @@ export default function CreateFiscalYearModal({
|
|||
onSuccess?.(newFiscalYear);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl ved oprettelse: ${error.message}`);
|
||||
}
|
||||
console.error('Failed to create fiscal year:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
|
|
@ -180,7 +181,7 @@ export default function CreateFiscalYearModal({
|
|||
loading: isSubmitting,
|
||||
}}
|
||||
width={520}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
{!activeCompany && (
|
||||
<Alert
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT
|
|||
lines.push({
|
||||
accountId: `vat-input-${vatCode}`,
|
||||
accountNumber: VAT_ACCOUNTS.inputVAT,
|
||||
accountName: 'Indgaaende moms',
|
||||
accountName: 'Indgående moms',
|
||||
description: `Moms: ${description}`,
|
||||
debit: vatAmount,
|
||||
credit: 0,
|
||||
|
|
@ -347,7 +347,7 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT
|
|||
lines.push({
|
||||
accountId: 'vat-output',
|
||||
accountNumber: VAT_ACCOUNTS.outputVAT,
|
||||
accountName: 'Udgaaende moms',
|
||||
accountName: 'Udgående moms',
|
||||
description: `Moms: ${description}`,
|
||||
debit: 0,
|
||||
credit: vatAmount,
|
||||
|
|
@ -431,7 +431,7 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
|
|||
generatedLines.push({
|
||||
accountId: `vat-input-${splitLine.vatCode}`,
|
||||
accountNumber: VAT_ACCOUNTS.inputVAT,
|
||||
accountName: 'Indgaaende moms',
|
||||
accountName: 'Indgående moms',
|
||||
description: `Moms: ${description}`,
|
||||
debit: lineVat,
|
||||
credit: 0,
|
||||
|
|
@ -506,7 +506,7 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
|
|||
generatedLines.push({
|
||||
accountId: 'vat-output',
|
||||
accountNumber: VAT_ACCOUNTS.outputVAT,
|
||||
accountName: 'Udgaaende moms',
|
||||
accountName: 'Udgående moms',
|
||||
description: `Moms: ${description}`,
|
||||
debit: 0,
|
||||
credit: lineVat,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import type { VATPeriodicitet } from '@/types/periods';
|
|||
export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
|
||||
S25: {
|
||||
code: 'S25',
|
||||
nameDanish: 'Udgaaende moms 25%',
|
||||
nameDanish: 'Udgående moms 25%',
|
||||
nameEnglish: 'Output VAT 25%',
|
||||
rate: 0.25,
|
||||
type: 'output',
|
||||
|
|
@ -30,7 +30,7 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
|
|||
},
|
||||
K25: {
|
||||
code: 'K25',
|
||||
nameDanish: 'Indgaaende moms 25%',
|
||||
nameDanish: 'Indgående moms 25%',
|
||||
nameEnglish: 'Input VAT 25%',
|
||||
rate: 0.25,
|
||||
type: 'input',
|
||||
|
|
@ -230,8 +230,8 @@ export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
|
|||
* Default VAT accounts for automatic double-entry
|
||||
*/
|
||||
export const VAT_ACCOUNTS = {
|
||||
inputVAT: '5610', // Indgaaende moms (fradrag)
|
||||
outputVAT: '5710', // Udgaaende moms (skyld)
|
||||
inputVAT: '5610', // Indgående moms (fradrag)
|
||||
outputVAT: '5710', // Udgående moms (skyld)
|
||||
euVAT: '5620', // EU-moms (erhvervelsesmoms)
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -20,14 +20,13 @@ import {
|
|||
DashboardOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useUser } from '@/stores/authStore';
|
||||
import { useCanAdmin } from '@/stores/companyStore';
|
||||
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';
|
||||
|
|
@ -59,8 +58,8 @@ export default function Admin() {
|
|||
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();
|
||||
// Check if user has Owner role for the active company
|
||||
const isAdmin = useCanAdmin();
|
||||
|
||||
// Fetch available read model types
|
||||
const { data: readModelTypes, isLoading: typesLoading } = useQuery({
|
||||
|
|
|
|||
|
|
@ -38,8 +38,10 @@ import { usePendingBankTransactions } from '@/api/queries/bankTransactionQueries
|
|||
import { formatCurrency, formatDate } from '@/lib/formatters';
|
||||
import { accountingColors } from '@/styles/theme';
|
||||
import type { BankTransaction } from '@/types/accounting';
|
||||
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// Type for ledger entries (API not implemented yet)
|
||||
|
|
@ -160,7 +162,7 @@ export default function Bankafstemning() {
|
|||
ledgerTransactionId: ledgerEntry.id,
|
||||
matchType: 'existing',
|
||||
});
|
||||
message.success('Match tilfojet');
|
||||
message.success('Match tilføjet');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -177,8 +179,9 @@ export default function Bankafstemning() {
|
|||
const handleSubmitCreate = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
console.log('Creating entry:', values);
|
||||
|
||||
// TODO: Backend mutation for creating a journal entry from bank transaction is needed.
|
||||
// This should create a JournalEntryDraft and then post it, linking it to the bank transaction.
|
||||
if (selectedBankTx) {
|
||||
addPendingMatch({
|
||||
bankTransactionId: selectedBankTx.id,
|
||||
|
|
@ -190,11 +193,13 @@ export default function Bankafstemning() {
|
|||
});
|
||||
}
|
||||
|
||||
message.success('Postering oprettet og matchet');
|
||||
message.success('Postering tilfojet til afventende matches');
|
||||
setIsCreateModalOpen(false);
|
||||
setSelectedBankTx(null);
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -203,9 +208,10 @@ export default function Bankafstemning() {
|
|||
message.warning('Ingen matches at gemme');
|
||||
return;
|
||||
}
|
||||
// TODO: Send to GraphQL mutation
|
||||
console.log('Saving matches:', pendingMatches);
|
||||
message.success(`${pendingMatches.length} afstemninger gemt`);
|
||||
// TODO: Backend mutation for saving reconciliation matches is not yet implemented.
|
||||
// The mutation should accept a list of bank transaction IDs matched to ledger entries,
|
||||
// mark them as reconciled, and create journal entries for new transactions.
|
||||
message.info('Denne funktion er under udvikling. Afstemninger kan endnu ikke gemmes til backend.');
|
||||
};
|
||||
|
||||
const handleApplySuggestion = (suggestion: MatchSuggestion) => {
|
||||
|
|
@ -241,42 +247,35 @@ export default function Bankafstemning() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Bankafstemning
|
||||
</Title>
|
||||
<Text type="secondary">{company?.name}</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<UndoOutlined />}
|
||||
onClick={clearAllSelections}
|
||||
disabled={
|
||||
selectedBankTransactions.length === 0 &&
|
||||
selectedLedgerTransactions.length === 0
|
||||
}
|
||||
>
|
||||
Nulstil valg
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={handleSaveAll}
|
||||
disabled={pendingMatches.length === 0}
|
||||
>
|
||||
Gem afstemninger ({pendingMatches.length})
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Bankafstemning"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Bank', path: '/bankafstemning' }, { title: 'Bankafstemning' }]}
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<UndoOutlined />}
|
||||
onClick={clearAllSelections}
|
||||
disabled={
|
||||
selectedBankTransactions.length === 0 &&
|
||||
selectedLedgerTransactions.length === 0
|
||||
}
|
||||
>
|
||||
Nulstil valg
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={handleSaveAll}
|
||||
disabled={pendingMatches.length === 0}
|
||||
>
|
||||
Gem afstemninger ({pendingMatches.length})
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<DemoDataDisclaimer message="Bankafstemning er delvist implementeret. Gem-funktionen er under udvikling." />
|
||||
|
||||
{/* Filters */}
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
|
|
@ -314,7 +313,7 @@ export default function Bankafstemning() {
|
|||
<Col span={8}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Bogforing (uafstemt)"
|
||||
title="Bogføring (uafstemt)"
|
||||
value={ledgerTotal}
|
||||
precision={2}
|
||||
formatter={(value) => formatCurrency(value as number)}
|
||||
|
|
@ -374,7 +373,7 @@ export default function Bankafstemning() {
|
|||
disabled={!canMatch}
|
||||
>
|
||||
Match valgte ({selectedBankTransactions.length} bank,{' '}
|
||||
{selectedLedgerTransactions.length} bogforing)
|
||||
{selectedLedgerTransactions.length} bogføring)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -496,7 +495,7 @@ export default function Bankafstemning() {
|
|||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>Bogforingsposter</Text>
|
||||
<Text strong>Bogføringsposter</Text>
|
||||
<Tag color="orange">{ledgerEntries.length} uafstemte</Tag>
|
||||
</Space>
|
||||
}
|
||||
|
|
@ -505,7 +504,7 @@ export default function Bankafstemning() {
|
|||
>
|
||||
{ledgerEntries.length === 0 ? (
|
||||
<Empty
|
||||
description="Ingen uafstemte bogforingsposter (API ikke implementeret endnu)"
|
||||
description="Ingen uafstemte bogføringsposter (API ikke implementeret endnu)"
|
||||
style={{ padding: 24 }}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -674,22 +673,22 @@ export default function Bankafstemning() {
|
|||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Vaelg konto"
|
||||
placeholder="Vælg konto"
|
||||
options={[
|
||||
{ value: '6100', label: '6100 - Husleje' },
|
||||
{ value: '6800', label: '6800 - Kontorartikler' },
|
||||
{ value: '5000', label: '5000 - Varekob' },
|
||||
{ value: '5000', label: '5000 - Varekøb' },
|
||||
{ value: '4000', label: '4000 - Salg' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="vatCode" label="Momskode">
|
||||
<Select
|
||||
placeholder="Vaelg momskode"
|
||||
placeholder="Vælg momskode"
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'K25', label: 'K25 - Indgaaende moms 25%' },
|
||||
{ value: 'S25', label: 'S25 - Udgaaende moms 25%' },
|
||||
{ value: 'K25', label: 'K25 - Indgående moms 25%' },
|
||||
{ value: 'S25', label: 'S25 - Udgående moms 25%' },
|
||||
{ value: 'NONE', label: 'Ingen moms' },
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { Row, Col, Card, Statistic, Typography, Space, Tag, Progress, Skeleton, Empty } from 'antd';
|
||||
import {
|
||||
BankOutlined,
|
||||
RiseOutlined,
|
||||
FallOutlined,
|
||||
FileTextOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
|
|
@ -18,8 +16,10 @@ import { useInvoices } from '@/api/queries/invoiceQueries';
|
|||
import { useVatReport } from '@/api/queries/vatQueries';
|
||||
import { formatCurrency, formatDate } from '@/lib/formatters';
|
||||
import { accountingColors } from '@/styles/theme';
|
||||
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
|
||||
// Types for chart data
|
||||
interface CashFlowDataPoint {
|
||||
|
|
@ -47,9 +47,13 @@ export default function Dashboard() {
|
|||
const { activeCompany } = useCompanyStore();
|
||||
const { currentFiscalYear } = usePeriodStore();
|
||||
|
||||
// Define date interval
|
||||
const periodStart = currentFiscalYear?.startDate || dayjs().startOf('year').format('YYYY-MM-DD');
|
||||
const periodEnd = currentFiscalYear?.endDate || dayjs().endOf('year').format('YYYY-MM-DD');
|
||||
// Define date interval - always format as YYYY-MM-DD for GraphQL DateOnly type
|
||||
const periodStart = currentFiscalYear?.startDate
|
||||
? dayjs(currentFiscalYear.startDate).format('YYYY-MM-DD')
|
||||
: dayjs().startOf('year').format('YYYY-MM-DD');
|
||||
const periodEnd = currentFiscalYear?.endDate
|
||||
? dayjs(currentFiscalYear.endDate).format('YYYY-MM-DD')
|
||||
: dayjs().endOf('year').format('YYYY-MM-DD');
|
||||
|
||||
const { data: balances = [], isLoading: balancesLoading } = useAccountBalances(
|
||||
activeCompany?.id,
|
||||
|
|
@ -207,7 +211,7 @@ export default function Dashboard() {
|
|||
|
||||
const revenueExpenseConfig = {
|
||||
data: cashFlowData.flatMap((d) => [
|
||||
{ month: d.month, type: 'Indtaegter', value: d.inflow },
|
||||
{ month: d.month, type: 'Indtægter', value: d.inflow },
|
||||
{ month: d.month, type: 'Udgifter', value: d.outflow },
|
||||
]),
|
||||
isGroup: true,
|
||||
|
|
@ -225,15 +229,13 @@ export default function Dashboard() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Dashboard
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
{company?.name} - {formatDate(new Date().toISOString(), 'MMMM YYYY')}
|
||||
</Text>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle={company?.name ? `${company.name} - ${formatDate(new Date().toISOString(), 'MMMM YYYY')}` : undefined}
|
||||
breadcrumbs={[{ title: 'Dashboard' }]}
|
||||
/>
|
||||
|
||||
<DemoDataDisclaimer message="Dashboard viser beregnede data fra kontoplanen. Pengestrøms- og udgiftsgrafer er endnu ikke tilgængelige." />
|
||||
|
||||
{/* KPI Cards */}
|
||||
<Row gutter={[16, 16]}>
|
||||
|
|
@ -249,13 +251,9 @@ export default function Dashboard() {
|
|||
formatter={(value) => formatCurrency(value as number)}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Tag
|
||||
color={metrics.cashChange >= 0 ? 'green' : 'red'}
|
||||
icon={metrics.cashChange >= 0 ? <RiseOutlined /> : <FallOutlined />}
|
||||
>
|
||||
{metrics.cashChange >= 0 ? '+' : ''}
|
||||
{(metrics.cashChange * 100).toFixed(1)}% denne maaned
|
||||
</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Baseret på kontosaldi i regnskabsåret
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
@ -296,10 +294,9 @@ export default function Dashboard() {
|
|||
formatter={(value) => formatCurrency(value as number)}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Tag color={metrics.apChange >= 0 ? 'orange' : 'green'}>
|
||||
{metrics.apChange >= 0 ? '+' : ''}
|
||||
{(metrics.apChange * 100).toFixed(1)}% denne maaned
|
||||
</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Baseret på kontosaldi i regnskabsåret
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
@ -315,32 +312,33 @@ export default function Dashboard() {
|
|||
formatter={(value) => formatCurrency(value as number)}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Tag color="blue">Naeste frist: 1. marts</Tag>
|
||||
<a href="/momsindberetning">Se momsindberetning</a>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
{/* Charts Row */}
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
{/* Cash Flow Chart */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="Pengestroemme" size="small">
|
||||
<Card title="Pengestrømme" size="small">
|
||||
{cashFlowData.length > 0 ? (
|
||||
<Line {...cashFlowConfig} />
|
||||
) : (
|
||||
<Empty description="Ingen pengestroemsdata tilgaengelig endnu" style={{ height: 200 }} />
|
||||
<Empty description="Ingen pengestrømsdata tilgængelig endnu" style={{ height: 200 }} />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Revenue vs Expenses */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="Indtaegter vs. Udgifter" size="small">
|
||||
<Card title="Indtægter vs. Udgifter" size="small">
|
||||
{cashFlowData.length > 0 ? (
|
||||
<Column {...revenueExpenseConfig} />
|
||||
) : (
|
||||
<Empty description="Ingen historiske data tilgaengelig endnu" style={{ height: 200 }} />
|
||||
<Empty description="Ingen historiske data tilgængelig endnu" style={{ height: 200 }} />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
@ -354,7 +352,7 @@ export default function Dashboard() {
|
|||
{expenseBreakdown.length > 0 ? (
|
||||
<Pie {...expenseConfig} />
|
||||
) : (
|
||||
<Empty description="Ingen udgiftsdata tilgaengelig" style={{ height: 200 }} />
|
||||
<Empty description="Ingen udgiftsdata tilgængelig" style={{ height: 200 }} />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
@ -440,7 +438,9 @@ export default function Dashboard() {
|
|||
<Col>
|
||||
<Space>
|
||||
<ClockCircleOutlined style={{ color: accountingColors.warning }} />
|
||||
<Text>Momsindberetning forfalder om 14 dage</Text>
|
||||
<a href="/momsindberetning">
|
||||
<Text>Se momsindberetning</Text>
|
||||
</a>
|
||||
</Space>
|
||||
</Col>
|
||||
{metrics.overdueInvoices > 0 && (
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import { formatCurrency, formatDate } from '@/lib/formatters';
|
|||
import { spacing } from '@/styles/designTokens';
|
||||
import { accountingColors } from '@/styles/theme';
|
||||
import { AmountText } from '@/components/shared/AmountText';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
|
|
@ -457,25 +458,16 @@ export default function Fakturaer() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Fakturaer
|
||||
</Title>
|
||||
<Text type="secondary">{company?.name}</Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateInvoice}>
|
||||
Ny fakturakladde
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Fakturaer"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Fakturaer' }]}
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateInvoice}>
|
||||
Ny fakturakladde
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
|
|
|
|||
|
|
@ -32,10 +32,13 @@ import { useCompanyStore } from '@/stores/companyStore';
|
|||
import { useActiveAccounts } from '@/api/queries/accountQueries';
|
||||
import { useJournalEntryDrafts } from '@/api/queries/draftQueries';
|
||||
import { formatCurrency } from '@/lib/formatters';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
import { validateDoubleEntry } from '@/lib/accounting';
|
||||
import type { TransactionLine, JournalEntryDraft } from '@/types/accounting';
|
||||
import { useCreateJournalEntryDraft, useUpdateJournalEntryDraft, useDiscardJournalEntryDraft } from '@/api/mutations/draftMutations';
|
||||
import { usePeriodStore } from '@/stores/periodStore';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// Display type for journal entry drafts
|
||||
|
|
@ -62,6 +65,13 @@ export default function Kassekladde() {
|
|||
{ debit: 0, credit: 0 },
|
||||
]);
|
||||
|
||||
const { currentFiscalYear } = usePeriodStore();
|
||||
|
||||
// Mutation hooks
|
||||
const createDraftMutation = useCreateJournalEntryDraft();
|
||||
const updateDraftMutation = useUpdateJournalEntryDraft();
|
||||
const discardDraftMutation = useDiscardJournalEntryDraft();
|
||||
|
||||
// Fetch accounts and drafts from API
|
||||
const { data: accounts = [], isLoading: accountsLoading } = useActiveAccounts(activeCompany?.id);
|
||||
const { data: drafts = [], isLoading: draftsLoading } = useJournalEntryDrafts(activeCompany?.id);
|
||||
|
|
@ -124,7 +134,7 @@ export default function Kassekladde() {
|
|||
return <Tag color="red">Annulleret</Tag>;
|
||||
}
|
||||
return value ? (
|
||||
<Tag color="green">Bogfort</Tag>
|
||||
<Tag color="green">Bogført</Tag>
|
||||
) : (
|
||||
<Tag color="orange">Kladde</Tag>
|
||||
);
|
||||
|
|
@ -189,17 +199,56 @@ export default function Kassekladde() {
|
|||
setIsModalOpen(true);
|
||||
break;
|
||||
case 'copy':
|
||||
message.success(`Bilag ${record.transactionNumber} kopieret`);
|
||||
if (!activeCompany) {
|
||||
message.error('Ingen virksomhed valgt');
|
||||
break;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const draft = await createDraftMutation.mutateAsync({
|
||||
companyId: activeCompany.id,
|
||||
name: `Kopi af ${record.description}`,
|
||||
description: record.description,
|
||||
fiscalYearId: currentFiscalYear?.id,
|
||||
});
|
||||
// Copy lines to the new draft
|
||||
if (record.lines && record.lines.length > 0) {
|
||||
await updateDraftMutation.mutateAsync({
|
||||
id: draft.id,
|
||||
lines: record.lines.map((l, idx) => ({
|
||||
lineNumber: idx + 1,
|
||||
accountId: l.accountId,
|
||||
debitAmount: l.debitAmount || 0,
|
||||
creditAmount: l.creditAmount || 0,
|
||||
description: l.description,
|
||||
vatCode: l.vatCode,
|
||||
})),
|
||||
});
|
||||
}
|
||||
message.success(`Bilag ${record.transactionNumber} kopieret`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl ved kopiering: ${error.message}`);
|
||||
}
|
||||
}
|
||||
})();
|
||||
break;
|
||||
case 'void':
|
||||
Modal.confirm({
|
||||
title: 'Annuller bilag',
|
||||
content: `Er du sikker pa at du vil annullere bilag ${record.transactionNumber}?`,
|
||||
content: `Er du sikker på at du vil annullere bilag ${record.transactionNumber}?`,
|
||||
okText: 'Annuller bilag',
|
||||
okType: 'danger',
|
||||
cancelText: 'Fortryd',
|
||||
onOk: () => {
|
||||
message.success(`Bilag ${record.transactionNumber} annulleret`);
|
||||
onOk: async () => {
|
||||
try {
|
||||
await discardDraftMutation.mutateAsync(record.id);
|
||||
message.success(`Bilag ${record.transactionNumber} annulleret`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl ved annullering: ${error.message}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
|
@ -238,18 +287,72 @@ export default function Kassekladde() {
|
|||
const validation = validateDoubleEntry(lines as TransactionLine[]);
|
||||
if (!validation.valid) {
|
||||
message.error(
|
||||
`Debet (${formatCurrency(validation.totalDebit)}) skal vaere lig kredit (${formatCurrency(validation.totalCredit)})`
|
||||
`Debet (${formatCurrency(validation.totalDebit)}) skal være lig kredit (${formatCurrency(validation.totalCredit)})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Submitting:', { ...values, lines });
|
||||
message.success('Bilag oprettet');
|
||||
if (!activeCompany) {
|
||||
message.error('Ingen virksomhed valgt');
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingDraft) {
|
||||
// Update existing draft
|
||||
await updateDraftMutation.mutateAsync({
|
||||
id: editingDraft.id,
|
||||
name: values.description,
|
||||
documentDate: values.date?.format('YYYY-MM-DD'),
|
||||
description: values.description,
|
||||
fiscalYearId: currentFiscalYear?.id,
|
||||
lines: lines
|
||||
.filter(l => l.accountId)
|
||||
.map((l, idx) => ({
|
||||
lineNumber: idx + 1,
|
||||
accountId: l.accountId!,
|
||||
debitAmount: l.debit || 0,
|
||||
creditAmount: l.credit || 0,
|
||||
description: l.description,
|
||||
vatCode: l.vatCode,
|
||||
})),
|
||||
});
|
||||
message.success('Bilag opdateret');
|
||||
} else {
|
||||
// Create new draft
|
||||
const draft = await createDraftMutation.mutateAsync({
|
||||
companyId: activeCompany.id,
|
||||
name: values.description,
|
||||
documentDate: values.date?.format('YYYY-MM-DD'),
|
||||
description: values.description,
|
||||
fiscalYearId: currentFiscalYear?.id,
|
||||
});
|
||||
|
||||
// Update the draft with lines
|
||||
if (lines.some(l => l.accountId)) {
|
||||
await updateDraftMutation.mutateAsync({
|
||||
id: draft.id,
|
||||
lines: lines
|
||||
.filter(l => l.accountId)
|
||||
.map((l, idx) => ({
|
||||
lineNumber: idx + 1,
|
||||
accountId: l.accountId!,
|
||||
debitAmount: l.debit || 0,
|
||||
creditAmount: l.credit || 0,
|
||||
description: l.description,
|
||||
vatCode: l.vatCode,
|
||||
})),
|
||||
});
|
||||
}
|
||||
message.success('Bilag oprettet');
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -258,21 +361,11 @@ export default function Kassekladde() {
|
|||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Kassekladde
|
||||
</Title>
|
||||
<Text type="secondary">{activeCompany?.name}</Text>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Kassekladde"
|
||||
subtitle={activeCompany?.name}
|
||||
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
|
||||
/>
|
||||
<Skeleton active paragraph={{ rows: 10 }} />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -280,32 +373,23 @@ export default function Kassekladde() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Kassekladde
|
||||
</Title>
|
||||
<Text type="secondary">{activeCompany?.name}</Text>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingDraft(null);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Nyt bilag
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Kassekladde"
|
||||
subtitle={activeCompany?.name}
|
||||
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingDraft(null);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Nyt bilag
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
|
|
@ -329,7 +413,7 @@ export default function Kassekladde() {
|
|||
style={{ width: 120 }}
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'posted', label: 'Bogfort' },
|
||||
{ value: 'posted', label: 'Bogført' },
|
||||
{ value: 'draft', label: 'Kladde' },
|
||||
{ value: 'discarded', label: 'Annulleret' },
|
||||
]}
|
||||
|
|
@ -373,7 +457,7 @@ export default function Kassekladde() {
|
|||
<Form.Item
|
||||
name="date"
|
||||
label="Dato"
|
||||
rules={[{ required: true, message: 'Vaelg dato' }]}
|
||||
rules={[{ required: true, message: 'Vælg dato' }]}
|
||||
initialValue={dayjs()}
|
||||
>
|
||||
<DatePicker format="DD/MM/YYYY" style={{ width: 150 }} />
|
||||
|
|
@ -409,7 +493,7 @@ export default function Kassekladde() {
|
|||
<td style={{ padding: 4 }}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Vaelg konto"
|
||||
placeholder="Vælg konto"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
value={line.accountId}
|
||||
|
|
@ -478,7 +562,7 @@ export default function Kassekladde() {
|
|||
<tr style={{ borderTop: '2px solid #f0f0f0' }}>
|
||||
<td style={{ padding: 8 }}>
|
||||
<Button type="dashed" size="small" onClick={handleAddLine}>
|
||||
+ Tilfoej linje
|
||||
+ Tilføj linje
|
||||
</Button>
|
||||
</td>
|
||||
<td
|
||||
|
|
|
|||
|
|
@ -1,46 +1,45 @@
|
|||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Tree,
|
||||
Table,
|
||||
Space,
|
||||
Tag,
|
||||
Modal,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Tabs,
|
||||
Statistic,
|
||||
message,
|
||||
Grid,
|
||||
Skeleton,
|
||||
Empty,
|
||||
Switch,
|
||||
Divider,
|
||||
Descriptions,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
FolderOutlined,
|
||||
FileOutlined,
|
||||
SearchOutlined,
|
||||
MoreOutlined,
|
||||
HistoryOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCompanyStore } from '@/stores/companyStore';
|
||||
import { usePeriodStore } from '@/stores/periodStore';
|
||||
import { useAccounts, useAccountBalances } from '@/api/queries/accountQueries';
|
||||
import { formatCurrency } from '@/lib/formatters';
|
||||
import { getAccountTypeName, getAccountNumberRange } from '@/lib/accounting';
|
||||
import { getAccountTypeName } from '@/lib/accounting';
|
||||
import { accountingColors } from '@/styles/theme';
|
||||
import { spacing } from '@/styles/designTokens';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import type { Account, AccountType } from '@/types/accounting';
|
||||
import { useCreateAccount } from '@/api/mutations/accountMutations';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
const accountTypes: AccountType[] = [
|
||||
'asset',
|
||||
|
|
@ -54,17 +53,23 @@ const accountTypes: AccountType[] = [
|
|||
'extraordinary',
|
||||
];
|
||||
|
||||
interface AccountWithBalance extends Account {
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export default function Kontooversigt() {
|
||||
const navigate = useNavigate();
|
||||
const { activeCompany } = useCompanyStore();
|
||||
const { currentFiscalYear } = usePeriodStore();
|
||||
const screens = useBreakpoint();
|
||||
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
|
||||
const [selectedAccount, setSelectedAccount] = useState<AccountWithBalance | null>(null);
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const isMobile = !screens.md;
|
||||
// Mutation hooks
|
||||
const createAccountMutation = useCreateAccount();
|
||||
|
||||
// Fetch accounts and balances from API
|
||||
const { data: accounts = [], isLoading: accountsLoading } = useAccounts(activeCompany?.id);
|
||||
|
|
@ -78,161 +83,176 @@ export default function Kontooversigt() {
|
|||
|
||||
const isLoading = accountsLoading || balancesLoading;
|
||||
|
||||
// Combine accounts with balances
|
||||
const accountsWithBalances = accounts.map(acc => {
|
||||
const balance = balances.find(b => b.id === acc.id);
|
||||
return { ...acc, balance: balance?.netChange ?? 0 };
|
||||
});
|
||||
|
||||
// Build tree data from accounts
|
||||
const buildTreeData = (): DataNode[] => {
|
||||
return accountTypes.map((type) => {
|
||||
const range = getAccountNumberRange(type);
|
||||
const typeAccounts = accountsWithBalances.filter((acc) => acc.type === type);
|
||||
const typeBalance = typeAccounts.reduce((sum, acc) => sum + acc.balance, 0);
|
||||
|
||||
return {
|
||||
key: type,
|
||||
title: (
|
||||
<Space>
|
||||
<Text strong>{getAccountTypeName(type)}</Text>
|
||||
<Text type="secondary">({range.min}-{range.max})</Text>
|
||||
<Text
|
||||
className="tabular-nums"
|
||||
style={{
|
||||
color: typeBalance >= 0 ? accountingColors.credit : accountingColors.debit,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(Math.abs(typeBalance))}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
icon: <FolderOutlined />,
|
||||
children: typeAccounts
|
||||
.filter((acc) =>
|
||||
searchText === '' ||
|
||||
acc.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
acc.accountNumber.includes(searchText)
|
||||
)
|
||||
.map((acc) => ({
|
||||
key: acc.id,
|
||||
title: (
|
||||
<Space>
|
||||
<Text code>{acc.accountNumber}</Text>
|
||||
<Text>{acc.name}</Text>
|
||||
{!acc.isActive && <Tag color="red">Inaktiv</Tag>}
|
||||
<Text
|
||||
className="tabular-nums"
|
||||
style={{
|
||||
color: acc.balance >= 0 ? accountingColors.credit : accountingColors.debit,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(Math.abs(acc.balance))}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
icon: <FileOutlined />,
|
||||
isLeaf: true,
|
||||
})),
|
||||
};
|
||||
// Combine accounts with balances and filter
|
||||
const tableData = useMemo(() => {
|
||||
const combined = accounts.map(acc => {
|
||||
const balance = balances.find(b => b.id === acc.id);
|
||||
return { ...acc, balance: balance?.netChange ?? 0 };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAccount = (selectedKeys: React.Key[]) => {
|
||||
const key = selectedKeys[0];
|
||||
if (key && !accountTypes.includes(key as AccountType)) {
|
||||
const account = accountsWithBalances.find((acc) => acc.id === key);
|
||||
setSelectedAccount(account || null);
|
||||
}
|
||||
return combined
|
||||
.filter(acc => showInactive || acc.isActive)
|
||||
.filter(acc =>
|
||||
searchText === '' ||
|
||||
acc.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
acc.accountNumber.includes(searchText)
|
||||
)
|
||||
.sort((a, b) => a.accountNumber.localeCompare(b.accountNumber));
|
||||
}, [accounts, balances, searchText, showInactive]);
|
||||
|
||||
// Calculate totals for KPI cards
|
||||
const kpiData = useMemo(() => {
|
||||
const data = accounts.map(acc => {
|
||||
const balance = balances.find(b => b.id === acc.id);
|
||||
return { ...acc, balance: balance?.netChange ?? 0 };
|
||||
});
|
||||
|
||||
return {
|
||||
assets: data.filter(a => a.type === 'asset').reduce((sum, a) => sum + a.balance, 0),
|
||||
liabilities: data.filter(a => ['liability', 'equity'].includes(a.type)).reduce((sum, a) => sum + Math.abs(a.balance), 0),
|
||||
revenue: data.filter(a => a.type === 'revenue').reduce((sum, a) => sum + Math.abs(a.balance), 0),
|
||||
expenses: data.filter(a => ['cogs', 'expense', 'personnel', 'financial'].includes(a.type)).reduce((sum, a) => sum + a.balance, 0),
|
||||
};
|
||||
}, [accounts, balances]);
|
||||
|
||||
const handleRowClick = (record: AccountWithBalance) => {
|
||||
setSelectedAccount(record);
|
||||
setIsEditMode(false);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateAccount = () => {
|
||||
setEditingAccount(null);
|
||||
setSelectedAccount(null);
|
||||
form.resetFields();
|
||||
setIsModalOpen(true);
|
||||
setIsEditMode(true);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleEditAccount = (account: Account) => {
|
||||
setEditingAccount(account);
|
||||
form.setFieldsValue(account);
|
||||
setIsModalOpen(true);
|
||||
const handleEditAccount = () => {
|
||||
if (selectedAccount) {
|
||||
form.setFieldsValue(selectedAccount);
|
||||
setIsEditMode(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseDrawer = () => {
|
||||
setIsDrawerOpen(false);
|
||||
setIsEditMode(false);
|
||||
setSelectedAccount(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
console.log('Submitting account:', values);
|
||||
message.success(editingAccount ? 'Konto opdateret' : 'Konto oprettet');
|
||||
setIsModalOpen(false);
|
||||
|
||||
if (!activeCompany) {
|
||||
message.error('Ingen virksomhed valgt');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedAccount) {
|
||||
// TODO: Backend does not yet have an updateAccount mutation.
|
||||
// For now, show a message indicating this is not yet supported.
|
||||
message.warning('Redigering af konti er endnu ikke understottet i backend');
|
||||
} else {
|
||||
// Create new account
|
||||
await createAccountMutation.mutateAsync({
|
||||
companyId: activeCompany.id,
|
||||
accountNumber: values.accountNumber,
|
||||
name: values.name,
|
||||
accountType: values.type,
|
||||
description: values.description,
|
||||
vatCodeId: values.vatCode,
|
||||
});
|
||||
message.success('Konto oprettet');
|
||||
}
|
||||
|
||||
handleCloseDrawer();
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate totals from actual data
|
||||
const totalAssets = accountsWithBalances
|
||||
.filter((a) => a.type === 'asset')
|
||||
.reduce((sum, a) => sum + a.balance, 0);
|
||||
const totalLiabilities = accountsWithBalances
|
||||
.filter((a) => ['liability', 'equity'].includes(a.type))
|
||||
.reduce((sum, a) => sum + Math.abs(a.balance), 0);
|
||||
const totalRevenue = accountsWithBalances
|
||||
.filter((a) => a.type === 'revenue')
|
||||
.reduce((sum, a) => sum + Math.abs(a.balance), 0);
|
||||
const totalExpenses = accountsWithBalances
|
||||
.filter((a) => ['cogs', 'expense', 'personnel', 'financial'].includes(a.type))
|
||||
.reduce((sum, a) => sum + a.balance, 0);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Kontooversigt"
|
||||
subtitle={activeCompany?.name}
|
||||
breadcrumbs={[
|
||||
{ title: 'Bogforing', path: '/bogforing' },
|
||||
{ title: 'Kontooversigt' },
|
||||
]}
|
||||
const columns: ColumnsType<AccountWithBalance> = [
|
||||
{
|
||||
title: 'Nr.',
|
||||
dataIndex: 'accountNumber',
|
||||
key: 'accountNumber',
|
||||
width: 100,
|
||||
render: (text) => <Text code>{text}</Text>,
|
||||
sorter: (a, b) => a.accountNumber.localeCompare(b.accountNumber),
|
||||
},
|
||||
{
|
||||
title: 'Navn',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<Text strong>{text}</Text>
|
||||
{!record.isActive && <Tag color="default" bordered={false}>Inaktiv</Tag>}
|
||||
</Space>
|
||||
),
|
||||
sorter: (a, b) => a.name.localeCompare(b.name),
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 150,
|
||||
filters: accountTypes.map(type => ({ text: getAccountTypeName(type), value: type })),
|
||||
onFilter: (value, record) => record.type === value,
|
||||
render: (type) => <Tag>{getAccountTypeName(type)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Moms',
|
||||
dataIndex: 'vatCode',
|
||||
key: 'vatCode',
|
||||
width: 100,
|
||||
render: (code) => code ? <Tag color="blue">{code}</Tag> : <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: 'Saldo',
|
||||
dataIndex: 'balance',
|
||||
key: 'balance',
|
||||
align: 'right',
|
||||
width: 150,
|
||||
render: (value) => (
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
color: value >= 0 ? accountingColors.credit : accountingColors.debit,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(value)}
|
||||
</Text>
|
||||
),
|
||||
sorter: (a, b) => a.balance - b.balance,
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
width: 50,
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MoreOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRowClick(record);
|
||||
}}
|
||||
/>
|
||||
<Skeleton active paragraph={{ rows: 10 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Kontooversigt"
|
||||
subtitle={activeCompany?.name}
|
||||
breadcrumbs={[
|
||||
{ title: 'Bogforing', path: '/bogforing' },
|
||||
{ title: 'Kontooversigt' },
|
||||
]}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreateAccount}
|
||||
aria-label="Opret ny konto"
|
||||
>
|
||||
Ny konto
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Empty description="Ingen konti fundet. Opret en ny konto for at komme i gang." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="Kontooversigt"
|
||||
subtitle={activeCompany?.name}
|
||||
breadcrumbs={[
|
||||
{ title: 'Bogforing', path: '/bogforing' },
|
||||
{ title: 'Bogføring', path: '/bogforing' },
|
||||
{ title: 'Kontooversigt' },
|
||||
]}
|
||||
extra={
|
||||
|
|
@ -240,280 +260,262 @@ export default function Kontooversigt() {
|
|||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreateAccount}
|
||||
aria-label="Opret ny konto"
|
||||
>
|
||||
Ny konto
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<Row gutter={[spacing.lg, spacing.lg]} style={{ marginBottom: spacing.lg }}>
|
||||
{/* KPI Cards */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small" role="region" aria-label="Aktiver total">
|
||||
<Card bordered={false} size="small" style={{ borderTop: `3px solid ${accountingColors.credit}` }}>
|
||||
<Statistic
|
||||
title="Aktiver"
|
||||
value={totalAssets}
|
||||
precision={2}
|
||||
suffix="kr."
|
||||
value={kpiData.assets}
|
||||
precision={0}
|
||||
valueStyle={{ color: accountingColors.credit }}
|
||||
formatter={(value) => formatCurrency(value as number)}
|
||||
formatter={(val) => formatCurrency(val as number)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small" role="region" aria-label="Passiver total">
|
||||
<Card bordered={false} size="small" style={{ borderTop: `3px solid ${accountingColors.debit}` }}>
|
||||
<Statistic
|
||||
title="Passiver"
|
||||
value={totalLiabilities}
|
||||
precision={2}
|
||||
suffix="kr."
|
||||
value={kpiData.liabilities}
|
||||
precision={0}
|
||||
valueStyle={{ color: accountingColors.debit }}
|
||||
formatter={(value) => formatCurrency(value as number)}
|
||||
formatter={(val) => formatCurrency(val as number)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small" role="region" aria-label="Omsaetning total">
|
||||
<Card bordered={false} size="small" style={{ borderTop: `3px solid ${accountingColors.credit}` }}>
|
||||
<Statistic
|
||||
title="Omsaetning"
|
||||
value={totalRevenue}
|
||||
precision={2}
|
||||
suffix="kr."
|
||||
title="Omsætning"
|
||||
value={kpiData.revenue}
|
||||
precision={0}
|
||||
valueStyle={{ color: accountingColors.credit }}
|
||||
formatter={(value) => formatCurrency(value as number)}
|
||||
formatter={(val) => formatCurrency(val as number)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small" role="region" aria-label="Omkostninger total">
|
||||
<Card bordered={false} size="small" style={{ borderTop: `3px solid ${accountingColors.debit}` }}>
|
||||
<Statistic
|
||||
title="Omkostninger"
|
||||
value={totalExpenses}
|
||||
precision={2}
|
||||
suffix="kr."
|
||||
value={kpiData.expenses}
|
||||
precision={0}
|
||||
valueStyle={{ color: accountingColors.debit }}
|
||||
formatter={(value) => formatCurrency(value as number)}
|
||||
formatter={(val) => formatCurrency(val as number)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Main Content */}
|
||||
<Row gutter={spacing.lg}>
|
||||
{/* Account Tree */}
|
||||
<Col xs={24} lg={10}>
|
||||
<Card title="Kontoplan" size="small">
|
||||
{/* Search moved outside extra for better mobile UX */}
|
||||
<Input
|
||||
placeholder="Sog efter konto eller kontonummer..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ marginBottom: spacing.md }}
|
||||
allowClear
|
||||
aria-label="Sog i kontoplan"
|
||||
/>
|
||||
<Tree
|
||||
showIcon
|
||||
defaultExpandAll
|
||||
treeData={buildTreeData()}
|
||||
onSelect={handleSelectAccount}
|
||||
selectedKeys={selectedAccount ? [selectedAccount.id] : []}
|
||||
style={{
|
||||
maxHeight: isMobile ? 300 : 450,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
aria-label="Kontoplan hierarki"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Account Details */}
|
||||
<Col xs={24} lg={14}>
|
||||
{selectedAccount ? (
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Text code>{selectedAccount.accountNumber}</Text>
|
||||
<Text strong>{selectedAccount.name}</Text>
|
||||
{!selectedAccount.isActive && (
|
||||
<Tag color="red">Inaktiv</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
extra={
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEditAccount(selectedAccount)}
|
||||
aria-label={`Rediger konto ${selectedAccount.accountNumber}`}
|
||||
>
|
||||
Rediger
|
||||
</Button>
|
||||
}
|
||||
role="region"
|
||||
aria-label={`Detaljer for konto ${selectedAccount.accountNumber}`}
|
||||
>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'transactions',
|
||||
label: 'Bevaegelser',
|
||||
children: (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Statistic
|
||||
title="Saldo"
|
||||
value={selectedAccount.balance}
|
||||
precision={2}
|
||||
formatter={(value) => formatCurrency(value as number)}
|
||||
valueStyle={{
|
||||
color:
|
||||
selectedAccount.balance >= 0
|
||||
? accountingColors.credit
|
||||
: accountingColors.debit,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Empty description="Ingen bevaegelser" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'info',
|
||||
label: 'Kontooplysninger',
|
||||
children: (
|
||||
<div>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={12}>
|
||||
<Text type="secondary">Kontonummer</Text>
|
||||
<div>
|
||||
<Text strong>{selectedAccount.accountNumber}</Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text type="secondary">Kontotype</Text>
|
||||
<div>
|
||||
<Tag>{getAccountTypeName(selectedAccount.type)}</Tag>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text type="secondary">Status</Text>
|
||||
<div>
|
||||
{selectedAccount.isActive ? (
|
||||
<Tag color="green">Aktiv</Tag>
|
||||
) : (
|
||||
<Tag color="red">Inaktiv</Tag>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text type="secondary">Momskode</Text>
|
||||
<div>
|
||||
<Text>{selectedAccount.vatCode || 'Ingen'}</Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<Card size="small">
|
||||
<EmptyState
|
||||
variant="accounts"
|
||||
icon={<FileOutlined style={{ fontSize: 48 }} />}
|
||||
title="Ingen konto valgt"
|
||||
description="Vaelg en konto i kontoplanen til venstre for at se detaljer og bevaegelser."
|
||||
compact
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Create/Edit Account Modal */}
|
||||
<Modal
|
||||
title={editingAccount ? 'Rediger konto' : 'Opret konto'}
|
||||
open={isModalOpen}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
onOk={handleSubmit}
|
||||
okText="Gem"
|
||||
cancelText="Annuller"
|
||||
<Card
|
||||
bordered={false}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
title={
|
||||
<Input
|
||||
prefix={<SearchOutlined className="text-gray-400" />}
|
||||
placeholder="Søg på navn eller nummer..."
|
||||
style={{ width: 300 }}
|
||||
allowClear
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
/>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Space>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>Vis inaktive</Text>
|
||||
<Switch size="small" checked={showInactive} onChange={setShowInactive} />
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="accountNumber"
|
||||
label="Kontonummer"
|
||||
rules={[
|
||||
{ required: true, message: 'Indtast kontonummer' },
|
||||
{
|
||||
pattern: /^\d{4}$/,
|
||||
message: 'Kontonummer skal vaere 4 cifre',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="F.eks. 1000" maxLength={4} />
|
||||
</Form.Item>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={{ pageSize: 50, showSizeChanger: true }}
|
||||
size="middle"
|
||||
onRow={(record) => ({
|
||||
onClick: () => handleRowClick(record),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Kontonavn"
|
||||
rules={[{ required: true, message: 'Indtast kontonavn' }]}
|
||||
>
|
||||
<Input placeholder="F.eks. Bankkonto" />
|
||||
</Form.Item>
|
||||
{/* Details/Edit Drawer */}
|
||||
<Drawer
|
||||
title={
|
||||
isEditMode
|
||||
? (selectedAccount ? 'Rediger konto' : 'Ny konto')
|
||||
: (
|
||||
<Space>
|
||||
{selectedAccount?.name}
|
||||
<Tag>{selectedAccount?.accountNumber}</Tag>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
width={500}
|
||||
open={isDrawerOpen}
|
||||
onClose={handleCloseDrawer}
|
||||
extra={
|
||||
!isEditMode && selectedAccount && (
|
||||
<Button type="primary" icon={<EditOutlined />} onClick={handleEditAccount}>
|
||||
Rediger
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
footer={
|
||||
isEditMode && (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Space>
|
||||
<Button onClick={() => isEditMode && selectedAccount ? setIsEditMode(false) : handleCloseDrawer()}>
|
||||
Annuller
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
Gem konto
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{isEditMode ? (
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="accountNumber"
|
||||
label="Kontonummer"
|
||||
rules={[
|
||||
{ required: true, message: 'Påkrævet' },
|
||||
{ pattern: /^\d{4}$/, message: 'Skal være 4 cifre' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={4} placeholder="1234" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="type"
|
||||
label="Kontotype"
|
||||
rules={[{ required: true, message: 'Påkrævet' }]}
|
||||
>
|
||||
<Select options={accountTypes.map(t => ({ label: getAccountTypeName(t), value: t }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
name="type"
|
||||
label="Kontotype"
|
||||
rules={[{ required: true, message: 'Vaelg kontotype' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Vaelg type"
|
||||
options={accountTypes.map((type) => ({
|
||||
value: type,
|
||||
label: getAccountTypeName(type),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Kontonavn"
|
||||
rules={[{ required: true, message: 'Påkrævet' }]}
|
||||
>
|
||||
<Input placeholder="F.eks. Salg af varer" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="vatCode" label="Momskode">
|
||||
<Select
|
||||
placeholder="Vaelg momskode"
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'S25', label: 'S25 - Udgaende moms 25%' },
|
||||
{ value: 'K25', label: 'K25 - Indgaende moms 25%' },
|
||||
{ value: 'E0', label: 'E0 - EU-varekob 0%' },
|
||||
{ value: 'U0', label: 'U0 - Eksport 0%' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="vatCode" label="Momskode">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="Vælg..."
|
||||
options={[
|
||||
{ value: 'S25', label: 'S25 - Udgående (Salg)' },
|
||||
{ value: 'K25', label: 'K25 - Indgående (Køb)' },
|
||||
{ value: 'E0', label: 'E0 - EU-salg' },
|
||||
{ value: 'U0', label: 'U0 - Eksport' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="isActive" label="Status" valuePropName="checked" initialValue={true}>
|
||||
<Switch checkedChildren="Aktiv" unCheckedChildren="Inaktiv" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="description" label="Beskrivelse">
|
||||
<Input.TextArea rows={2} placeholder="Valgfri beskrivelse" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Beskrivelse">
|
||||
<Input.TextArea rows={4} placeholder="Interne noter til denne konto..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : selectedAccount ? (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{/* Balance Summary */}
|
||||
<Card size="small" bordered={false} style={{ background: '#f5f5f5' }}>
|
||||
<Statistic
|
||||
title="Nuværende Saldo"
|
||||
value={selectedAccount.balance}
|
||||
precision={2}
|
||||
suffix="kr."
|
||||
valueStyle={{
|
||||
color: selectedAccount.balance >= 0 ? accountingColors.credit : accountingColors.debit,
|
||||
fontSize: 24,
|
||||
}}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Beregnet for indeværende regnskabsår
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Form.Item name="isActive" label="Status" initialValue={true}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: true, label: 'Aktiv' },
|
||||
{ value: false, label: 'Inaktiv' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="Kontonummer">{selectedAccount.accountNumber}</Descriptions.Item>
|
||||
<Descriptions.Item label="Kontotype">{getAccountTypeName(selectedAccount.type)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Moms">{selectedAccount.vatCode || <Text type="secondary">Ingen</Text>}</Descriptions.Item>
|
||||
<Descriptions.Item label="Status">
|
||||
{selectedAccount.isActive ? <Tag color="success">Aktiv</Tag> : <Tag color="default">Inaktiv</Tag>}
|
||||
</Descriptions.Item>
|
||||
{selectedAccount.description && (
|
||||
<Descriptions.Item label="Beskrivelse">
|
||||
{selectedAccount.description}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
<Divider orientation="left" plain><HistoryOutlined /> Seneste Bevægelser</Divider>
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<Text type="secondary">Ingen posteringer i den valgte periode.</Text>
|
||||
<br />
|
||||
<Button type="link" size="small" onClick={() => navigate('/kassekladde')}>Gå til kassekladde</Button>
|
||||
</div>
|
||||
|
||||
<Divider plain />
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<AlertInfo
|
||||
message="Systemkonto"
|
||||
description="Denne konto bruges automatisk af systemet til specifikke posteringer."
|
||||
show={!!selectedAccount.isSystemAccount}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
</Space>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper component for alerts
|
||||
function AlertInfo({ message, description, show }: { message: string, description: string, show: boolean }) {
|
||||
if (!show) return null;
|
||||
return (
|
||||
<div style={{ background: '#e6f7ff', border: '1px solid #91d5ff', padding: '8px 12px', borderRadius: 4 }}>
|
||||
<Space align="start">
|
||||
<InfoCircleOutlined style={{ color: '#1890ff', marginTop: 4 }} />
|
||||
<div>
|
||||
<Text strong style={{ color: '#1890ff' }}>{message}</Text>
|
||||
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.65)' }}>{description}</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -124,9 +124,9 @@ export default function Kreditnotaer() {
|
|||
// Fetch customers for dropdown
|
||||
const { data: customers = [] } = useActiveCustomers(company?.id);
|
||||
|
||||
// Fetch invoices for applying credit notes (only when modal is open)
|
||||
// Fetch invoices for applying credit notes and for original invoice selector
|
||||
const { data: allInvoices = [] } = useInvoices(company?.id, undefined, {
|
||||
enabled: !!company?.id && isApplyModalOpen,
|
||||
enabled: !!company?.id && (isApplyModalOpen || isCreateModalOpen),
|
||||
});
|
||||
|
||||
const openInvoices: Invoice[] = allInvoices.filter(
|
||||
|
|
@ -623,6 +623,15 @@ export default function Kreditnotaer() {
|
|||
allowClear
|
||||
placeholder="Vælg faktura der krediteres"
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={allInvoices
|
||||
.filter((i: Invoice) => !i.isCreditNote && i.status !== 'voided')
|
||||
.map((i: Invoice) => ({
|
||||
value: i.id,
|
||||
label: `${i.invoiceNumber} - ${i.customerName} (${formatCurrency(i.amountTotal)})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="Årsag">
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
Spin,
|
||||
Alert,
|
||||
Drawer,
|
||||
|
|
@ -48,6 +49,7 @@ import { formatDate, validateCVRModulus11 } from '@/lib/formatters';
|
|||
import { spacing } from '@/styles/designTokens';
|
||||
import { StatusBadge } from '@/components/shared/StatusBadge';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
|
@ -310,27 +312,18 @@ export default function Kunder() {
|
|||
|
||||
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>
|
||||
<PageHeader
|
||||
title="Kunder"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Kunder' }]}
|
||||
extra={
|
||||
<ShortcutTooltip shortcutId="newCustomer">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
Ny kunde
|
||||
</Button>
|
||||
</ShortcutTooltip>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
|
|
@ -391,15 +384,10 @@ export default function Kunder() {
|
|||
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>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>Vis inaktive</Text>
|
||||
<Switch size="small" checked={showInactive} onChange={setShowInactive} />
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
|
|
@ -15,24 +15,26 @@ import {
|
|||
Alert,
|
||||
Modal,
|
||||
Descriptions,
|
||||
message,
|
||||
Empty,
|
||||
Skeleton,
|
||||
} from 'antd';
|
||||
import {
|
||||
DownloadOutlined,
|
||||
SendOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Pie } from '@ant-design/charts';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCompany } from '@/hooks/useCompany';
|
||||
import { formatCurrency, formatDate, formatPeriod } from '@/lib/formatters';
|
||||
import { useCompanyStore } from '@/stores/companyStore';
|
||||
import { useVatReport } from '@/api/queries/vatQueries';
|
||||
import { formatCurrency, formatPeriod } from '@/lib/formatters';
|
||||
import { accountingColors } from '@/styles/theme';
|
||||
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
|
||||
// Danish VAT boxes (Rubrikker)
|
||||
// Danish VAT boxes (Rubrikker) - mapped from backend VatReport
|
||||
interface VATBox {
|
||||
boxNumber: number;
|
||||
nameDanish: string;
|
||||
|
|
@ -42,134 +44,90 @@ interface VATBox {
|
|||
basis?: number;
|
||||
}
|
||||
|
||||
const mockVATReport: VATBox[] = [
|
||||
{
|
||||
boxNumber: 1,
|
||||
nameDanish: 'Salgsmoms',
|
||||
nameEnglish: 'Output VAT',
|
||||
description: 'Moms af varer og ydelser solgt i Danmark (25%)',
|
||||
amount: 62500,
|
||||
basis: 250000,
|
||||
},
|
||||
{
|
||||
boxNumber: 2,
|
||||
nameDanish: 'Moms af varekøb i udlandet (EU)',
|
||||
nameEnglish: 'VAT on goods from EU',
|
||||
description: 'Erhvervelsesmoms ved køb af varer fra andre EU-lande',
|
||||
amount: 5000,
|
||||
basis: 20000,
|
||||
},
|
||||
{
|
||||
boxNumber: 3,
|
||||
nameDanish: 'Moms af ydelseskøb i udlandet',
|
||||
nameEnglish: 'VAT on services from abroad',
|
||||
description: 'Moms ved køb af ydelser fra udlandet med omvendt betalingspligt',
|
||||
amount: 2500,
|
||||
basis: 10000,
|
||||
},
|
||||
{
|
||||
boxNumber: 4,
|
||||
nameDanish: 'Købsmoms',
|
||||
nameEnglish: 'Input VAT',
|
||||
description: 'Fradragsberettiget moms af køb',
|
||||
amount: 35000,
|
||||
basis: 140000,
|
||||
},
|
||||
{
|
||||
boxNumber: 5,
|
||||
nameDanish: 'Olie- og flaskegasafgift',
|
||||
nameEnglish: 'Oil and gas duty',
|
||||
description: 'Godtgørelse af olie- og flaskegasafgift',
|
||||
amount: 0,
|
||||
},
|
||||
{
|
||||
boxNumber: 6,
|
||||
nameDanish: 'Elafgift',
|
||||
nameEnglish: 'Electricity duty',
|
||||
description: 'Godtgørelse af elafgift',
|
||||
amount: 1200,
|
||||
},
|
||||
{
|
||||
boxNumber: 7,
|
||||
nameDanish: 'Naturgas- og bygasafgift',
|
||||
nameEnglish: 'Natural gas duty',
|
||||
description: 'Godtgørelse af naturgas- og bygasafgift',
|
||||
amount: 0,
|
||||
},
|
||||
{
|
||||
boxNumber: 8,
|
||||
nameDanish: 'Kulafgift',
|
||||
nameEnglish: 'Coal duty',
|
||||
description: 'Godtgørelse af kulafgift',
|
||||
amount: 0,
|
||||
},
|
||||
{
|
||||
boxNumber: 9,
|
||||
nameDanish: 'CO2-afgift',
|
||||
nameEnglish: 'CO2 duty',
|
||||
description: 'Godtgørelse af CO2-afgift',
|
||||
amount: 300,
|
||||
},
|
||||
];
|
||||
|
||||
// Historical submissions
|
||||
const mockSubmissions = [
|
||||
{
|
||||
id: '1',
|
||||
period: '2024-10',
|
||||
submittedAt: '2024-11-28',
|
||||
status: 'accepted',
|
||||
netVAT: 28500,
|
||||
referenceNumber: 'SKAT-2024-123456',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
period: '2024-07',
|
||||
submittedAt: '2024-08-30',
|
||||
status: 'accepted',
|
||||
netVAT: 32100,
|
||||
referenceNumber: 'SKAT-2024-789012',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
period: '2024-04',
|
||||
submittedAt: '2024-05-29',
|
||||
status: 'accepted',
|
||||
netVAT: -5600,
|
||||
referenceNumber: 'SKAT-2024-345678',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Momsindberetning() {
|
||||
const { company } = useCompany();
|
||||
const { activeCompany } = useCompanyStore();
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<dayjs.Dayjs>(
|
||||
dayjs().subtract(1, 'month').startOf('month')
|
||||
);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('quarterly');
|
||||
|
||||
// Calculate totals
|
||||
const outputVAT = mockVATReport
|
||||
.filter((box) => [1, 2, 3].includes(box.boxNumber))
|
||||
.reduce((sum, box) => sum + box.amount, 0);
|
||||
// Calculate period dates based on selection
|
||||
const periodStart = useMemo(() => {
|
||||
if (periodType === 'quarterly') {
|
||||
return selectedPeriod.startOf('quarter').format('YYYY-MM-DD');
|
||||
}
|
||||
return selectedPeriod.startOf('month').format('YYYY-MM-DD');
|
||||
}, [selectedPeriod, periodType]);
|
||||
|
||||
const inputVAT = mockVATReport
|
||||
.filter((box) => box.boxNumber === 4)
|
||||
.reduce((sum, box) => sum + box.amount, 0);
|
||||
const periodEnd = useMemo(() => {
|
||||
if (periodType === 'quarterly') {
|
||||
return selectedPeriod.endOf('quarter').format('YYYY-MM-DD');
|
||||
}
|
||||
return selectedPeriod.endOf('month').format('YYYY-MM-DD');
|
||||
}, [selectedPeriod, periodType]);
|
||||
|
||||
const energyDuties = mockVATReport
|
||||
.filter((box) => [5, 6, 7, 8, 9].includes(box.boxNumber))
|
||||
.reduce((sum, box) => sum + box.amount, 0);
|
||||
// Fetch VAT report from backend
|
||||
const { data: vatReport, isLoading, error } = useVatReport(
|
||||
activeCompany?.id,
|
||||
periodStart,
|
||||
periodEnd
|
||||
);
|
||||
|
||||
const netVAT = outputVAT - inputVAT - energyDuties;
|
||||
// Map backend VatReport to UI's rubrik display
|
||||
const vatBoxes: VATBox[] = useMemo(() => {
|
||||
if (!vatReport) return [];
|
||||
return [
|
||||
{
|
||||
boxNumber: 1,
|
||||
nameDanish: 'Salgsmoms',
|
||||
nameEnglish: 'Output VAT',
|
||||
description: 'Moms af varer og ydelser solgt i Danmark (25%)',
|
||||
amount: vatReport.boxA,
|
||||
basis: vatReport.basis1,
|
||||
},
|
||||
{
|
||||
boxNumber: 2,
|
||||
nameDanish: 'Moms af varekob i udlandet (EU)',
|
||||
nameEnglish: 'VAT on goods from EU',
|
||||
description: 'Erhvervelsesmoms ved kob af varer fra andre EU-lande',
|
||||
amount: vatReport.boxC,
|
||||
basis: vatReport.basis3,
|
||||
},
|
||||
{
|
||||
boxNumber: 3,
|
||||
nameDanish: 'Moms af ydelseskob i udlandet',
|
||||
nameEnglish: 'VAT on services from abroad',
|
||||
description: 'Moms ved kob af ydelser fra udlandet med omvendt betalingspligt',
|
||||
amount: vatReport.boxD,
|
||||
basis: vatReport.basis4,
|
||||
},
|
||||
{
|
||||
boxNumber: 4,
|
||||
nameDanish: 'Kobsmoms',
|
||||
nameEnglish: 'Input VAT',
|
||||
description: 'Fradragsberettiget moms af kob',
|
||||
amount: vatReport.boxB,
|
||||
basis: undefined, // Backend doesn't provide a specific basis for input VAT
|
||||
},
|
||||
];
|
||||
}, [vatReport]);
|
||||
|
||||
// Calculate totals from real data
|
||||
const outputVAT = vatReport?.totalOutputVat ?? 0;
|
||||
const inputVAT = vatReport?.totalInputVat ?? 0;
|
||||
const netVAT = vatReport?.netVat ?? 0;
|
||||
|
||||
// Pie chart config
|
||||
const pieData = [
|
||||
{ type: 'Salgsmoms', value: mockVATReport[0].amount },
|
||||
{ type: 'EU-moms', value: mockVATReport[1].amount + mockVATReport[2].amount },
|
||||
{ type: 'Købsmoms (fradrag)', value: inputVAT },
|
||||
{ type: 'Energiafgifter (fradrag)', value: energyDuties },
|
||||
];
|
||||
const pieData = useMemo(() => {
|
||||
if (!vatReport) return [];
|
||||
return [
|
||||
{ type: 'Salgsmoms', value: vatReport.boxA },
|
||||
{ type: 'EU-moms', value: (vatReport.boxC || 0) + (vatReport.boxD || 0) },
|
||||
{ type: 'Kobsmoms (fradrag)', value: inputVAT },
|
||||
].filter(d => d.value > 0);
|
||||
}, [vatReport, inputVAT]);
|
||||
|
||||
const pieConfig = {
|
||||
data: pieData,
|
||||
|
|
@ -243,96 +201,49 @@ export default function Momsindberetning() {
|
|||
},
|
||||
];
|
||||
|
||||
const handleSubmit = () => {
|
||||
Modal.confirm({
|
||||
title: 'Indsend momsangivelse',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: (
|
||||
<div>
|
||||
<p>Du er ved at indsende momsangivelse for:</p>
|
||||
<p>
|
||||
<Text strong>Periode:</Text> {formatPeriod(selectedPeriod.toDate())}
|
||||
</p>
|
||||
<p>
|
||||
<Text strong>Moms til betaling:</Text>{' '}
|
||||
<Text
|
||||
style={{
|
||||
color: netVAT >= 0 ? accountingColors.debit : accountingColors.credit,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(Math.abs(netVAT))}
|
||||
{netVAT < 0 ? ' (tilgode)' : ''}
|
||||
</Text>
|
||||
</p>
|
||||
<Alert
|
||||
message="Denne handling kan ikke fortrydes"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
okText: 'Indsend til SKAT',
|
||||
cancelText: 'Annuller',
|
||||
onOk: () => {
|
||||
message.success('Momsangivelse indsendt til SKAT');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
return (
|
||||
<Tag color="green" icon={<CheckCircleOutlined />}>
|
||||
Godkendt
|
||||
</Tag>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<Tag color="blue" icon={<ClockCircleOutlined />}>
|
||||
Afventer
|
||||
</Tag>
|
||||
);
|
||||
case 'rejected':
|
||||
return (
|
||||
<Tag color="red" icon={<ExclamationCircleOutlined />}>
|
||||
Afvist
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return <Tag>{status}</Tag>;
|
||||
}
|
||||
};
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Momsindberetning"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Rapportering' }, { title: 'Momsindberetning' }]}
|
||||
/>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Momsindberetning
|
||||
</Title>
|
||||
<Text type="secondary">{company?.name}</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<DownloadOutlined />}>Eksporter</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
>
|
||||
Forhåndsvis
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Momsindberetning"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Rapportering' }, { title: 'Momsindberetning' }]}
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<DownloadOutlined />}>Eksporter</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
disabled={!vatReport}
|
||||
>
|
||||
Forhåndsvis
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* SKAT submission notice */}
|
||||
<Alert
|
||||
type="warning"
|
||||
message="Indberetning skal ske manuelt på skat.dk"
|
||||
description="Automatisk indberetning til SKAT er endnu ikke implementeret. Brug disse tal til at udfylde momsangivelsen på skat.dk manuelt."
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
{/* Period Selection */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
|
|
@ -343,7 +254,7 @@ export default function Momsindberetning() {
|
|||
onChange={setPeriodType}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'monthly', label: 'Månedlig' },
|
||||
{ value: 'monthly', label: 'Maanedlig' },
|
||||
{ value: 'quarterly', label: 'Kvartalsvis' },
|
||||
]}
|
||||
/>
|
||||
|
|
@ -356,12 +267,26 @@ export default function Momsindberetning() {
|
|||
<Tag color="blue">
|
||||
Frist: {dayjs(selectedPeriod).add(1, 'month').endOf('month').format('D. MMMM YYYY')}
|
||||
</Tag>
|
||||
{vatReport && (
|
||||
<Tag color="green">{vatReport.transactionCount} transaktioner</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<Alert
|
||||
type="error"
|
||||
message="Fejl ved indlaesning af momsdata"
|
||||
description={error.message}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Summary Cards */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Udgående moms"
|
||||
|
|
@ -372,7 +297,7 @@ export default function Momsindberetning() {
|
|||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Indgående moms (fradrag)"
|
||||
|
|
@ -383,18 +308,7 @@ export default function Momsindberetning() {
|
|||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Energiafgifter (fradrag)"
|
||||
value={energyDuties}
|
||||
precision={2}
|
||||
formatter={(value) => formatCurrency(value as number)}
|
||||
valueStyle={{ color: accountingColors.credit }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={netVAT >= 0 ? 'Moms til betaling' : 'Moms til gode'}
|
||||
|
|
@ -413,87 +327,57 @@ export default function Momsindberetning() {
|
|||
<Row gutter={16}>
|
||||
<Col xs={24} lg={16}>
|
||||
<Card title="Momsangivelse - Rubrikker" size="small">
|
||||
<Table
|
||||
dataSource={mockVATReport}
|
||||
columns={columns}
|
||||
rowKey="boxNumber"
|
||||
pagination={false}
|
||||
size="small"
|
||||
summary={() => (
|
||||
<Table.Summary fixed>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={3}>
|
||||
<Text strong>Moms til betaling / tilgode</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} align="right">
|
||||
<Text
|
||||
strong
|
||||
className="tabular-nums"
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color:
|
||||
netVAT >= 0 ? accountingColors.debit : accountingColors.credit,
|
||||
}}
|
||||
>
|
||||
{netVAT >= 0 ? '' : '-'}
|
||||
{formatCurrency(Math.abs(netVAT))}
|
||||
</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
</Table.Summary>
|
||||
)}
|
||||
/>
|
||||
{vatBoxes.length > 0 ? (
|
||||
<Table
|
||||
dataSource={vatBoxes}
|
||||
columns={columns}
|
||||
rowKey="boxNumber"
|
||||
pagination={false}
|
||||
size="small"
|
||||
summary={() => (
|
||||
<Table.Summary fixed>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={3}>
|
||||
<Text strong>Moms til betaling / tilgode</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} align="right">
|
||||
<Text
|
||||
strong
|
||||
className="tabular-nums"
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color:
|
||||
netVAT >= 0 ? accountingColors.debit : accountingColors.credit,
|
||||
}}
|
||||
>
|
||||
{netVAT >= 0 ? '' : '-'}
|
||||
{formatCurrency(Math.abs(netVAT))}
|
||||
</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
</Table.Summary>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="Ingen momsdata for den valgte periode" />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={8}>
|
||||
<Card title="Fordeling" size="small" style={{ marginBottom: 16 }}>
|
||||
<Pie {...pieConfig} />
|
||||
{pieData.length > 0 ? (
|
||||
<Pie {...pieConfig} />
|
||||
) : (
|
||||
<Empty description="Ingen data at vise" style={{ height: 200 }} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Tidligere indberetninger" size="small">
|
||||
{mockSubmissions.map((sub) => (
|
||||
<div
|
||||
key={sub.id}
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text strong>
|
||||
{dayjs(sub.period, 'YYYY-MM').format('MMMM YYYY')}
|
||||
</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Indsendt {formatDate(sub.submittedAt)}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
{getStatusTag(sub.status)}
|
||||
<br />
|
||||
<Text
|
||||
className="tabular-nums"
|
||||
style={{
|
||||
color:
|
||||
sub.netVAT >= 0
|
||||
? accountingColors.debit
|
||||
: accountingColors.credit,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(sub.netVAT)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<DemoDataDisclaimer message="Indberetningshistorik er endnu ikke tilgængelig" />
|
||||
<Text type="secondary">
|
||||
Tidligere indberetninger vil blive vist her nar SKAT-integration er implementeret.
|
||||
</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
@ -512,18 +396,25 @@ export default function Momsindberetning() {
|
|||
Download PDF
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
key="skat-link"
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={() => {
|
||||
window.open('https://skat.dk', '_blank');
|
||||
setIsPreviewOpen(false);
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
Indsend til SKAT
|
||||
Ga til skat.dk
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
message="Brug disse tal til at udfylde momsangivelsen på skat.dk"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label="Virksomhed">{company?.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="CVR">{company?.cvr}</Descriptions.Item>
|
||||
|
|
@ -537,22 +428,24 @@ export default function Momsindberetning() {
|
|||
|
||||
<Divider />
|
||||
|
||||
<Table
|
||||
dataSource={mockVATReport}
|
||||
columns={[
|
||||
{ dataIndex: 'boxNumber', title: 'Rubrik', width: 80 },
|
||||
{ dataIndex: 'nameDanish', title: 'Felt' },
|
||||
{
|
||||
dataIndex: 'amount',
|
||||
title: 'Beløb',
|
||||
align: 'right',
|
||||
render: (v) => formatCurrency(v),
|
||||
},
|
||||
]}
|
||||
rowKey="boxNumber"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
{vatBoxes.length > 0 && (
|
||||
<Table
|
||||
dataSource={vatBoxes}
|
||||
columns={[
|
||||
{ dataIndex: 'boxNumber', title: 'Rubrik', width: 80 },
|
||||
{ dataIndex: 'nameDanish', title: 'Felt' },
|
||||
{
|
||||
dataIndex: 'amount',
|
||||
title: 'Belob',
|
||||
align: 'right',
|
||||
render: (v: number) => formatCurrency(v),
|
||||
},
|
||||
]}
|
||||
rowKey="boxNumber"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import { spacing } from '@/styles/designTokens';
|
|||
import { accountingColors } from '@/styles/theme';
|
||||
import { AmountText } from '@/components/shared/AmountText';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
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';
|
||||
|
|
@ -140,7 +141,7 @@ export default function Ordrer() {
|
|||
|
||||
const handleSubmitCreate = async () => {
|
||||
if (!company || !currentFiscalYear) {
|
||||
showError('Virksomhed eller regnskabsaar ikke valgt');
|
||||
showError('Virksomhed eller regnskabsår ikke valgt');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -226,12 +227,12 @@ export default function Ordrer() {
|
|||
const handleConfirmOrder = async () => {
|
||||
if (!selectedOrder) return;
|
||||
if (selectedOrder.lines.length === 0) {
|
||||
showWarning('Tilfoej mindst en linje foer bekraeftelse');
|
||||
showWarning('Tilføj mindst en linje før bekræftelse');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await confirmOrderMutation.mutateAsync(selectedOrder.id);
|
||||
showSuccess('Ordre bekraeftet');
|
||||
showSuccess('Ordre bekræftet');
|
||||
// Refresh would happen via query invalidation
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
|
|
@ -276,7 +277,7 @@ export default function Ordrer() {
|
|||
|
||||
const handleSubmitConvert = async () => {
|
||||
if (!selectedOrder || selectedLinesToInvoice.length === 0) {
|
||||
showWarning('Vaelg mindst en linje at fakturere');
|
||||
showWarning('Vælg mindst en linje at fakturere');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -346,7 +347,7 @@ export default function Ordrer() {
|
|||
render: (value: string | undefined) => (value ? formatDate(value) : '-'),
|
||||
},
|
||||
{
|
||||
title: 'Beloeb',
|
||||
title: 'Beløb',
|
||||
dataIndex: 'amountTotal',
|
||||
key: 'amountTotal',
|
||||
width: 120,
|
||||
|
|
@ -371,7 +372,7 @@ export default function Ordrer() {
|
|||
align: 'center',
|
||||
filters: [
|
||||
{ text: 'Kladde', value: 'draft' },
|
||||
{ text: 'Bekraeftet', value: 'confirmed' },
|
||||
{ text: 'Bekræftet', value: 'confirmed' },
|
||||
{ text: 'Delvist faktureret', value: 'partially_invoiced' },
|
||||
{ text: 'Fuldt faktureret', value: 'fully_invoiced' },
|
||||
{ text: 'Annulleret', value: 'cancelled' },
|
||||
|
|
@ -399,37 +400,28 @@ export default function Ordrer() {
|
|||
|
||||
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>
|
||||
<PageHeader
|
||||
title="Ordrer"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Ordrer' }]}
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateOrder}>
|
||||
Ny ordre
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<Alert
|
||||
message="Fejl ved indlaesning af ordrer"
|
||||
message="Fejl ved indlæsning af ordrer"
|
||||
description={error.message}
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: spacing.lg }}
|
||||
action={
|
||||
<Button size="small" onClick={() => refetch()}>
|
||||
Proev igen
|
||||
Prøv igen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
|
@ -454,7 +446,7 @@ export default function Ordrer() {
|
|||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Bekraeftede"
|
||||
title="Bekræftede"
|
||||
value={stats.confirmed}
|
||||
valueStyle={{ color: accountingColors.credit }}
|
||||
/>
|
||||
|
|
@ -463,7 +455,7 @@ export default function Ordrer() {
|
|||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Samlet vaerdi"
|
||||
title="Samlet værdi"
|
||||
value={stats.totalValue}
|
||||
precision={2}
|
||||
valueStyle={{ color: accountingColors.credit }}
|
||||
|
|
@ -477,7 +469,7 @@ export default function Ordrer() {
|
|||
<Card size="small" style={{ marginBottom: spacing.lg }}>
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder="Soeg ordre..."
|
||||
placeholder="Søg ordre..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
|
|
@ -491,7 +483,7 @@ export default function Ordrer() {
|
|||
options={[
|
||||
{ value: 'all', label: 'Alle status' },
|
||||
{ value: 'draft', label: 'Kladde' },
|
||||
{ value: 'confirmed', label: 'Bekraeftet' },
|
||||
{ value: 'confirmed', label: 'Bekræftet' },
|
||||
{ value: 'partially_invoiced', label: 'Delvist faktureret' },
|
||||
{ value: 'fully_invoiced', label: 'Fuldt faktureret' },
|
||||
{ value: 'cancelled', label: 'Annulleret' },
|
||||
|
|
@ -503,7 +495,7 @@ export default function Ordrer() {
|
|||
{/* Order Table */}
|
||||
<Card size="small">
|
||||
{loading ? (
|
||||
<Spin tip="Indlaeser ordrer..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
|
||||
<Spin tip="Indlæser ordrer..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
|
||||
<div style={{ minHeight: 200 }} />
|
||||
</Spin>
|
||||
) : filteredOrders.length > 0 ? (
|
||||
|
|
@ -518,7 +510,7 @@ export default function Ordrer() {
|
|||
<EmptyState
|
||||
variant="default"
|
||||
title="Ingen ordrer"
|
||||
description={searchText ? 'Ingen ordrer matcher din soegning' : 'Opret din foerste ordre'}
|
||||
description={searchText ? 'Ingen ordrer matcher din søgning' : 'Opret din første ordre'}
|
||||
primaryAction={
|
||||
!searchText
|
||||
? {
|
||||
|
|
@ -546,11 +538,11 @@ export default function Ordrer() {
|
|||
<Form.Item
|
||||
name="customerId"
|
||||
label="Kunde"
|
||||
rules={[{ required: true, message: 'Vaelg kunde' }]}
|
||||
rules={[{ required: true, message: 'Vælg kunde' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Vaelg kunde"
|
||||
placeholder="Vælg kunde"
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
|
|
@ -576,7 +568,7 @@ export default function Ordrer() {
|
|||
<Form.Item name="reference" label="Reference">
|
||||
<Input placeholder="Projektnavn, tilbudsnr., etc." />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="Bemaerkninger">
|
||||
<Form.Item name="notes" label="Bemærkninger">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
|
@ -612,7 +604,7 @@ export default function Ordrer() {
|
|||
onClick={handleOpenAddLineModal}
|
||||
loading={addOrderLineMutation.isPending}
|
||||
>
|
||||
Tilfoej linje
|
||||
Tilføj linje
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
|
|
@ -621,13 +613,13 @@ export default function Ordrer() {
|
|||
loading={confirmOrderMutation.isPending}
|
||||
disabled={selectedOrder.lines.length === 0}
|
||||
>
|
||||
Bekraeft
|
||||
Bekræft
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canShowConvertToInvoice(selectedOrder) && (
|
||||
<Tooltip
|
||||
title={selectedOrder.status === 'draft' ? 'Bekraeft ordren foerst' : undefined}
|
||||
title={selectedOrder.status === 'draft' ? 'Bekræft ordren først' : undefined}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
|
|
@ -720,7 +712,7 @@ export default function Ordrer() {
|
|||
) : (
|
||||
<Alert
|
||||
message="Ingen linjer endnu"
|
||||
description="Tilfoej linjer for at kunne bekraefte ordren."
|
||||
description="Tilføj linjer for at kunne bekræfte ordren."
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
|
|
@ -732,13 +724,13 @@ export default function Ordrer() {
|
|||
<Col span={12}>
|
||||
{selectedOrder.notes && (
|
||||
<>
|
||||
<Text type="secondary">Bemaerkninger:</Text>
|
||||
<Text type="secondary">Bemærkninger:</Text>
|
||||
<p>{selectedOrder.notes}</p>
|
||||
</>
|
||||
)}
|
||||
{selectedOrder.cancelledReason && (
|
||||
<>
|
||||
<Text type="secondary">Annulleringsaarsag:</Text>
|
||||
<Text type="secondary">Annulleringsårsag:</Text>
|
||||
<p style={{ color: 'red' }}>{selectedOrder.cancelledReason}</p>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -746,7 +738,7 @@ export default function Ordrer() {
|
|||
<Col span={12}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Text type="secondary">Beloeb ex. moms: </Text>
|
||||
<Text type="secondary">Beløb ex. moms: </Text>
|
||||
<Text>{formatCurrency(selectedOrder.amountExVat)}</Text>
|
||||
</div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
|
|
@ -794,7 +786,7 @@ export default function Ordrer() {
|
|||
>
|
||||
<Alert
|
||||
message="Advarsel"
|
||||
description="At annullere ordren kan ikke fortrydes. Eventuelle delfaktureringer forbliver uaendrede."
|
||||
description="At annullere ordren kan ikke fortrydes. Eventuelle delfaktureringer forbliver uændrede."
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: spacing.lg }}
|
||||
|
|
@ -802,8 +794,8 @@ export default function Ordrer() {
|
|||
<Form form={cancelForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="reason"
|
||||
label="Aarsag til annullering"
|
||||
rules={[{ required: true, message: 'Angiv aarsag' }]}
|
||||
label="Årsag til annullering"
|
||||
rules={[{ required: true, message: 'Angiv årsag' }]}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="Beskriv hvorfor ordren annulleres" />
|
||||
</Form.Item>
|
||||
|
|
@ -812,14 +804,14 @@ export default function Ordrer() {
|
|||
|
||||
{/* Add Line Modal */}
|
||||
<Modal
|
||||
title="Tilfoej linje"
|
||||
title="Tilføj linje"
|
||||
open={isAddLineModalOpen}
|
||||
onCancel={() => {
|
||||
setIsAddLineModalOpen(false);
|
||||
setSelectedProductId(null);
|
||||
}}
|
||||
onOk={handleSubmitAddLine}
|
||||
okText="Tilfoej"
|
||||
okText="Tilføj"
|
||||
cancelText="Annuller"
|
||||
confirmLoading={addOrderLineMutation.isPending}
|
||||
width={550}
|
||||
|
|
@ -840,7 +832,7 @@ export default function Ordrer() {
|
|||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="product">Vaelg produkt</Radio.Button>
|
||||
<Radio.Button value="product">Vælg produkt</Radio.Button>
|
||||
<Radio.Button value="freetext">Fritekst</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
|
@ -850,11 +842,11 @@ export default function Ordrer() {
|
|||
label="Produkt"
|
||||
required
|
||||
validateStatus={addLineMode === 'product' && !selectedProductId ? 'error' : undefined}
|
||||
help={addLineMode === 'product' && !selectedProductId ? 'Vaelg et produkt' : undefined}
|
||||
help={addLineMode === 'product' && !selectedProductId ? 'Vælg et produkt' : undefined}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Soeg efter produkt..."
|
||||
placeholder="Søg efter produkt..."
|
||||
optionFilterProp="children"
|
||||
value={selectedProductId}
|
||||
onChange={handleProductSelect}
|
||||
|
|
@ -922,7 +914,7 @@ export default function Ordrer() {
|
|||
<Form.Item
|
||||
name="vatCode"
|
||||
label="Momskode"
|
||||
rules={[{ required: true, message: 'Vaelg momskode' }]}
|
||||
rules={[{ required: true, message: 'Vælg momskode' }]}
|
||||
>
|
||||
<Select
|
||||
disabled={addLineMode === 'product' && !!selectedProductId}
|
||||
|
|
@ -952,8 +944,8 @@ export default function Ordrer() {
|
|||
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."
|
||||
message="Vælg linjer til fakturering"
|
||||
description="Vælg hvilke ordrelinjer der skal inkluderes i fakturaen. Du kan fakturere delvist og oprette flere fakturaer senere."
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: spacing.lg }}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
InputNumber,
|
||||
Select,
|
||||
AutoComplete,
|
||||
Spin,
|
||||
Alert,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
|
|
@ -20,6 +19,8 @@ import {
|
|||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Switch,
|
||||
Skeleton,
|
||||
} from 'antd';
|
||||
import { showSuccess, showError } from '@/lib/errorHandling';
|
||||
import {
|
||||
|
|
@ -45,9 +46,10 @@ import { formatDate, formatCurrency } from '@/lib/formatters';
|
|||
import { spacing } from '@/styles/designTokens';
|
||||
import { StatusBadge } from '@/components/shared/StatusBadge';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
|
||||
// VAT code options
|
||||
const vatCodeOptions = [
|
||||
|
|
@ -310,33 +312,57 @@ export default function Produkter() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: spacing.xl }}>
|
||||
<Spin size="large" />
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Produkter"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Produkter' }]}
|
||||
/>
|
||||
<Row gutter={spacing.md} style={{ marginBottom: spacing.lg }}>
|
||||
<Col xs={24} sm={8}><Card><Skeleton active paragraph={{ rows: 2 }} /></Card></Col>
|
||||
<Col xs={24} sm={8}><Card><Skeleton active paragraph={{ rows: 2 }} /></Card></Col>
|
||||
<Col xs={24} sm={8}><Card><Skeleton active paragraph={{ rows: 2 }} /></Card></Col>
|
||||
</Row>
|
||||
<Card><Skeleton active paragraph={{ rows: 8 }} /></Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
message="Fejl ved indlæsning af produkter"
|
||||
description={error.message}
|
||||
type="error"
|
||||
showIcon
|
||||
/>
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Produkter"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Produkter' }]}
|
||||
/>
|
||||
<Alert
|
||||
message="Fejl ved indlæsning af produkter"
|
||||
description={error.message}
|
||||
type="error"
|
||||
showIcon
|
||||
action={
|
||||
<Button size="small" onClick={() => window.location.reload()}>
|
||||
Prøv igen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<PageHeader
|
||||
title="Produkter"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Produkter' }]}
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
Opret produkt
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Statistics */}
|
||||
<Row gutter={spacing.md} style={{ marginBottom: spacing.lg }}>
|
||||
|
|
@ -373,12 +399,10 @@ export default function Produkter() {
|
|||
allowClear
|
||||
style={{ width: 300 }}
|
||||
/>
|
||||
<Button
|
||||
type={showInactive ? 'primary' : 'default'}
|
||||
onClick={() => setShowInactive(!showInactive)}
|
||||
>
|
||||
{showInactive ? 'Skjul inaktive' : 'Vis inaktive'}
|
||||
</Button>
|
||||
<Space>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>Vis inaktive</Text>
|
||||
<Switch size="small" checked={showInactive} onChange={setShowInactive} />
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
Divider,
|
||||
message,
|
||||
Space,
|
||||
Tag,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined,
|
||||
|
|
@ -22,6 +22,8 @@ import {
|
|||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useCompany } from '@/hooks/useCompany';
|
||||
import { useUpdateCompany } from '@/api/mutations/companyMutations';
|
||||
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
|
|
@ -29,24 +31,45 @@ export default function Settings() {
|
|||
const { company } = useCompany();
|
||||
const [companyForm] = Form.useForm();
|
||||
const [preferencesForm] = Form.useForm();
|
||||
const updateCompanyMutation = useUpdateCompany();
|
||||
|
||||
const handleSaveCompany = async () => {
|
||||
try {
|
||||
const values = await companyForm.validateFields();
|
||||
console.log('Saving company:', values);
|
||||
|
||||
if (!company?.id) {
|
||||
message.error('Ingen virksomhed valgt');
|
||||
return;
|
||||
}
|
||||
|
||||
await updateCompanyMutation.mutateAsync({
|
||||
id: company.id,
|
||||
input: {
|
||||
name: values.name,
|
||||
cvr: values.cvr,
|
||||
address: values.address,
|
||||
city: values.city,
|
||||
postalCode: values.postalCode,
|
||||
},
|
||||
});
|
||||
message.success('Virksomhedsoplysninger gemt');
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl ved gemning: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePreferences = async () => {
|
||||
try {
|
||||
const values = await preferencesForm.validateFields();
|
||||
console.log('Saving preferences:', values);
|
||||
message.success('Præferencer gemt');
|
||||
await preferencesForm.validateFields();
|
||||
// TODO: Backend does not yet have a preferences mutation.
|
||||
// Preferences like VAT period, auto-reconcile, etc. need a dedicated backend endpoint.
|
||||
message.info('Præferencer er endnu ikke forbundet til backend');
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl ved gemning: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -282,56 +305,19 @@ export default function Settings() {
|
|||
<Title level={5} style={{ margin: 0 }}>
|
||||
Tilknyttede bankkonti
|
||||
</Title>
|
||||
<Button type="primary">Tilføj bankkonto</Button>
|
||||
<Button type="primary" onClick={() => message.info('Bankforbindelse tilføjes under Indstillinger > Integrationer (kommer snart)')}>Tilføj bankkonto</Button>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Mock bank accounts */}
|
||||
{[
|
||||
{
|
||||
id: '1',
|
||||
bankName: 'Danske Bank',
|
||||
accountName: 'Erhvervskonto',
|
||||
accountNumber: '1234-5678901234',
|
||||
ledgerAccount: '1000 - Bank',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
bankName: 'Nordea',
|
||||
accountName: 'Opsparingskonto',
|
||||
accountNumber: '9876-5432109876',
|
||||
ledgerAccount: '1010 - Bank opsparing',
|
||||
isActive: true,
|
||||
},
|
||||
].map((account) => (
|
||||
<Card key={account.id} size="small">
|
||||
<Row align="middle" justify="space-between">
|
||||
<Col>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space>
|
||||
<Text strong>{account.bankName}</Text>
|
||||
<Tag color="blue">{account.accountName}</Tag>
|
||||
{account.isActive && <Tag color="green">Aktiv</Tag>}
|
||||
</Space>
|
||||
<Text type="secondary">{account.accountNumber}</Text>
|
||||
<Text type="secondary">
|
||||
Bogføringskonto: {account.ledgerAccount}
|
||||
</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button size="small">Rediger</Button>
|
||||
<Button size="small" danger>
|
||||
Fjern
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="Ingen bankkonti tilknyttet endnu"
|
||||
>
|
||||
<Button type="primary" onClick={() => message.info('Bankforbindelse tilføjes under Indstillinger > Integrationer (kommer snart)')}>
|
||||
Tilføj bankkonto
|
||||
</Button>
|
||||
</Empty>
|
||||
</Space>
|
||||
</Card>
|
||||
),
|
||||
|
|
@ -345,6 +331,7 @@ export default function Settings() {
|
|||
),
|
||||
children: (
|
||||
<Card>
|
||||
<DemoDataDisclaimer message="Brugerstyring er under udvikling" />
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -356,50 +343,15 @@ export default function Settings() {
|
|||
<Title level={5} style={{ margin: 0 }}>
|
||||
Brugere med adgang
|
||||
</Title>
|
||||
<Button type="primary">Inviter bruger</Button>
|
||||
<Button type="primary" disabled>Inviter bruger</Button>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Mock users */}
|
||||
{[
|
||||
{
|
||||
id: '1',
|
||||
name: 'Admin Bruger',
|
||||
email: 'admin@example.com',
|
||||
role: 'Administrator',
|
||||
lastLogin: '2025-01-17',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Bogholder',
|
||||
email: 'bogholder@example.com',
|
||||
role: 'Bogholder',
|
||||
lastLogin: '2025-01-16',
|
||||
},
|
||||
].map((user) => (
|
||||
<Card key={user.id} size="small">
|
||||
<Row align="middle" justify="space-between">
|
||||
<Col>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong>{user.name}</Text>
|
||||
<Text type="secondary">{user.email}</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Tag color={user.role === 'Administrator' ? 'gold' : 'blue'}>
|
||||
{user.role}
|
||||
</Tag>
|
||||
<Text type="secondary">
|
||||
Sidste login: {user.lastLogin}
|
||||
</Text>
|
||||
<Button size="small">Rediger</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
<Text type="secondary">
|
||||
Brugere med adgang til denne virksomhed vil blive vist her,
|
||||
når funktionen er implementeret.
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Company, CompanyRole } from '@/types/accounting';
|
||||
import type { CompanyRole, CompanyWithRole } from '@/types/accounting';
|
||||
|
||||
interface CompanyState {
|
||||
// Current active company
|
||||
activeCompany: Company | null;
|
||||
// List of available companies
|
||||
companies: Company[];
|
||||
// Current active company (includes role from myCompanies query)
|
||||
activeCompany: CompanyWithRole | null;
|
||||
// List of available companies (includes role from myCompanies query)
|
||||
companies: CompanyWithRole[];
|
||||
// Loading state
|
||||
isLoading: boolean;
|
||||
|
||||
// Actions
|
||||
setActiveCompany: (company: Company) => void;
|
||||
setCompanies: (companies: Company[]) => void;
|
||||
setActiveCompany: (company: CompanyWithRole) => void;
|
||||
setCompanies: (companies: CompanyWithRole[]) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
clearActiveCompany: () => void;
|
||||
}
|
||||
|
|
@ -53,11 +53,11 @@ export const useCompanies = () =>
|
|||
useCompanyStore((state) => state.companies);
|
||||
|
||||
// Get the current user's role for the active company
|
||||
// Returns 'owner' as default for now - in production this would come from the server
|
||||
// Returns the role from the myCompanies query data stored on the active company
|
||||
export const useActiveCompanyRole = (): CompanyRole => {
|
||||
// Placeholder: In a real implementation, this would check the user's role
|
||||
// for the currently active company from the server/auth context
|
||||
return 'owner';
|
||||
const activeCompany = useCompanyStore((state) => state.activeCompany);
|
||||
// Return the actual role from the CompanyWithRole data, default to 'viewer' if not set
|
||||
return activeCompany?.role ?? 'viewer';
|
||||
};
|
||||
|
||||
// Helper functions for user roles
|
||||
|
|
@ -88,9 +88,8 @@ export function getRoleColor(role: CompanyRole): string {
|
|||
}
|
||||
|
||||
// Hook to check if current user can administer the company
|
||||
// This is a placeholder - in a real app, this would check the user's role
|
||||
// Checks if the user has Owner role for the active company
|
||||
export function useCanAdmin(): boolean {
|
||||
// For now, return true to allow all users to manage access
|
||||
// In production, this should check the current user's role
|
||||
return true;
|
||||
const role = useActiveCompanyRole();
|
||||
return role === 'owner';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface Account {
|
|||
type: AccountType;
|
||||
parentId?: string;
|
||||
isActive: boolean;
|
||||
isSystemAccount?: boolean;
|
||||
description?: string;
|
||||
vatCode?: string;
|
||||
balance: number;
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ export type VATCode =
|
|||
* VAT code type classification
|
||||
*/
|
||||
export type VATCodeType =
|
||||
| 'output' // Udgaaende moms (salg)
|
||||
| 'input' // Indgaaende moms (koeb)
|
||||
| 'output' // Udgående moms (salg)
|
||||
| 'input' // Indgående moms (køb)
|
||||
| 'reverse_charge' // Omvendt betalingspligt
|
||||
| 'exempt' // Momsfritaget
|
||||
| 'none'; // Ingen moms
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"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"],"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