Backend (.NET 10): - EventFlow CQRS/Event Sourcing with PostgreSQL - GraphQL.NET API with mutations and queries - Custom ReadModelSqlGenerator for snake_case PostgreSQL columns - Hangfire for background job processing - Integration tests with isolated test databases Frontend (React/Vite): - Initial project structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
345 lines
8.7 KiB
TypeScript
345 lines
8.7 KiB
TypeScript
// Period Hook - React hook for period context and validation
|
|
|
|
import { useMemo, useCallback } from 'react';
|
|
import { usePeriodStore } from '@/stores/periodStore';
|
|
import {
|
|
getPeriodForDate,
|
|
getPreviousPeriod,
|
|
getSamePeriodPreviousYear,
|
|
getYearToDateRange,
|
|
canPostToDate,
|
|
validatePeriodClose,
|
|
} from '@/lib/periods';
|
|
import type { AccountingPeriod, FiscalYear, PeriodStatus } from '@/types/periods';
|
|
import type { Transaction } from '@/types/accounting';
|
|
|
|
/**
|
|
* Hook for accessing period context in components
|
|
*/
|
|
export function usePeriodContext() {
|
|
const {
|
|
currentFiscalYear,
|
|
currentPeriod,
|
|
selectedPeriod,
|
|
selectedVATPeriod,
|
|
comparisonPeriod,
|
|
comparisonType,
|
|
periods,
|
|
fiscalYears,
|
|
vatPeriods,
|
|
isLoading,
|
|
} = usePeriodStore();
|
|
|
|
const effectivePeriod = selectedPeriod || currentPeriod;
|
|
|
|
return {
|
|
// Current context
|
|
currentFiscalYear,
|
|
currentPeriod,
|
|
selectedPeriod,
|
|
effectivePeriod,
|
|
selectedVATPeriod,
|
|
comparisonPeriod,
|
|
comparisonType,
|
|
|
|
// Lists
|
|
periods,
|
|
fiscalYears,
|
|
vatPeriods,
|
|
|
|
// Loading
|
|
isLoading,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hook for period selection and navigation
|
|
*/
|
|
export function usePeriodSelector() {
|
|
const {
|
|
periods,
|
|
fiscalYears,
|
|
selectedPeriod,
|
|
setSelectedPeriod,
|
|
setComparisonPeriod,
|
|
clearComparison,
|
|
} = usePeriodStore();
|
|
|
|
const selectPeriod = useCallback(
|
|
(periodId: string) => {
|
|
const period = periods.find((p) => p.id === periodId);
|
|
if (period) {
|
|
setSelectedPeriod(period);
|
|
}
|
|
},
|
|
[periods, setSelectedPeriod]
|
|
);
|
|
|
|
const selectPreviousPeriod = useCallback(() => {
|
|
if (!selectedPeriod) return;
|
|
const previous = getPreviousPeriod(selectedPeriod, periods);
|
|
if (previous) {
|
|
setSelectedPeriod(previous);
|
|
}
|
|
}, [selectedPeriod, periods, setSelectedPeriod]);
|
|
|
|
const selectNextPeriod = useCallback(() => {
|
|
if (!selectedPeriod) return;
|
|
const currentIndex = periods.findIndex((p) => p.id === selectedPeriod.id);
|
|
if (currentIndex >= 0 && currentIndex < periods.length - 1) {
|
|
setSelectedPeriod(periods[currentIndex + 1]);
|
|
}
|
|
}, [selectedPeriod, periods, setSelectedPeriod]);
|
|
|
|
const enableComparison = useCallback(
|
|
(type: 'previous-period' | 'previous-year' | 'custom', customPeriod?: AccountingPeriod) => {
|
|
if (!selectedPeriod) return;
|
|
|
|
let comparisonPeriodData: AccountingPeriod | undefined;
|
|
|
|
if (type === 'previous-period') {
|
|
comparisonPeriodData = getPreviousPeriod(selectedPeriod, periods);
|
|
} else if (type === 'previous-year') {
|
|
comparisonPeriodData = getSamePeriodPreviousYear(selectedPeriod, periods);
|
|
} else if (type === 'custom' && customPeriod) {
|
|
comparisonPeriodData = customPeriod;
|
|
}
|
|
|
|
if (comparisonPeriodData) {
|
|
setComparisonPeriod(comparisonPeriodData, type);
|
|
}
|
|
},
|
|
[selectedPeriod, periods, setComparisonPeriod]
|
|
);
|
|
|
|
const disableComparison = useCallback(() => {
|
|
clearComparison();
|
|
}, [clearComparison]);
|
|
|
|
// Get periods grouped by fiscal year
|
|
const periodsByYear = useMemo(() => {
|
|
const grouped: Record<string, AccountingPeriod[]> = {};
|
|
for (const period of periods) {
|
|
if (!grouped[period.fiscalYearId]) {
|
|
grouped[period.fiscalYearId] = [];
|
|
}
|
|
grouped[period.fiscalYearId].push(period);
|
|
}
|
|
return grouped;
|
|
}, [periods]);
|
|
|
|
// Get open periods only
|
|
const openPeriods = useMemo(
|
|
() => periods.filter((p) => p.status === 'open'),
|
|
[periods]
|
|
);
|
|
|
|
return {
|
|
// State
|
|
selectedPeriod,
|
|
periods,
|
|
fiscalYears,
|
|
periodsByYear,
|
|
openPeriods,
|
|
|
|
// Actions
|
|
selectPeriod,
|
|
selectPreviousPeriod,
|
|
selectNextPeriod,
|
|
enableComparison,
|
|
disableComparison,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hook for posting validation
|
|
*/
|
|
export function usePostingValidation() {
|
|
const { periods, periodSettings } = usePeriodStore();
|
|
|
|
const validatePostingDate = useCallback(
|
|
(date: string) => {
|
|
if (!periodSettings) {
|
|
// Default to strict validation if no settings
|
|
return canPostToDate(date, periods, {
|
|
preventPostingToClosedPeriods: true,
|
|
preventPostingToFuturePeriods: true,
|
|
});
|
|
}
|
|
|
|
return canPostToDate(date, periods, {
|
|
preventPostingToClosedPeriods: periodSettings.preventPostingToClosedPeriods,
|
|
preventPostingToFuturePeriods: periodSettings.preventPostingToFuturePeriods,
|
|
});
|
|
},
|
|
[periods, periodSettings]
|
|
);
|
|
|
|
const getPeriodStatus = useCallback(
|
|
(date: string): PeriodStatus | 'no-period' => {
|
|
const period = getPeriodForDate(date, periods);
|
|
return period?.status || 'no-period';
|
|
},
|
|
[periods]
|
|
);
|
|
|
|
const isDatePostable = useCallback(
|
|
(date: string): boolean => {
|
|
return validatePostingDate(date).allowed;
|
|
},
|
|
[validatePostingDate]
|
|
);
|
|
|
|
return {
|
|
validatePostingDate,
|
|
getPeriodStatus,
|
|
isDatePostable,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hook for period management (closing, locking, etc.)
|
|
*/
|
|
export function usePeriodManagement() {
|
|
const {
|
|
periods,
|
|
closePeriod,
|
|
reopenPeriod,
|
|
lockPeriod,
|
|
updatePeriod,
|
|
} = usePeriodStore();
|
|
|
|
const canClosePeriod = useCallback(
|
|
(periodId: string, transactions: Transaction[]): { canClose: boolean; errors: string[]; warnings: string[] } => {
|
|
const period = periods.find((p) => p.id === periodId);
|
|
if (!period) {
|
|
return {
|
|
canClose: false,
|
|
errors: ['Periode ikke fundet'],
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
const validation = validatePeriodClose(period, transactions, {
|
|
requireAllReconciled: true,
|
|
});
|
|
|
|
return {
|
|
canClose: validation.isValid,
|
|
errors: validation.errors.map((e) => e.messageDanish),
|
|
warnings: validation.warnings.map((w) => w.messageDanish),
|
|
};
|
|
},
|
|
[periods]
|
|
);
|
|
|
|
const closeAccountingPeriod = useCallback(
|
|
(periodId: string, userId: string) => {
|
|
closePeriod(periodId, userId);
|
|
},
|
|
[closePeriod]
|
|
);
|
|
|
|
const reopenAccountingPeriod = useCallback(
|
|
(periodId: string, userId: string) => {
|
|
reopenPeriod(periodId, userId);
|
|
},
|
|
[reopenPeriod]
|
|
);
|
|
|
|
const lockAccountingPeriod = useCallback(
|
|
(periodId: string, userId: string) => {
|
|
lockPeriod(periodId, userId);
|
|
},
|
|
[lockPeriod]
|
|
);
|
|
|
|
const getPeriodActions = useCallback(
|
|
(periodId: string) => {
|
|
const period = periods.find((p) => p.id === periodId);
|
|
if (!period) return { canClose: false, canReopen: false, canLock: false };
|
|
|
|
return {
|
|
canClose: period.status === 'open',
|
|
canReopen: period.status === 'closed',
|
|
canLock: period.status === 'closed',
|
|
};
|
|
},
|
|
[periods]
|
|
);
|
|
|
|
return {
|
|
canClosePeriod,
|
|
closeAccountingPeriod,
|
|
reopenAccountingPeriod,
|
|
lockAccountingPeriod,
|
|
getPeriodActions,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hook for year-to-date calculations
|
|
*/
|
|
export function useYearToDate() {
|
|
const { currentFiscalYear, selectedPeriod, periods } = usePeriodStore();
|
|
|
|
const ytdRange = useMemo(() => {
|
|
if (!currentFiscalYear || !selectedPeriod) return null;
|
|
return getYearToDateRange(selectedPeriod, currentFiscalYear);
|
|
}, [currentFiscalYear, selectedPeriod]);
|
|
|
|
const ytdPeriods = useMemo(() => {
|
|
if (!currentFiscalYear || !selectedPeriod) return [];
|
|
return periods.filter((p) => {
|
|
if (p.fiscalYearId !== currentFiscalYear.id) return false;
|
|
return p.periodNumber <= selectedPeriod.periodNumber;
|
|
});
|
|
}, [currentFiscalYear, selectedPeriod, periods]);
|
|
|
|
return {
|
|
ytdRange,
|
|
ytdPeriods,
|
|
fiscalYear: currentFiscalYear,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Combined hook for common period operations
|
|
*/
|
|
export function usePeriod() {
|
|
const context = usePeriodContext();
|
|
const selector = usePeriodSelector();
|
|
const validation = usePostingValidation();
|
|
const management = usePeriodManagement();
|
|
const ytd = useYearToDate();
|
|
|
|
return {
|
|
// Context
|
|
...context,
|
|
|
|
// Selector
|
|
selectPeriod: selector.selectPeriod,
|
|
selectPreviousPeriod: selector.selectPreviousPeriod,
|
|
selectNextPeriod: selector.selectNextPeriod,
|
|
enableComparison: selector.enableComparison,
|
|
disableComparison: selector.disableComparison,
|
|
periodsByYear: selector.periodsByYear,
|
|
openPeriods: selector.openPeriods,
|
|
|
|
// Validation
|
|
validatePostingDate: validation.validatePostingDate,
|
|
getPeriodStatus: validation.getPeriodStatus,
|
|
isDatePostable: validation.isDatePostable,
|
|
|
|
// Management
|
|
canClosePeriod: management.canClosePeriod,
|
|
closeAccountingPeriod: management.closeAccountingPeriod,
|
|
reopenAccountingPeriod: management.reopenAccountingPeriod,
|
|
lockAccountingPeriod: management.lockAccountingPeriod,
|
|
getPeriodActions: management.getPeriodActions,
|
|
|
|
// YTD
|
|
ytdRange: ytd.ytdRange,
|
|
ytdPeriods: ytd.ytdPeriods,
|
|
};
|
|
}
|