diff --git a/biome.json b/biome.json index 4f583438..09ef5df6 100644 --- a/biome.json +++ b/biome.json @@ -14,6 +14,7 @@ "!.next", "!styled-system", "!drizzle", + "!orga", "!prisma/generated", "!.pnpm-store" ] diff --git a/prisma/migrations/20260629130000_add_savings_contract_fields/migration.sql b/prisma/migrations/20260629130000_add_savings_contract_fields/migration.sql new file mode 100644 index 00000000..b2510efd --- /dev/null +++ b/prisma/migrations/20260629130000_add_savings_contract_fields/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "SavingsRateType" AS ENUM ('FIXED', 'VARYING'); + +-- AlterTable +ALTER TABLE "Loan" ADD COLUMN "isSavingsContract" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "savingsRateType" "SavingsRateType", +ADD COLUMN "savingsMonthlyAmount" DOUBLE PRECISION, +ADD COLUMN "savingsDepositCount" INTEGER, +ADD COLUMN "savingsFirstDepositDate" TIMESTAMP(3); diff --git a/prisma/migrations/20260702150500_add_savings_last_deposit_date/migration.sql b/prisma/migrations/20260702150500_add_savings_last_deposit_date/migration.sql new file mode 100644 index 00000000..68badd25 --- /dev/null +++ b/prisma/migrations/20260702150500_add_savings_last_deposit_date/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Loan" ADD COLUMN "savingsLastDepositDate" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 10b60cf4..8b99ff5f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -270,6 +270,12 @@ model Loan { terminationPeriodType DurationType? duration Int? durationType DurationType? + isSavingsContract Boolean @default(false) + savingsRateType SavingsRateType? + savingsMonthlyAmount Float? + savingsDepositCount Int? + savingsFirstDepositDate DateTime? + savingsLastDepositDate DateTime? amount Float interestRate Float altInterestMethod InterestMethod? @@ -517,6 +523,11 @@ enum DurationType { YEARS } +enum SavingsRateType { + FIXED + VARYING +} + enum TransactionType { INTEREST DEPOSIT diff --git a/prisma/seed.ts b/prisma/seed.ts index d2bca684..14519e50 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -2,7 +2,21 @@ import 'dotenv/config'; import { mkdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { PrismaPg } from '@prisma/adapter-pg'; -import { DashboardLayoutScope, InterestMethod, Language, Prisma, PrismaClient, TemplateDataset } from '@prisma/client'; +import { + ContractStatus, + Country, + DashboardLayoutScope, + DurationType, + InterestMethod, + Language, + LenderType, + Prisma, + PrismaClient, + Salutation, + SavingsRateType, + TemplateDataset, + TerminationType, +} from '@prisma/client'; import { hashPassword } from '@/lib/utils/password'; @@ -217,6 +231,180 @@ async function seedGlobalDashboardLayout() { ); } +const DEV_LENDER_AT_EMAIL = 'lender-at@dev.local'; +const DEV_LENDER_DE_EMAIL = 'lender-de@dev.local'; + +async function getNextLenderNumber(projectId: string) { + const result = await prisma.lender.aggregate({ + where: { projectId }, + _max: { lenderNumber: true }, + }); + return (result._max.lenderNumber ?? 0) + 1; +} + +async function getNextLoanNumber(projectId: string) { + const result = await prisma.loan.aggregate({ + where: { lender: { projectId } }, + _max: { loanNumber: true }, + }); + return (result._max.loanNumber ?? 0) + 1; +} + +type DevLenderSeedData = { + email: string; + name: string; + type: LenderType; + salutation: Salutation; + firstName: string; + lastName: string; + street: string; + zip: string; + place: string; + country: Country; + iban: string; + bic: string; +}; + +async function createDevLender(projectId: string, data: DevLenderSeedData) { + await prisma.user.upsert({ + where: { email: data.email }, + update: {}, + create: { + email: data.email, + name: data.name, + language: Language.de, + }, + }); + + return prisma.lender.create({ + data: { + lenderNumber: await getNextLenderNumber(projectId), + projectId, + type: data.type, + salutation: data.salutation, + firstName: data.firstName, + lastName: data.lastName, + street: data.street, + zip: data.zip, + place: data.place, + country: data.country, + email: data.email, + iban: data.iban, + bic: data.bic, + }, + }); +} + +async function seedDevProjectData(adminUserId: string) { + let project = await prisma.project.findFirst({ + where: { slug: 'dev-gmbh' }, + }); + + if (!project) { + project = await prisma.project.create({ + data: { + slug: 'dev-gmbh', + configuration: { + create: { + name: 'Development GmbH', + interestMethod: InterestMethod.ACT_360_COMPOUND, + }, + }, + managers: { connect: { id: adminUserId } }, + }, + }); + console.info('Dev project created'); + } + + const lenderAt = + (await prisma.lender.findFirst({ + where: { projectId: project.id, email: DEV_LENDER_AT_EMAIL }, + })) ?? + (await createDevLender(project.id, { + email: DEV_LENDER_AT_EMAIL, + name: 'Anna Huber', + type: LenderType.PERSON, + salutation: Salutation.PERSONAL, + firstName: 'Anna', + lastName: 'Huber', + street: 'Mariahilfer Straße 12', + zip: '1060', + place: 'Wien', + country: Country.AT, + iban: 'AT611904300234573201', + bic: 'BKAUATWW', + })); + + const lenderDe = + (await prisma.lender.findFirst({ + where: { projectId: project.id, email: DEV_LENDER_DE_EMAIL }, + })) ?? + (await createDevLender(project.id, { + email: DEV_LENDER_DE_EMAIL, + name: 'Thomas Müller', + type: LenderType.PERSON, + salutation: Salutation.FORMAL, + firstName: 'Thomas', + lastName: 'Müller', + street: 'Hauptstraße 5', + zip: '80331', + place: 'München', + country: Country.DE, + iban: 'DE89370400440532013000', + bic: 'COBADEFFXXX', + })); + + const existingNormalLoan = await prisma.loan.findFirst({ + where: { lenderId: lenderDe.id, isSavingsContract: false }, + }); + + if (!existingNormalLoan) { + await prisma.loan.create({ + data: { + loanNumber: await getNextLoanNumber(project.id), + lenderId: lenderDe.id, + signDate: new Date('2024-01-15'), + terminationType: TerminationType.DURATION, + duration: 5, + durationType: DurationType.YEARS, + amount: 50_000, + interestRate: 3.5, + contractStatus: ContractStatus.COMPLETED, + isSavingsContract: false, + }, + }); + console.info('Dev normal contract created'); + } + + const existingSavingsLoan = await prisma.loan.findFirst({ + where: { lenderId: lenderAt.id, isSavingsContract: true }, + }); + + if (!existingSavingsLoan) { + await prisma.loan.create({ + data: { + loanNumber: await getNextLoanNumber(project.id), + lenderId: lenderAt.id, + signDate: new Date('2024-03-01'), + terminationType: TerminationType.DURATION, + duration: 10, + durationType: DurationType.YEARS, + amount: 60_000, + interestRate: 2.0, + contractStatus: ContractStatus.PENDING, + isSavingsContract: true, + savingsRateType: SavingsRateType.FIXED, + savingsMonthlyAmount: 500, + savingsDepositCount: 120, + savingsFirstDepositDate: new Date('2024-04-01'), + }, + }); + console.info('Dev savings contract created'); + } + + console.info('Dev lenders and contracts seeded'); +} + async function main() { await seedGlobalDashboardLayout(); @@ -241,24 +429,7 @@ async function main() { await seedSystemTemplates(user.id); if (process.env.ENVIRONMENT === 'dev') { - const project = await prisma.project.findFirst({ - where: { slug: 'dev-gmbh' }, - }); - if (!project) { - await prisma.project.create({ - data: { - slug: 'dev-gmbh', - configuration: { - create: { - name: 'Development GmbH', - interestMethod: InterestMethod.ACT_360_COMPOUND, - }, - }, - managers: { connect: { id: user.id } }, - }, - }); - console.info('Dev instance and project created'); - } + await seedDevProjectData(user.id); } } } diff --git a/src/actions/loans/mutations/create-loan.ts b/src/actions/loans/mutations/create-loan.ts index 20d7c4ac..f6c3af85 100644 --- a/src/actions/loans/mutations/create-loan.ts +++ b/src/actions/loans/mutations/create-loan.ts @@ -82,6 +82,13 @@ export const createLoanAction = lenderAction.inputSchema(loanFormSchema).action( terminationPeriodType: data.terminationPeriodType, duration: data.duration, durationType: data.durationType, + isSavingsContract: data.isSavingsContract, + savingsRateType: data.isSavingsContract ? data.savingsRateType : null, + savingsMonthlyAmount: + data.isSavingsContract && data.savingsRateType === 'FIXED' ? data.savingsMonthlyAmount : null, + savingsDepositCount: data.isSavingsContract ? data.savingsDepositCount : null, + savingsFirstDepositDate: data.isSavingsContract ? data.savingsFirstDepositDate : null, + savingsLastDepositDate: data.isSavingsContract ? data.savingsLastDepositDate : null, altInterestMethod: data.altInterestMethod, contractStatus: data.contractStatus, additionalFields: data.additionalFields ?? {}, diff --git a/src/actions/loans/mutations/update-loan.ts b/src/actions/loans/mutations/update-loan.ts index 0cabc8eb..8708ba1a 100644 --- a/src/actions/loans/mutations/update-loan.ts +++ b/src/actions/loans/mutations/update-loan.ts @@ -75,6 +75,13 @@ export const updateLoanAction = loanAction terminationPeriodType: data.terminationPeriodType, duration: data.duration, durationType: data.durationType, + isSavingsContract: data.isSavingsContract, + savingsRateType: data.isSavingsContract ? data.savingsRateType : null, + savingsMonthlyAmount: + data.isSavingsContract && data.savingsRateType === 'FIXED' ? data.savingsMonthlyAmount : null, + savingsDepositCount: data.isSavingsContract ? data.savingsDepositCount : null, + savingsFirstDepositDate: data.isSavingsContract ? data.savingsFirstDepositDate : null, + savingsLastDepositDate: data.isSavingsContract ? data.savingsLastDepositDate : null, altInterestMethod: data.altInterestMethod, contractStatus: data.contractStatus, additionalFields: data.additionalFields ?? {}, diff --git a/src/components/dashboard/widgets/filters/entity-filter-control.tsx b/src/components/dashboard/widgets/filters/entity-filter-control.tsx index 967e0c97..e0bb8f94 100644 --- a/src/components/dashboard/widgets/filters/entity-filter-control.tsx +++ b/src/components/dashboard/widgets/filters/entity-filter-control.tsx @@ -4,6 +4,7 @@ import type { ColumnFilter } from '@tanstack/react-table'; import { EntityDateFilter } from '@/components/dashboard/widgets/filters/entity-date-filter'; import { + BooleanFilter, MultiSelectFilter, NumberFilter, SelectFilter, @@ -23,6 +24,14 @@ export function EntityFilterControl({ const filterState: ColumnFilter | undefined = value === '' || value == null ? undefined : { id: 'filter', value }; switch (definition.type) { + case 'boolean': + return ( + onChange(v)} + size="sm" + /> + ); case 'select': return ( void; + size?: FilterFieldSize; +}) { + const tCommon = useTranslations('common.ui'); + const parsed = useMemo(() => parseBooleanFilterValue(value), [value]); + + return ( + + ); +} diff --git a/src/components/form/form-date-picker.tsx b/src/components/form/form-date-picker.tsx index db0d6600..4f178e54 100644 --- a/src/components/form/form-date-picker.tsx +++ b/src/components/form/form-date-picker.tsx @@ -1,17 +1,9 @@ 'use client'; -import { de, enUS } from 'date-fns/locale'; -import { Calendar as CalendarIcon, X } from 'lucide-react'; -import { useLocale } from 'next-intl'; -import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; -import { cn, formatDateLong } from '@/lib/utils'; - -import { Button } from '../ui/button'; -import { Calendar } from '../ui/calendar'; -import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; +import { DatePickerInput } from '@/components/ui/date-picker-input'; +import { FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; interface FormDatePickerProps { name: string; @@ -21,16 +13,7 @@ interface FormDatePickerProps { } export function FormDatePicker({ name, label, placeholder = 'Pick a date', disabled }: FormDatePickerProps) { - const locale = useLocale(); - const dateLocale = locale === 'de' ? de : enUS; const form = useFormContext(); - const [open, setOpen] = useState(false); - - // Function to convert a date to UTC - const toUTC = (date: Date | undefined) => { - if (!date) return null; - return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0)); - }; return ( ( {label && {label}} - - - - - )} - - - - - - - { - field.onChange(toUTC(date)); - setOpen(false); - }} - autoFocus - disabled={disabled} - locale={dateLocale} - /> - - + field.onChange(date ?? '')} + placeholder={placeholder} + calendarDisabled={disabled} + /> )} diff --git a/src/components/form/form-sanity-checks-provider.tsx b/src/components/form/form-sanity-checks-provider.tsx index 05563ac3..aaf74cbb 100644 --- a/src/components/form/form-sanity-checks-provider.tsx +++ b/src/components/form/form-sanity-checks-provider.tsx @@ -1,7 +1,7 @@ 'use client'; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import type { FormWarning } from '@/types/form-warnings'; +import { areFormWarningsEqual, type FormWarning } from '@/types/form-warnings'; type FormSanityChecksContextValue = { warnings: FormWarning[]; @@ -23,7 +23,7 @@ export function FormSanityChecksProvider({ children }: { children: ReactNode }) return next; } const existing = prev[id]; - if (existing?.id === warning.id && existing.message === warning.message) { + if (existing && areFormWarningsEqual(existing, warning)) { return prev; } return { ...prev, [id]: warning }; diff --git a/src/components/form/form-warning-message.tsx b/src/components/form/form-warning-message.tsx new file mode 100644 index 00000000..e14a9472 --- /dev/null +++ b/src/components/form/form-warning-message.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import type { ReactNode } from 'react'; +import { NoWrap } from '@/components/ui/no-wrap'; +import type { FormWarning, FormWarningMessageNamespace, FormWarningMessageValues } from '@/types/form-warnings'; + +const richTextTags = { + nowrap: (chunks: ReactNode) => {chunks}, +}; + +function RichFormWarningMessage({ + messageNamespace, + messageKey, + messageValues = {}, +}: { + messageNamespace: FormWarningMessageNamespace; + messageKey: string; + messageValues?: FormWarningMessageValues; +}) { + const t = useTranslations(messageNamespace); + + return t.rich(messageKey, { + ...messageValues, + ...richTextTags, + }); +} + +export function FormWarningMessage({ warning }: { warning: FormWarning }) { + if (warning.message) { + return warning.message; + } + + if (warning.messageKey && warning.messageNamespace) { + return ( + + ); + } + + return null; +} diff --git a/src/components/lender-dashboard/lender-loan-accordion-card.tsx b/src/components/lender-dashboard/lender-loan-accordion-card.tsx index 64dcf06d..28b9567c 100644 --- a/src/components/lender-dashboard/lender-loan-accordion-card.tsx +++ b/src/components/lender-dashboard/lender-loan-accordion-card.tsx @@ -9,7 +9,8 @@ import { ProjectLogo } from '@/components/dashboard/project-logo'; import { TemplateQuickActions } from '@/components/templates/template-quick-actions'; import { Button } from '@/components/ui/button'; import { InfoItem } from '@/components/ui/info-item'; -import { cn, formatCurrency, formatPercentage, getLenderName } from '@/lib/utils'; +import { formatTerminationModalities } from '@/lib/table-column-utils'; +import { cn, formatCurrency, formatDateShort, formatPercentage, getLenderName } from '@/lib/utils'; import { formatAddressPlace } from '@/lib/utils/format'; import { splitIbanIntoGroups } from '@/lib/utils/iban'; import type { LoanDetailsWithCalculations } from '@/types/loans'; @@ -35,29 +36,8 @@ export function LenderLoanAccordionCard({ loan, isOpen, onOpenChange }: LenderLo onOpenChange(!isOpen); }; - const getTerminationModalities = () => { - switch (loan.terminationType) { - case 'ENDDATE': - return `${commonT('enums.loan.terminationType.ENDDATE')} - ${loan.endDate ? format(new Date(loan.endDate), 'PPP', { locale: dateLocale }) : '-'}`; - case 'TERMINATION': - if (!loan.terminationPeriod || !loan.terminationPeriodType) - return `${commonT('enums.loan.terminationType.TERMINATION')} - -`; - return `${commonT('enums.loan.terminationType.TERMINATION')} - ${loan.terminationPeriod} ${ - loan.terminationPeriodType === 'MONTHS' - ? commonT('enums.loan.durationUnit.MONTHS') - : commonT('enums.loan.durationUnit.YEARS') - }`; - case 'DURATION': - if (!loan.duration || !loan.durationType) return `${commonT('enums.loan.terminationType.DURATION')} - -`; - return `${commonT('enums.loan.terminationType.DURATION')} - ${loan.duration} ${ - loan.durationType === 'MONTHS' - ? commonT('enums.loan.durationUnit.MONTHS') - : commonT('enums.loan.durationUnit.YEARS') - }`; - default: - return '-'; - } - }; + const getTerminationModalities = () => + formatTerminationModalities(loan, commonT, (d) => formatDateShort(d, locale)); const lender = loan.lender; const lenderName = getLenderName(lender); @@ -95,6 +75,12 @@ export function LenderLoanAccordionCard({ loan, isOpen, onOpenChange }: LenderLo {formatPercentage(loan.interestRate)} · {format(new Date(loan.signDate), 'PP', { locale: dateLocale })} + {loan.isSavingsContract && ( + <> + · + {t('new.form.savingsContract')} + + )} {/* biome-ignore lint/a11y/useKeyWithClickEvents: toolbar stops accordion toggle only */} @@ -161,6 +147,30 @@ export function LenderLoanAccordionCard({ loan, isOpen, onOpenChange }: LenderLo } /> + {loan.isSavingsContract && loan.savingsDepositCount != null && ( + <> + + {loan.requiredDepositsCount > 0 && ( + + )} + + )} {loan.terminationDate && loan.terminationType === 'TERMINATION' && ( formatTerminationModalities(loan, commonT, (d) => formatDateLong(d, locale)); + const getTerminationModalities = () => formatTerminationModalities(loan, commonT, (d) => formatDateShort(d, locale)); + + const savingsFirstDepositDate = loan.isSavingsContract + ? resolveSavingsFirstDepositDate(loan.savingsFirstDepositDate, loan.signDate) + : null; + const savingsLastDepositDate = loan.isSavingsContract + ? resolveSavingsLastDepositDate( + loan.savingsFirstDepositDate, + loan.savingsLastDepositDate, + loan.savingsDepositCount, + loan.signDate, + ) + : null; const handleDeleteLoan = async () => { const toastId = toast.loading(t('delete.loading')); @@ -181,8 +194,65 @@ export function LoanAccordionCard({ loan, defaultOpen = false }: LoanAccordionCa } /> + + {loan.outstandingDepositsCount > 0 && loan.outstandingDepositSinceDays != null ? ( +
+ {loan.outstandingDepositSinceDays > 0 + ? t('table.paymentOutstandingSince', { days: loan.outstandingDepositSinceDays }) + : t('table.paymentOutstanding')} +
+ ) : ( +
{t('table.paymentNotOutstanding')}
+ )} + + } + />
+ {loan.isSavingsContract && loan.savingsDepositCount != null && ( + <> + + {(savingsFirstDepositDate || savingsLastDepositDate) && ( + + {formatDateLong(savingsFirstDepositDate, locale)} –{' '} + {formatDateLong(savingsLastDepositDate, locale)} + + ) : savingsFirstDepositDate ? ( + formatDateLong(savingsFirstDepositDate, locale) + ) : ( + formatDateLong(savingsLastDepositDate, locale) + ) + } + /> + )} + {loan.requiredDepositsCount > 0 && ( + + )} + + )} {canTerminateLoan && (
+ ); } diff --git a/src/components/loans/loan-form.tsx b/src/components/loans/loan-form.tsx index a025cc6a..13e773a5 100644 --- a/src/components/loans/loan-form.tsx +++ b/src/components/loans/loan-form.tsx @@ -1,7 +1,7 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { DurationType } from '@prisma/client'; +import { DurationType, SavingsRateType } from '@prisma/client'; import { useQuery } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { getLendersByProjectAction } from '@/actions/lenders'; @@ -77,6 +77,12 @@ export function LoanForm({ terminationPeriodType: initialData?.terminationPeriodType || DurationType.MONTHS, duration: initialData?.duration || '', durationType: initialData?.durationType || DurationType.YEARS, + isSavingsContract: initialData?.isSavingsContract ?? false, + savingsRateType: initialData?.savingsRateType ?? SavingsRateType.FIXED, + savingsMonthlyAmount: formatNumber(initialData?.savingsMonthlyAmount) || ('' as const), + savingsDepositCount: initialData?.savingsDepositCount ?? '', + savingsFirstDepositDate: initialData?.savingsFirstDepositDate || '', + savingsLastDepositDate: initialData?.savingsLastDepositDate || '', additionalFields: additionalFieldDefaults( project.configuration.loanAdditionalFields || [], (initialData?.additionalFields as AdditionalFieldValues | undefined) || {}, diff --git a/src/components/loans/savings-form-fields.tsx b/src/components/loans/savings-form-fields.tsx new file mode 100644 index 00000000..e1fe609e --- /dev/null +++ b/src/components/loans/savings-form-fields.tsx @@ -0,0 +1,455 @@ +'use client'; + +import { SavingsRateType } from '@prisma/client'; +import { ChartColumn, Equal, Lock } from 'lucide-react'; +import { useLocale, useTranslations } from 'next-intl'; +import type { KeyboardEvent, ReactNode } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { DatePickerInput } from '@/components/ui/date-picker-input'; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { + calculateSavingsDepositCountFromMonthlyAmount, + calculateSavingsLastDepositDate, + calculateSavingsMonthlyAmount, + resolveSavingsFirstDepositDate, +} from '@/lib/loans/savings-contract'; +import type { LoanFormClientData } from '@/lib/schemas/loan'; +import { formatDateLong, formatNumber, NumberParser } from '@/lib/utils'; + +type SavingsFieldKey = 'savingsMonthlyAmount' | 'savingsDepositCount'; +type FieldMode = 'defined' | 'derived'; + +const hasDateValue = (value: Date | '' | null | undefined): value is Date => + value instanceof Date && !Number.isNaN(value.getTime()); +const hasCountValue = (value: '' | number | null | undefined): value is number => + typeof value === 'number' && value >= 1; +const hasAmountValue = (value: string) => { + const parser = new NumberParser('de-DE'); + const parsed = parser.parse(value); + return parsed != null && parsed > 0; +}; + +const getInitialFieldModes = ( + values: LoanFormClientData, + isFixedRate: boolean, +): Record => ({ + savingsMonthlyAmount: isFixedRate && hasAmountValue(values.savingsMonthlyAmount) ? 'defined' : null, + savingsDepositCount: hasCountValue(values.savingsDepositCount) ? 'defined' : null, +}); + +interface LockedFieldOverlayProps { + isLocked: boolean; + unlockLabel: string; + onUnlock: () => void; + children: ReactNode; +} + +function LockedFieldOverlay({ isLocked, unlockLabel, onUnlock, children }: LockedFieldOverlayProps) { + const handleUnlock = useCallback( + (event: KeyboardEvent) => { + if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onUnlock(); + }, + [onUnlock], + ); + + return ( +
+ {children} + {isLocked && ( +
+ ); +} + +function SavingsFieldLabel({ label, isLocked }: { label: string; isLocked: boolean }) { + return ( + + {label} + {isLocked && + ); +} + +export function SavingsFormFields() { + const t = useTranslations('dashboard.loans'); + const commonT = useTranslations('common'); + const locale = useLocale(); + const { watch, setValue, control, getValues } = useFormContext(); + const hasInitializedModesRef = useRef(false); + + const isSavingsContract = watch('isSavingsContract'); + const savingsRateType = watch('savingsRateType'); + const signDate = watch('signDate'); + const amount = watch('amount'); + const savingsFirstDepositDate = watch('savingsFirstDepositDate'); + const savingsDepositCount = watch('savingsDepositCount'); + const isFixedRate = savingsRateType === SavingsRateType.FIXED; + const toggleValue = isFixedRate ? 'fixed' : 'varying'; + + const parser = new NumberParser('de-DE'); + const loanAmount = parser.parse(amount as string) ?? 0; + const previousLoanAmountRef = useRef(loanAmount); + + const [fieldModes, setFieldModes] = useState>(() => + getInitialFieldModes(getValues(), isFixedRate), + ); + const [firstDepositDatePickerOpen, setFirstDepositDatePickerOpen] = useState(false); + + useEffect(() => { + if (!isSavingsContract) { + hasInitializedModesRef.current = false; + return; + } + + if (hasInitializedModesRef.current) return; + + hasInitializedModesRef.current = true; + setFieldModes(getInitialFieldModes(getValues(), isFixedRate)); + }, [getValues, isFixedRate, isSavingsContract]); + + const setFieldMode = useCallback((field: SavingsFieldKey, mode: FieldMode | null) => { + setFieldModes((current) => ({ ...current, [field]: mode })); + }, []); + + const setDerivedValue = useCallback( + (field: SavingsFieldKey, value: number | string | '' | null) => { + setValue(field, value as never, { shouldDirty: true, shouldValidate: true }); + if (value === '' || value === null) { + setFieldMode(field, null); + return; + } + setFieldMode(field, 'derived'); + }, + [setFieldMode, setValue], + ); + + const applyDependencyRules = useCallback( + (changed: SavingsFieldKey | 'amount') => { + const values = getValues(); + const depositCount = values.savingsDepositCount; + const monthlyAmount = parser.parse(values.savingsMonthlyAmount as string); + + const hasDepositCount = hasCountValue(depositCount); + const hasMonthlyAmount = monthlyAmount != null && monthlyAmount > 0; + + if (changed === 'savingsMonthlyAmount' && isFixedRate && loanAmount > 0 && hasMonthlyAmount) { + const count = calculateSavingsDepositCountFromMonthlyAmount(loanAmount, monthlyAmount); + if (count) setDerivedValue('savingsDepositCount', count); + return; + } + + if (changed === 'savingsDepositCount' && hasDepositCount && typeof depositCount === 'number') { + if (isFixedRate && loanAmount > 0) { + const monthly = calculateSavingsMonthlyAmount(loanAmount, depositCount); + if (monthly != null) setDerivedValue('savingsMonthlyAmount', formatNumber(monthly)); + } + return; + } + + if (changed === 'amount') { + if (!isFixedRate || loanAmount <= 0) return; + + if (fieldModes.savingsMonthlyAmount === 'defined' && hasMonthlyAmount) { + const count = calculateSavingsDepositCountFromMonthlyAmount(loanAmount, monthlyAmount); + if (count) setDerivedValue('savingsDepositCount', count); + return; + } + + if (fieldModes.savingsDepositCount === 'defined' && hasDepositCount && typeof depositCount === 'number') { + const monthly = calculateSavingsMonthlyAmount(loanAmount, depositCount); + if (monthly != null) setDerivedValue('savingsMonthlyAmount', formatNumber(monthly)); + } + } + }, + [fieldModes, getValues, isFixedRate, loanAmount, parser, setDerivedValue], + ); + + useEffect(() => { + if (!isSavingsContract) { + previousLoanAmountRef.current = loanAmount; + return; + } + + if (previousLoanAmountRef.current === loanAmount) return; + previousLoanAmountRef.current = loanAmount; + + applyDependencyRules('amount'); + }, [applyDependencyRules, isSavingsContract, loanAmount]); + + useEffect(() => { + if (!isSavingsContract) return; + + const effectiveFirst = resolveSavingsFirstDepositDate( + hasDateValue(savingsFirstDepositDate) ? savingsFirstDepositDate : null, + hasDateValue(signDate) ? signDate : null, + ); + + if (effectiveFirst && hasCountValue(savingsDepositCount)) { + const last = calculateSavingsLastDepositDate(effectiveFirst, savingsDepositCount); + setValue('savingsLastDepositDate', last ?? '', { shouldDirty: true, shouldValidate: true }); + return; + } + + setValue('savingsLastDepositDate', '', { shouldDirty: true, shouldValidate: true }); + }, [isSavingsContract, savingsFirstDepositDate, savingsDepositCount, signDate, setValue]); + + const handleUserFieldChange = useCallback( + (field: SavingsFieldKey, value: number | string | '' | null) => { + setValue(field, value as never, { shouldDirty: true, shouldValidate: true }); + + if (value === '' || value === null) { + setFieldMode(field, null); + return; + } + + setFieldMode(field, 'defined'); + applyDependencyRules(field); + }, + [applyDependencyRules, setFieldMode, setValue], + ); + + const handleFirstDepositDateChange = useCallback( + (date: Date | '' | null) => { + setValue('savingsFirstDepositDate', date ?? '', { shouldDirty: true, shouldValidate: true }); + }, + [setValue], + ); + + const handleUnlockField = useCallback( + (field: SavingsFieldKey) => { + if (fieldModes[field] !== 'derived') return; + setFieldMode(field, 'defined'); + applyDependencyRules(field); + }, + [applyDependencyRules, fieldModes, setFieldMode], + ); + + const isFieldLocked = useCallback( + (field: SavingsFieldKey, hasValue: boolean) => fieldModes[field] === 'derived' && hasValue, + [fieldModes], + ); + + const handleToggleChange = (value: string) => { + if (!value) return; + + setValue('savingsRateType', value === 'fixed' ? SavingsRateType.FIXED : SavingsRateType.VARYING, { + shouldDirty: true, + shouldValidate: true, + }); + + if (value !== 'fixed') { + setValue('savingsMonthlyAmount', '', { shouldDirty: true, shouldValidate: true }); + setFieldMode('savingsMonthlyAmount', null); + } + }; + + const monthlyFormatter = new Intl.NumberFormat('de-DE', { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }); + + const fieldUnlockLabel = t('new.form.savingsFieldUnlock'); + + const calculatedLastDepositDate = (() => { + const effectiveFirst = resolveSavingsFirstDepositDate( + hasDateValue(savingsFirstDepositDate) ? savingsFirstDepositDate : null, + hasDateValue(signDate) ? signDate : null, + ); + if (!effectiveFirst || !hasCountValue(savingsDepositCount)) return null; + return calculateSavingsLastDepositDate(effectiveFirst, savingsDepositCount); + })(); + + return ( + <> + {isSavingsContract && ( +
+
+ + + + + + + + + + + +
+ + {isFixedRate ? ( +
+ ( + + + + handleUnlockField('savingsMonthlyAmount')} + > +
+ + € + +
+ { + if (isFieldLocked('savingsMonthlyAmount', hasAmountValue(field.value))) return; + const value = event.target.value; + if (!value) return; + const number = parser.parse(value) ?? 0; + handleUserFieldChange('savingsMonthlyAmount', monthlyFormatter.format(number)); + }} + onChange={(event) => { + if (isFieldLocked('savingsMonthlyAmount', hasAmountValue(field.value))) return; + handleUserFieldChange('savingsMonthlyAmount', parser.strip(event.target.value)); + }} + className="pl-12 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+
+ +
+ )} + /> + ( + + + + handleUnlockField('savingsDepositCount')} + > + { + if (isFieldLocked('savingsDepositCount', hasCountValue(field.value))) return; + const next = e.target.value; + handleUserFieldChange('savingsDepositCount', next === '' ? '' : Number.parseInt(next, 10)); + }} + className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> + + + + + )} + /> +
+ ) : ( +
+ ( + + + + handleUnlockField('savingsDepositCount')} + > + { + if (isFieldLocked('savingsDepositCount', hasCountValue(field.value))) return; + const next = e.target.value; + handleUserFieldChange('savingsDepositCount', next === '' ? '' : Number.parseInt(next, 10)); + }} + className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> + + + + + )} + /> +
+ )} + +
+ ( + + {t('new.form.savingsFirstDepositDate')} + handleFirstDepositDateChange(date ?? '')} + placeholder={commonT('ui.form.enterPlaceholder')} + open={firstDepositDatePickerOpen} + onOpenChange={setFirstDepositDatePickerOpen} + /> + + + )} + /> + {calculatedLastDepositDate && ( +
+ +

{formatDateLong(calculatedLastDepositDate, locale)}

+
+ )} +
+
+ )} + + ); +} diff --git a/src/components/ui/data-table-column-filters.tsx b/src/components/ui/data-table-column-filters.tsx index cef6bfcf..a00f1a51 100644 --- a/src/components/ui/data-table-column-filters.tsx +++ b/src/components/ui/data-table-column-filters.tsx @@ -1,8 +1,10 @@ import type { ColumnFiltersState } from '@tanstack/react-table'; import type { SetTableUrlState, TableUrlState } from '@/lib/hooks/use-table-url-state'; +import { isInactiveBooleanFilterValue } from '@/types/boolean-filter-value'; import { + BooleanFilter, DateFilter, MultiSelectFilter, NumberFilter, @@ -11,7 +13,7 @@ import { } from './data-table-column-filters/index'; type ColumnFilterConfig = { - type: 'text' | 'select' | 'multi-select' | 'number' | 'date'; + type: 'text' | 'select' | 'multi-select' | 'number' | 'date' | 'boolean'; options?: { label: string; value: string }[]; label?: string; }; @@ -28,7 +30,10 @@ interface DataTableColumnFiltersProps { }; } -function isEmptyFilterValue(value: unknown): boolean { +function isEmptyFilterValue(value: unknown, type?: ColumnFilterConfig['type']): boolean { + if (type === 'boolean') { + return isInactiveBooleanFilterValue(value); + } return value === '' || value == null || (Array.isArray(value) && value.every((v) => v === '' || v == null)); } @@ -40,10 +45,10 @@ export function DataTableColumnFilters({ }: DataTableColumnFiltersProps) { const activeFilters = controlled?.columnFilters ?? tableState?.columnFilters ?? []; - const handleFilterChange = (columnId: string, value: unknown) => { + const handleFilterChange = (columnId: string, value: unknown, type?: ColumnFilterConfig['type']) => { const filters = activeFilters.filter((filter) => filter.id !== columnId); - if (!isEmptyFilterValue(value)) { + if (!isEmptyFilterValue(value, type)) { filters.push({ id: columnId, value }); } @@ -68,6 +73,15 @@ export function DataTableColumnFilters({
{(() => { switch (filterConfig.type) { + case 'boolean': + return ( + { + handleFilterChange(columnId, value, 'boolean'); + }} + /> + ); case 'select': return ( void; + size?: FilterFieldSize; +} + +export function BooleanFilter({ filterState, onFilterChange, size = 'default' }: BooleanFilterProps) { + return ; +} diff --git a/src/components/ui/data-table-column-filters/index.ts b/src/components/ui/data-table-column-filters/index.ts index 25da09c7..61039e3c 100644 --- a/src/components/ui/data-table-column-filters/index.ts +++ b/src/components/ui/data-table-column-filters/index.ts @@ -1,3 +1,4 @@ +export * from './boolean-filter'; export * from './date-filter'; export * from './multi-select-filter'; export * from './number-filter'; diff --git a/src/components/ui/data-table-header.tsx b/src/components/ui/data-table-header.tsx index eadbe48b..a75b8273 100644 --- a/src/components/ui/data-table-header.tsx +++ b/src/components/ui/data-table-header.tsx @@ -36,9 +36,10 @@ interface DataTableHeaderProps { showFilter?: boolean; columnFilters?: { [key: string]: { - type: 'text' | 'select' | 'multi-select' | 'number' | 'date'; + type: 'text' | 'select' | 'multi-select' | 'number' | 'date' | 'boolean'; options?: { label: string; value: string }[]; label?: string; + allowEmpty?: boolean; }; }; viewType?: ViewType; diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index 6df08674..9e6b25b2 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -138,7 +138,7 @@ export const dateRangeFilter: FilterFn = (row, columnId, filterValue) = export type DataTableColumnFilters = { [key: string]: { - type: 'text' | 'select' | 'multi-select' | 'number' | 'date'; + type: 'text' | 'select' | 'multi-select' | 'number' | 'date' | 'boolean'; options?: { label: string; value: string }[]; label?: string; }; diff --git a/src/components/ui/date-picker-input.tsx b/src/components/ui/date-picker-input.tsx new file mode 100644 index 00000000..393ea743 --- /dev/null +++ b/src/components/ui/date-picker-input.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { Calendar as CalendarIcon, X } from 'lucide-react'; +import { useLocale } from 'next-intl'; +import { useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { FormControl } from '@/components/ui/form'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn, formatDateLong, getDateFnsLocale, toUTCDate } from '@/lib/utils'; + +interface DatePickerInputProps { + value: Date | string | '' | null | undefined; + onChange: (value: Date | null) => void; + placeholder?: string; + disabled?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + calendarDisabled?: (date: Date) => boolean; + withFormControl?: boolean; + className?: string; +} + +const hasDateValue = (value: Date | string | '' | null | undefined): value is Date | string => { + if (value == null || value === '') return false; + const date = value instanceof Date ? value : new Date(value); + return !Number.isNaN(date.getTime()); +}; + +export function DatePickerInput({ + value, + onChange, + placeholder = 'Pick a date', + disabled, + open: openProp, + onOpenChange: onOpenChangeProp, + calendarDisabled, + withFormControl = false, + className, +}: DatePickerInputProps) { + const locale = useLocale(); + const dateLocale = getDateFnsLocale(locale); + const [internalOpen, setInternalOpen] = useState(false); + const open = openProp ?? internalOpen; + + const handleOpenChange = (next: boolean) => { + if (disabled && next) return; + (onOpenChangeProp ?? setInternalOpen)(next); + }; + + const selectedDate = hasDateValue(value) ? (value instanceof Date ? value : new Date(value)) : undefined; + + const trigger = ( + + )} + +
+ + ); + + return ( + + {withFormControl ? {trigger} : trigger} + + { + onChange(toUTCDate(date)); + handleOpenChange(false); + }} + autoFocus + disabled={calendarDisabled} + locale={dateLocale} + /> + + + ); +} diff --git a/src/components/ui/form-actions.tsx b/src/components/ui/form-actions.tsx index 4e85ac4b..ce99edae 100644 --- a/src/components/ui/form-actions.tsx +++ b/src/components/ui/form-actions.tsx @@ -3,6 +3,7 @@ import { AlertTriangle } from 'lucide-react'; import type { ReactNode } from 'react'; +import { FormWarningMessage } from '@/components/form/form-warning-message'; import { useFormSanityChecksOptional } from '@/components/form/form-sanity-checks-provider'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; @@ -36,11 +37,13 @@ export function FormActions({ {warnings.map((warning) => ( - - {warning.message} + + + + ))} diff --git a/src/components/ui/form-section.tsx b/src/components/ui/form-section.tsx index eff93a14..20441f58 100644 --- a/src/components/ui/form-section.tsx +++ b/src/components/ui/form-section.tsx @@ -4,11 +4,11 @@ import { cn } from '@/lib/utils'; import { SectionCard } from '../generic/section-card'; interface FormSectionProps { - title: string; + title: string | ReactNode; /** Kurzer erklärender Text unter dem Abschnittstitel */ description?: string | ReactNode; children: ReactNode; - icon?: ReactNode; + icon?: ReactNode | null; className?: string; contentClassName?: string; /** Erweitert den Standard-Container (`space-y-4`), z. B. für volle Kartenhöhe im Grid */ @@ -28,7 +28,7 @@ export function FormSection({ } + icon={icon === undefined ? : icon} className={className} contentClassName={contentClassName} > diff --git a/src/components/ui/no-wrap.tsx b/src/components/ui/no-wrap.tsx new file mode 100644 index 00000000..09f1688e --- /dev/null +++ b/src/components/ui/no-wrap.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from 'react'; + +export function NoWrap({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/lib/calculations/loan-calculations.ts b/src/lib/calculations/loan-calculations.ts index 6120d61c..5d0a13c9 100644 --- a/src/lib/calculations/loan-calculations.ts +++ b/src/lib/calculations/loan-calculations.ts @@ -12,6 +12,8 @@ import moment, { type Moment } from 'moment'; import type { CalculationOptions } from '@/types/calculation'; import { LoanStatus, type LoanWithRelations } from '@/types/loans'; +import { calculateOutstandingDeposits } from '@/lib/loans/savings-contract'; + import { getLoanTermDays, getRepaymentPeriodDays } from './loan-duration-metrics'; import { createdAtDescSorter, transactionSorter } from '../utils/sorters'; @@ -585,6 +587,7 @@ export function calculateLoanFields(loan: LoanWithRelations & T, options: Cal interestError: numbers.toDate.interestError.toNumber(), loanTermDays: getLoanTermDays(loan, toDate), repaymentPeriodDays: getRepaymentPeriodDays(loan, toDate), + ...calculateOutstandingDeposits(loan, toDate), // add interests per year as virtual transactions transactions: loan.transactions .map((transaction) => ({ diff --git a/src/lib/dashboard/table-widget/loan-table-column-registry.ts b/src/lib/dashboard/table-widget/loan-table-column-registry.ts index 0e1f3c47..0990daa6 100644 --- a/src/lib/dashboard/table-widget/loan-table-column-registry.ts +++ b/src/lib/dashboard/table-widget/loan-table-column-registry.ts @@ -10,10 +10,12 @@ import { getLenderSortValue } from '@/lib/dashboard/table-widget/lender-table-co import { createAdditionalFieldsColumns, createAdditionalFieldDefaultColumnVisibility, + createBooleanColumn, createCurrencyColumn, createDateColumn, createDurationDaysColumn, createEnumBadgeColumn, + createNullableCurrencyColumn, createNumberColumn, createPercentageColumn, createTerminationModalitiesColumn, @@ -36,6 +38,12 @@ const LOAN_TABLE_STATIC_COLUMN_META: { id: string; labelKey: string }[] = [ { id: 'amount', labelKey: 'table.amount' }, { id: 'balance', labelKey: 'table.balance' }, { id: 'deposits', labelKey: 'table.deposits' }, + { id: 'outstandingDepositSum', labelKey: 'table.outstandingDepositSum' }, + { id: 'outstandingDepositSinceDate', labelKey: 'table.outstandingDepositSinceDate' }, + { id: 'outstandingDepositSinceDays', labelKey: 'table.outstandingDepositSinceDays' }, + { id: 'depositsCount', labelKey: 'table.depositsCount' }, + { id: 'requiredDepositsCount', labelKey: 'table.requiredDepositsCount' }, + { id: 'outstandingDepositsCount', labelKey: 'table.outstandingDepositsCount' }, { id: 'withdrawals', labelKey: 'table.withdrawals' }, { id: 'notReclaimed', labelKey: 'table.notReclaimed' }, { id: 'interestRate', labelKey: 'table.interestRate' }, @@ -49,6 +57,7 @@ const LOAN_TABLE_STATIC_COLUMN_META: { id: string; labelKey: string }[] = [ { id: 'status', labelKey: 'table.status' }, { id: 'altInterestMethod', labelKey: 'table.altInterestMethod' }, { id: 'contractStatus', labelKey: 'table.contractStatus' }, + { id: 'isSavingsContract', labelKey: 'table.isSavingsContract' }, ]; const LOAN_COLUMN_GROUP = { key: 'loan' as const, order: 0 }; @@ -106,6 +115,29 @@ export function buildLoanTableColumns( createCurrencyColumn('amount', 'table.amount', t, locale), createCurrencyColumn('balance', 'table.balance', t, locale), createCurrencyColumn('deposits', 'table.deposits', t, locale), + createNullableCurrencyColumn('outstandingDepositSum', 'table.outstandingDepositSum', t), + createDateColumn('outstandingDepositSinceDate', 'table.outstandingDepositSinceDate', t, locale), + createDurationDaysColumn( + 'outstandingDepositSinceDays', + 'table.outstandingDepositSinceDays', + t, + durationT, + ), + createNumberColumn('depositsCount', 'table.depositsCount', t, locale, { integer: true }), + createNumberColumn( + 'requiredDepositsCount', + 'table.requiredDepositsCount', + t, + locale, + { integer: true }, + ), + createNumberColumn( + 'outstandingDepositsCount', + 'table.outstandingDepositsCount', + t, + locale, + { integer: true }, + ), createCurrencyColumn('withdrawals', 'table.withdrawals', t, locale), createCurrencyColumn('notReclaimed', 'table.notReclaimed', t, locale), createPercentageColumn('interestRate', 'table.interestRate', t, locale), @@ -158,6 +190,7 @@ export function buildLoanTableColumns( } }, ), + createBooleanColumn('isSavingsContract', 'table.isSavingsContract', t, commonT), ...createAdditionalFieldsColumns( project.configuration.loanAdditionalFields, 'additionalFields', @@ -265,6 +298,8 @@ export function getLoanSortValue( return row.altInterestMethod ? commonT(`enums.interestMethod.${row.altInterestMethod}`) : ''; case 'contractStatus': return row.contractStatus ? commonT(`enums.loan.contractStatus.${row.contractStatus}`) : ''; + case 'isSavingsContract': + return row.isSavingsContract ? commonT('ui.boolean.yes') : commonT('ui.boolean.no'); default: { const value = readNestedValue(row, normalizedId); if (value instanceof Date) { @@ -290,6 +325,12 @@ export const LOAN_TABLE_COLUMN_IDS = [ 'amount', 'balance', 'deposits', + 'outstandingDepositSum', + 'outstandingDepositSinceDate', + 'outstandingDepositSinceDays', + 'depositsCount', + 'requiredDepositsCount', + 'outstandingDepositsCount', 'withdrawals', 'notReclaimed', 'interestRate', @@ -303,4 +344,5 @@ export const LOAN_TABLE_COLUMN_IDS = [ 'status', 'altInterestMethod', 'contractStatus', + 'isSavingsContract', ] as const; diff --git a/src/lib/dashboard/table-widget/transaction-table-column-registry.tsx b/src/lib/dashboard/table-widget/transaction-table-column-registry.tsx index 0e397d1a..636af7e3 100644 --- a/src/lib/dashboard/table-widget/transaction-table-column-registry.tsx +++ b/src/lib/dashboard/table-widget/transaction-table-column-registry.tsx @@ -55,6 +55,7 @@ const LOAN_TABLE_STATIC_COLUMN_META: { id: string; labelKey: string }[] = [ { id: 'status', labelKey: 'table.status' }, { id: 'altInterestMethod', labelKey: 'table.altInterestMethod' }, { id: 'contractStatus', labelKey: 'table.contractStatus' }, + { id: 'isSavingsContract', labelKey: 'table.isSavingsContract' }, ]; const DEFAULT_VISIBLE_COLUMN_IDS = [ diff --git a/src/lib/entity-filters/filter-definitions.ts b/src/lib/entity-filters/filter-definitions.ts index e9acf750..8fc2f314 100644 --- a/src/lib/entity-filters/filter-definitions.ts +++ b/src/lib/entity-filters/filter-definitions.ts @@ -14,7 +14,7 @@ import { createAdditionalFieldFilters } from '@/lib/table-column-utils'; import type { EntityFilterFieldOption, EntityFilterEntity } from '@/types/entity-filters'; import type { ProjectWithConfiguration } from '@/types/projects'; -export type DataTableColumnFilterType = 'text' | 'select' | 'multi-select' | 'number' | 'date'; +export type DataTableColumnFilterType = 'text' | 'select' | 'multi-select' | 'number' | 'date' | 'boolean'; export type DataTableColumnFilterDefinition = { type: DataTableColumnFilterType; @@ -57,6 +57,12 @@ export function buildLoanColumnFiltersMap( amount: { type: 'number', label: t('table.amount') }, balance: { type: 'number', label: t('table.balance') }, deposits: { type: 'number', label: t('table.deposits') }, + outstandingDepositSum: { type: 'number', label: t('table.outstandingDepositSum') }, + outstandingDepositSinceDate: { type: 'date', label: t('table.outstandingDepositSinceDate') }, + outstandingDepositSinceDays: { type: 'number', label: t('table.outstandingDepositSinceDays') }, + depositsCount: { type: 'number', label: t('table.depositsCount') }, + requiredDepositsCount: { type: 'number', label: t('table.requiredDepositsCount') }, + outstandingDepositsCount: { type: 'number', label: t('table.outstandingDepositsCount') }, withdrawals: { type: 'number', label: t('table.withdrawals') }, notReclaimed: { type: 'number', label: t('table.notReclaimed') }, interestRate: { type: 'number', label: t('table.interestRate') }, @@ -101,6 +107,10 @@ export function buildLoanColumnFiltersMap( value, })), }, + isSavingsContract: { + type: 'boolean', + label: t('table.isSavingsContract'), + }, ...createAdditionalFieldFilters('additionalFields', project.configuration.loanAdditionalFields), }; } diff --git a/src/lib/entity-filters/filter-matchers.ts b/src/lib/entity-filters/filter-matchers.ts index 6fe302a0..64ab6351 100644 --- a/src/lib/entity-filters/filter-matchers.ts +++ b/src/lib/entity-filters/filter-matchers.ts @@ -1,5 +1,6 @@ import type { DataTableColumnFilterType } from '@/lib/entity-filters/filter-definitions'; import { resolveEntityDateFilterBounds } from '@/lib/entity-filters/resolve-date-filter-range'; +import { parseBooleanFilterValue } from '@/types/boolean-filter-value'; export function matchesTextFilter(value: unknown, filterValue: unknown): boolean { if (value === null || value === undefined) { @@ -82,6 +83,15 @@ export function matchesMultiSelectFilter(value: unknown, filterValue: unknown): return filterValue.includes(String(value)); } +export function matchesBooleanFilter(value: unknown, filterValue: unknown): boolean { + const parsed = parseBooleanFilterValue(filterValue); + if (parsed === '') { + return true; + } + const normalized = value === true || value === 'true' ? 'true' : 'false'; + return normalized === parsed; +} + export type FilterMatchOptions = { referenceDate?: Date; }; @@ -101,6 +111,8 @@ export function matchesFilterByType( return matchesSelectFilter(value, filterValue); case 'multi-select': return matchesMultiSelectFilter(value, filterValue); + case 'boolean': + return matchesBooleanFilter(value, filterValue); default: return matchesTextFilter(value, filterValue); } diff --git a/src/lib/entity-filters/get-filter-value.ts b/src/lib/entity-filters/get-filter-value.ts index 8df4cc17..f859f942 100644 --- a/src/lib/entity-filters/get-filter-value.ts +++ b/src/lib/entity-filters/get-filter-value.ts @@ -153,6 +153,8 @@ export function getLoanFilterValue( return loan.altInterestMethod; case 'contractStatus': return loan.contractStatus; + case 'isSavingsContract': + return loan.isSavingsContract ? 'true' : 'false'; case 'balance': case 'deposits': case 'withdrawals': diff --git a/src/lib/loans/loan-end-date-sanity-check.ts b/src/lib/loans/loan-end-date-sanity-check.ts new file mode 100644 index 00000000..9f97d9ec --- /dev/null +++ b/src/lib/loans/loan-end-date-sanity-check.ts @@ -0,0 +1,77 @@ +import { DurationType, TerminationType } from '@prisma/client'; +import { isValid } from 'date-fns'; +import moment from 'moment'; +import { resolveSavingsLastDepositDate } from '@/lib/loans/savings-contract'; + +export type LoanEndDateSanityCheckInput = { + terminationType: TerminationType; + signDate: Date | string | null | undefined; + endDate: Date | string | null | undefined; + duration: number | '' | null | undefined; + durationType: DurationType | null | undefined; + isSavingsContract: boolean; + savingsFirstDepositDate: Date | string | null | undefined; + savingsLastDepositDate: Date | string | null | undefined; + savingsDepositCount: number | '' | null | undefined; +}; + +export type LoanEndDateSanityCheckResult = 'beforeSavingsLastDepositDate'; + +const toDate = (value: Date | string | null | undefined): Date | null => { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return isValid(date) ? date : null; +}; + +const isOnOrAfter = (date: Date, reference: Date): boolean => + !moment(date).startOf('day').isBefore(moment(reference).startOf('day')); + +export function resolveContractEndDate(input: LoanEndDateSanityCheckInput): Date | null { + if (input.terminationType === TerminationType.ENDDATE) { + return toDate(input.endDate); + } + + if (input.terminationType === TerminationType.DURATION) { + const signDate = toDate(input.signDate); + if (!signDate || !input.duration || !input.durationType) return null; + + const calculated = moment(signDate).add( + Number(input.duration), + input.durationType === DurationType.MONTHS ? 'months' : 'years', + ); + return calculated.isValid() ? calculated.toDate() : null; + } + + return null; +} + +export function hasFixedTermEndDate(terminationType: TerminationType): boolean { + return terminationType === TerminationType.ENDDATE || terminationType === TerminationType.DURATION; +} + +export function evaluateLoanEndDateSanityChecks( + input: LoanEndDateSanityCheckInput, +): LoanEndDateSanityCheckResult[] { + if (!input.isSavingsContract || !hasFixedTermEndDate(input.terminationType)) { + return []; + } + + const contractEndDate = resolveContractEndDate(input); + if (!contractEndDate) return []; + + const depositCount = typeof input.savingsDepositCount === 'number' ? input.savingsDepositCount : null; + const results: LoanEndDateSanityCheckResult[] = []; + + const savingsLastDepositDate = resolveSavingsLastDepositDate( + toDate(input.savingsFirstDepositDate), + toDate(input.savingsLastDepositDate), + depositCount, + toDate(input.signDate), + ); + + if (savingsLastDepositDate && !isOnOrAfter(contractEndDate, savingsLastDepositDate)) { + results.push('beforeSavingsLastDepositDate'); + } + + return results; +} diff --git a/src/lib/loans/savings-contract.ts b/src/lib/loans/savings-contract.ts new file mode 100644 index 00000000..b94f50e1 --- /dev/null +++ b/src/lib/loans/savings-contract.ts @@ -0,0 +1,265 @@ +import { type Loan, SavingsRateType, type Transaction, TransactionType } from '@prisma/client'; +import moment from 'moment'; + +export type ExpectedTransaction = { date: Date; amount: number | null }; + +export type CoverageEntry = ExpectedTransaction & { + cumulativeExpected: number | null; + covered: boolean; + outstandingAmount: number | null; +}; + +export type LoanCoverage = { + timeline: CoverageEntry[]; + netReceived: number; + depositsCount: number; + amountsKnown: boolean; +}; + +export type OutstandingDepositMetrics = { + depositsCount: number; + requiredDepositsCount: number; + outstandingDepositsCount: number; + outstandingDepositSum: number | null; + outstandingDepositSinceDate: Date | null; + outstandingDepositSinceDays: number | null; +}; + +type LoanForSchedule = Pick< + Loan, + | 'amount' + | 'signDate' + | 'isSavingsContract' + | 'savingsRateType' + | 'savingsMonthlyAmount' + | 'savingsDepositCount' + | 'savingsFirstDepositDate' + | 'savingsLastDepositDate' +>; + +type LoanForOutstandingDeposits = LoanForSchedule & { + transactions: Pick[]; +}; + +type TransactionForCoverage = Pick; + +const MONEY_EPSILON = 0.005; + +const round2 = (value: number) => Math.round(value * 100) / 100; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +const sumUp = (items: T[], getValue: (item: T) => number | null | undefined): number => + items.reduce((sum, item) => sum + (getValue(item) ?? 0), 0); + +const isOnOrBefore = (date: Date, toDate: Date) => + moment(date).startOf('day').isSameOrBefore(moment(toDate).startOf('day')); + +export const getDepositsUntilDate = (transactions: TransactionForCoverage[], toDate: Date): TransactionForCoverage[] => + transactions.filter( + (transaction) => transaction.type === TransactionType.DEPOSIT && isOnOrBefore(transaction.date, toDate), + ); + +export const getWithdrawalsUntilDate = ( + transactions: TransactionForCoverage[], + toDate: Date, +): TransactionForCoverage[] => + transactions.filter( + (transaction) => transaction.type === TransactionType.WITHDRAWAL && isOnOrBefore(transaction.date, toDate), + ); + +export const getNetReceived = (deposits: TransactionForCoverage[], withdrawals: TransactionForCoverage[]): number => { + const depositSum = sumUp(deposits, (transaction) => transaction.amount); + const withdrawalSum = sumUp(withdrawals, (transaction) => transaction.amount); + return depositSum - withdrawalSum; +}; + +export const getDueExpectedTransactions = (schedule: ExpectedTransaction[], toDate: Date): ExpectedTransaction[] => + schedule.filter((entry) => isOnOrBefore(entry.date, toDate)).sort((a, b) => a.date.getTime() - b.date.getTime()); + +export const areDueAmountsKnown = (due: ExpectedTransaction[]): boolean => + due.length > 0 && due.every((entry) => entry.amount != null); + +export const buildAmountBasedCoverageTimeline = (due: ExpectedTransaction[], netReceived: number): CoverageEntry[] => { + const available = Math.max(0, netReceived); + let cumulative = 0; + + return due.map((entry) => { + const amount = entry.amount as number; + cumulative += amount; + const covered = cumulative <= available + MONEY_EPSILON; + const outstandingAmount = clamp(cumulative - available, 0, amount); + + return { + date: entry.date, + amount: entry.amount, + cumulativeExpected: cumulative, + covered, + outstandingAmount, + }; + }); +}; + +export const buildCountBasedCoverageTimeline = ( + due: ExpectedTransaction[], + depositsCount: number, + withdrawalCount: number, +): CoverageEntry[] => { + const coveredCount = Math.max(0, depositsCount - withdrawalCount); + + return due.map((entry, index) => ({ + date: entry.date, + amount: entry.amount, + cumulativeExpected: null, + covered: index < coveredCount, + outstandingAmount: null, + })); +}; + +const toValidDate = (value: Date | string | null | undefined) => { + if (!value) return null; + const dateMoment = moment(value); + return dateMoment.isValid() ? dateMoment.toDate() : null; +}; + +export const resolveSavingsFirstDepositDate = ( + firstDepositDate: Date | string | null | undefined, + signDate: Date | string | null | undefined, +) => toValidDate(firstDepositDate) ?? toValidDate(signDate); + +export const calculateSavingsLastDepositDate = (firstDepositDate: Date, depositCount: number) => { + const firstMoment = moment(firstDepositDate); + if (!firstMoment.isValid()) return null; + if (!Number.isFinite(depositCount) || depositCount < 1) return null; + + const lastMoment = firstMoment.clone().add(depositCount - 1, 'months'); + return lastMoment.isValid() ? lastMoment.toDate() : null; +}; + +export const calculateSavingsFirstDepositDate = (lastDepositDate: Date, depositCount: number) => { + const lastMoment = moment(lastDepositDate); + if (!lastMoment.isValid()) return null; + if (!Number.isFinite(depositCount) || depositCount < 1) return null; + + const firstMoment = lastMoment.clone().subtract(depositCount - 1, 'months'); + return firstMoment.isValid() ? firstMoment.toDate() : null; +}; + +export const calculateSavingsDepositCountFromDates = (firstDepositDate: Date, lastDepositDate: Date) => { + const firstMoment = moment(firstDepositDate); + const lastMoment = moment(lastDepositDate); + if (!firstMoment.isValid() || !lastMoment.isValid()) return null; + if (lastMoment.isBefore(firstMoment, 'day')) return null; + + if (lastMoment.isSame(firstMoment, 'day')) return 1; + + const monthsBetween = Math.max(0, lastMoment.diff(firstMoment, 'months') - 1); + return 2 + monthsBetween; +}; + +export const calculateSavingsMonthlyAmount = (loanAmount: number, depositCount: number) => { + if (!Number.isFinite(loanAmount) || loanAmount <= 0) return null; + if (!Number.isFinite(depositCount) || depositCount < 1) return null; + return round2(loanAmount / depositCount); +}; + +export const calculateSavingsDepositCountFromMonthlyAmount = (loanAmount: number, monthlyAmount: number) => { + if (!Number.isFinite(loanAmount) || loanAmount <= 0) return null; + if (!Number.isFinite(monthlyAmount) || monthlyAmount <= 0) return null; + return Math.ceil(loanAmount / monthlyAmount); +}; + +export const resolveSavingsLastDepositDate = ( + firstDepositDate: Date | string | null | undefined, + lastDepositDate: Date | string | null | undefined, + depositCount: number | null | undefined, + signDate?: Date | string | null | undefined, +) => { + const resolvedLast = toValidDate(lastDepositDate); + if (resolvedLast) return resolvedLast; + + const resolvedFirst = resolveSavingsFirstDepositDate(firstDepositDate, signDate); + if (resolvedFirst && depositCount != null && depositCount >= 1) { + return calculateSavingsLastDepositDate(resolvedFirst, depositCount); + } + + return null; +}; + +export const getExpectedDepositSchedule = (loan: LoanForSchedule): ExpectedTransaction[] => { + const firstDepositDate = resolveSavingsFirstDepositDate(loan.savingsFirstDepositDate, loan.signDate); + + if (loan.isSavingsContract && firstDepositDate && loan.savingsDepositCount != null && loan.savingsDepositCount >= 1) { + const firstMoment = moment(firstDepositDate); + const amount = + loan.savingsRateType === SavingsRateType.FIXED && loan.savingsMonthlyAmount != null + ? loan.savingsMonthlyAmount + : null; + + return Array.from({ length: loan.savingsDepositCount }, (_, index) => ({ + date: firstMoment.clone().add(index, 'months').toDate(), + amount, + })); + } + + return [{ date: loan.signDate, amount: loan.amount }]; +}; + +export const captureLoanCoverage = ( + schedule: ExpectedTransaction[], + transactions: TransactionForCoverage[], + toDate: Date, +): LoanCoverage => { + const deposits = getDepositsUntilDate(transactions, toDate); + const withdrawals = getWithdrawalsUntilDate(transactions, toDate); + + const depositsCount = deposits.length; + const netReceived = getNetReceived(deposits, withdrawals); + + const due = getDueExpectedTransactions(schedule, toDate); + + const amountsKnown = areDueAmountsKnown(due); + const timeline = amountsKnown + ? buildAmountBasedCoverageTimeline(due, netReceived) + : buildCountBasedCoverageTimeline(due, depositsCount, withdrawals.length); + + return { + timeline, + netReceived, + depositsCount, + amountsKnown, + }; +}; + +export const calculateOutstandingDeposits = ( + loan: LoanForOutstandingDeposits, + toDate: Date, +): OutstandingDepositMetrics => { + const schedule = getExpectedDepositSchedule(loan); + const coverage = captureLoanCoverage(schedule, loan.transactions, toDate); + const outstanding = coverage.timeline.filter((entry) => !entry.covered); + const outstandingDepositSinceDate = outstanding[0]?.date ?? null; + + let outstandingDepositSinceDays: number | null = null; + if (outstandingDepositSinceDate) { + const toMoment = moment(toDate).startOf('day'); + const sinceMoment = moment(outstandingDepositSinceDate).startOf('day'); + const daysSince = toMoment.diff(sinceMoment, 'days'); + outstandingDepositSinceDays = Math.max(0, daysSince); + } + + let outstandingDepositSum: number | null = null; + if (coverage.amountsKnown) { + const totalOutstanding = sumUp(outstanding, (entry) => entry.outstandingAmount); + outstandingDepositSum = round2(totalOutstanding); + } + + return { + depositsCount: coverage.depositsCount, + requiredDepositsCount: schedule.length, + outstandingDepositsCount: outstanding.length, + outstandingDepositSum, + outstandingDepositSinceDate, + outstandingDepositSinceDays, + }; +}; diff --git a/src/lib/schemas/loan.ts b/src/lib/schemas/loan.ts index 284852ba..2a35db2e 100644 --- a/src/lib/schemas/loan.ts +++ b/src/lib/schemas/loan.ts @@ -1,5 +1,5 @@ -import type { ContractStatus, DurationType, InterestMethod } from '@prisma/client'; -import { TerminationType } from '@prisma/client'; +import type { ContractStatus, DurationType, InterestMethod, SavingsRateType } from '@prisma/client'; +import { SavingsRateType as SavingsRateTypeEnum, TerminationType } from '@prisma/client'; import { z } from 'zod'; import { @@ -11,6 +11,7 @@ import { optionalIntSchema, optionalNumberSchema, periodTypeEnum, + selectEnumOptional, } from './common'; // Define the loan form schema based on the Prisma model @@ -66,6 +67,56 @@ export const loanTerminationSchema = z.object({ durationType: periodTypeEnum.nullable().optional(), }); +export const savingsRateTypeEnum = selectEnumOptional(SavingsRateTypeEnum); + +export const loanSavingsSchema = z.object({ + isSavingsContract: z.boolean().default(false), + savingsRateType: savingsRateTypeEnum.nullable().optional(), + savingsMonthlyAmount: optionalNumberSchema, + savingsDepositCount: optionalIntSchema, + savingsFirstDepositDate: createDateSchema(false), + savingsLastDepositDate: createDateSchema(false), +}); + +export type LoanSavingsData = z.infer; + +export const validateSavings = (data: LoanSavingsData, ctx: z.RefinementCtx) => { + if (!data.isSavingsContract) return; + + if (!data.savingsRateType) { + ctx.addIssue({ + code: 'custom', + message: 'validation.common.required', + path: ['savingsRateType'], + }); + } + + if (data.savingsRateType === 'FIXED') { + if (!data.savingsMonthlyAmount) { + ctx.addIssue({ + code: 'custom', + message: 'validation.common.required', + path: ['savingsMonthlyAmount'], + }); + } + if (!data.savingsDepositCount) { + ctx.addIssue({ + code: 'custom', + message: 'validation.common.required', + path: ['savingsDepositCount'], + }); + } + } else if (data.savingsRateType === 'VARYING') { + if (!data.savingsDepositCount) { + ctx.addIssue({ + code: 'custom', + message: 'validation.common.required', + path: ['savingsDepositCount'], + }); + } + } +}; + export const loanFormSchema = z .object({ // General Information @@ -78,12 +129,16 @@ export const loanFormSchema = z // Termination Information ...loanTerminationSchema.shape, + // Savings Contract Information + ...loanSavingsSchema.shape, + // Additional Information altInterestMethod: interestMethodEnum.nullable().optional(), contractStatus: contractStatusEnum.default('PENDING'), additionalFields: additionalFieldValuesSchema.default({}).optional().nullable(), }) - .superRefine(validateTermination); + .superRefine(validateTermination) + .superRefine(validateSavings); export type LoanFormData = z.infer; // We keep two types intentionally: @@ -101,6 +156,12 @@ export type LoanFormClientData = { terminationPeriodType: DurationType | null | undefined; duration: '' | number | null; durationType: DurationType | null | undefined; + isSavingsContract: boolean; + savingsRateType: SavingsRateType | null | undefined; + savingsMonthlyAmount: string; + savingsDepositCount: '' | number | null; + savingsFirstDepositDate: Date | '' | null; + savingsLastDepositDate: Date | '' | null; altInterestMethod: InterestMethod | null | undefined; contractStatus: ContractStatus; additionalFields: Record | null | undefined; diff --git a/src/lib/table-column-utils.tsx b/src/lib/table-column-utils.tsx index 215bbc7a..664de393 100644 --- a/src/lib/table-column-utils.tsx +++ b/src/lib/table-column-utils.tsx @@ -1,11 +1,12 @@ import type { Lender, Loan } from '@prisma/client'; import type { CellContext, ColumnDef, Row, VisibilityState } from '@tanstack/react-table'; -import moment from 'moment'; import type { ReactNode } from 'react'; import { Badge } from '@/components/ui/badge'; import type { ColumnGroupMeta, DataTableColumnFilters } from '@/components/ui/data-table'; import { DataTableColumnHeader } from '@/components/ui/data-table-column-header'; +import { matchesBooleanFilter } from '@/lib/entity-filters/filter-matchers'; import { formatDurationDays } from '@/lib/format-duration'; + import { formatCurrency, formatPercentage, getLenderName, NumberParser, resolveIntlLocaleForDates } from '@/lib/utils'; import { type AdditionalFieldConfig, AdditionalFieldType, AdditionalNumberFormat } from './schemas/common'; @@ -21,6 +22,12 @@ export function compoundTextFilter(row: Row, columnId: string, filterValue return searchValue.includes(searchFilter); } +// Define the custom filter function for boolean fields +export function booleanFilter(row: Row, columnId: string, filterValue: unknown) { + const raw = row.getValue(columnId) === true ? 'true' : 'false'; + return matchesBooleanFilter(raw, filterValue); +} + // Define the custom filter function for enum fields export function enumFilter(row: Row, columnId: string, filterValue: unknown) { const value = row.getValue(columnId); @@ -292,6 +299,33 @@ export function createCurrencyColumn( column.filterFn = 'inNumberRange'; return mergeExportMeta(column, { type: 'currency' }); } + +export function createNullableCurrencyColumn( + accessorKey: string, + headerKey: string | undefined, + t: (key: string) => string, +): ColumnDef { + const parser = new NumberParser('de-DE'); + const column = createColumn( + { + accessorKey, + header: headerKey, + align: 'right', + cell: ({ row }) => { + const rawValue = row.getValue(accessorKey); + if (rawValue === null || rawValue === undefined) { + return ''; + } + const value = parser.parse(String(rawValue)) || 0; + return
{formatCurrency(value)}
; + }, + }, + t, + ); + + column.filterFn = 'inNumberRange'; + return mergeExportMeta(column, { type: 'currency' }); +} export function createDateColumn( accessorKey: string, headerKey: string | undefined, @@ -445,6 +479,36 @@ export function createEnumBadgeColumn( }, }); } + +export function createBooleanColumn( + accessorKey: string, + headerKey: string, + t: (key: string) => string, + commonT: (key: string) => string, +): ColumnDef { + const formatBoolean = (value: unknown) => (value === true ? commonT('ui.boolean.yes') : commonT('ui.boolean.no')); + + const column = createColumn( + { + accessorKey, + header: headerKey, + cell: ({ row }) => formatBoolean(row.getValue(accessorKey)), + filterFn: booleanFilter, + sortingFn: (rowA, rowB, columnId) => { + const a = rowA.getValue(columnId) === true ? 1 : 0; + const b = rowB.getValue(columnId) === true ? 1 : 0; + return a - b; + }, + }, + t, + ); + + return mergeExportMeta(column, { + type: 'text', + getValue: (row) => formatBoolean((row as Record)[accessorKey]), + }); +} + export function createTerminationTypeColumn( t: (key: string) => string, commonT: (key: string) => string, @@ -802,6 +866,12 @@ export function createAdditionalFieldFilters( label: field.name, }; } + if (field.type === AdditionalFieldType.BOOLEAN) { + filters[`${accessorKey}.${field.id}`] = { + type: 'boolean' as const, + label: field.name, + }; + } }); return filters; @@ -875,11 +945,7 @@ export function formatTerminationModalities( case 'DURATION': { if (!data.duration || !data.durationType) return '-'; const duration = `${data.duration} ${durationUnitLabel(data.durationType)}`; - const calculatedEndDate = moment(data.signDate) - .add(data.duration, data.durationType === 'MONTHS' ? 'months' : 'years') - .toDate(); - const formatted = (formatDate ?? defaultFormatDate)(calculatedEndDate); - return commonT('enums.loan.terminationModalities.DURATION', { duration, date: formatted || '-' }); + return commonT('enums.loan.terminationModalities.DURATION', { duration }); } case 'TERMINATION': { if (!data.terminationPeriod || !data.terminationPeriodType) return '-'; diff --git a/src/lib/templates/merge-tags.ts b/src/lib/templates/merge-tags.ts index d23183dd..b528bf9c 100644 --- a/src/lib/templates/merge-tags.ts +++ b/src/lib/templates/merge-tags.ts @@ -54,11 +54,22 @@ export const LOAN_FIELDS = [ 'terminationDateLong', 'terminationType', 'contractStatus', + 'isSavingsContract', + 'savingsRateType', + 'savingsMonthlyAmount', + 'savingsDepositCount', + 'savingsFirstDepositDate', + 'savingsFirstDepositDateLong', + 'savingsLastDepositDate', + 'savingsLastDepositDateLong', + 'savingsSummary', // Calculated fields from loan-calculations.ts 'status', 'balance', 'interest', 'deposits', + 'depositsCount', + 'savingsPaymentStatus', 'withdrawals', 'interestPaid', 'interestError', @@ -182,10 +193,21 @@ export const FIELD_TYPES: Record = { 'loan.terminationDateLong': 'date', 'loan.terminationType': 'enum', 'loan.contractStatus': 'enum', + 'loan.isSavingsContract': 'boolean', + 'loan.savingsRateType': 'enum', + 'loan.savingsMonthlyAmount': 'currency', + 'loan.savingsDepositCount': 'number', + 'loan.savingsFirstDepositDate': 'date', + 'loan.savingsFirstDepositDateLong': 'date', + 'loan.savingsLastDepositDate': 'date', + 'loan.savingsLastDepositDateLong': 'date', + 'loan.savingsSummary': 'string', 'loan.status': 'enum', 'loan.balance': 'currency', 'loan.interest': 'currency', 'loan.deposits': 'currency', + 'loan.depositsCount': 'number', + 'loan.savingsPaymentStatus': 'string', 'loan.withdrawals': 'currency', 'loan.interestPaid': 'currency', 'loan.interestError': 'currency', diff --git a/src/lib/templates/template-data.ts b/src/lib/templates/template-data.ts index 72b0403b..7a8a1a6e 100644 --- a/src/lib/templates/template-data.ts +++ b/src/lib/templates/template-data.ts @@ -1,19 +1,22 @@ import { Prisma, type TemplateDataset, type Transaction, TransactionType } from '@prisma/client'; +import { createTranslator } from 'next-intl'; import { calculateLenderFields } from '@/lib/calculations/lender-calculations'; import { calculateLoanFields, calculateLoanPerYear } from '@/lib/calculations/loan-calculations'; import { db } from '@/lib/db'; -import { getSoliloanProjectName } from '@/lib/project-name'; +import { resolveSavingsFirstDepositDate, resolveSavingsLastDepositDate } from '@/lib/loans/savings-contract'; import { lenderFilesRelation, lenderNotesRelation, loanFilesRelation, loanNotesRelation, } from '@/lib/prisma/notes-files-relations'; +import { getSoliloanProjectName } from '@/lib/project-name'; import { withSystemMergeData } from '@/lib/templates/system-merge-links'; import { formatCurrency, formatDateLong, formatDateShort, formatPercentage, getLenderName } from '@/lib/utils'; import { parseAdditionalFields } from '@/lib/utils/additional-fields'; import { transactionSorter } from '@/lib/utils/sorters'; +import deDashboardMessages from '@/messages/de/dashboard.json'; import type { LenderWithRelations } from '@/types/lenders'; import type { LoanWithRelations } from '@/types/loans'; @@ -254,9 +257,17 @@ type TemplateLoanRecord = Record & { signDate: Date | string | null; endDate: Date | string | null; terminationDate: Date | string | null; + isSavingsContract?: boolean; + savingsRateType?: string | null; + savingsMonthlyAmount?: number | null; + savingsDepositCount?: number | null; + savingsFirstDepositDate?: Date | string | null; + savingsLastDepositDate?: Date | string | null; balance: number; interest: number; deposits: number; + depositsCount?: number; + requiredDepositsCount?: number; withdrawals: number; interestPaid: number; interestError: number; @@ -368,7 +379,53 @@ function buildTransactionsYearlyList( return [opening, ...middle, closing]; } +function savingsRateTypeLabel(rateType: string | null | undefined) { + if (rateType === 'FIXED') return 'Feste Rate'; + if (rateType === 'VARYING') return 'Variable Raten'; + return ''; +} + +function formatSavingsSummary(loan: TemplateLoanRecord, locale: string) { + if (!loan.isSavingsContract || loan.savingsDepositCount == null) return ''; + + const t = createTranslator({ + locale: 'de', + messages: deDashboardMessages, + namespace: 'loans.table', + }); + + if (loan.savingsRateType === 'FIXED' && loan.savingsMonthlyAmount != null) { + return t('savingsContractFixedSummary', { + months: loan.savingsDepositCount, + amount: formatCurrency(loan.savingsMonthlyAmount, locale), + }); + } + + return t('savingsContractSummary', { months: loan.savingsDepositCount }); +} + +function formatSavingsPaymentStatus(loan: TemplateLoanRecord) { + if (!loan.isSavingsContract) return ''; + + const required = loan.requiredDepositsCount ?? loan.savingsDepositCount; + if (required == null) return ''; + + return `${loan.depositsCount ?? 0} von ${required} Raten eingezahlt`; +} + function formatLoanFields(loan: TemplateLoanRecord, locale: string) { + const resolvedFirstDepositDate = loan.isSavingsContract + ? resolveSavingsFirstDepositDate(loan.savingsFirstDepositDate, loan.signDate) + : null; + const resolvedLastDepositDate = loan.isSavingsContract + ? resolveSavingsLastDepositDate( + loan.savingsFirstDepositDate, + loan.savingsLastDepositDate, + loan.savingsDepositCount, + loan.signDate, + ) + : null; + return { ...loan, amount: formatCurrency(loan.amount, locale), @@ -380,9 +437,24 @@ function formatLoanFields(loan: TemplateLoanRecord, locale: string) { terminationDate: formatDateShort(loan.terminationDate, locale), terminationDateLong: formatDateLong(loan.terminationDate, locale), contractStatus: loan.contractStatus === 'COMPLETED' ? 'Abgeschlossen' : 'Laufend', + isSavingsContract: loan.isSavingsContract ? 'Ja' : 'Nein', + savingsRateType: loan.isSavingsContract ? savingsRateTypeLabel(loan.savingsRateType) : '', + savingsMonthlyAmount: + loan.isSavingsContract && loan.savingsRateType === 'FIXED' + ? formatCurrency(loan.savingsMonthlyAmount, locale) + : '', + savingsDepositCount: + loan.isSavingsContract && loan.savingsDepositCount != null ? String(loan.savingsDepositCount) : '', + savingsFirstDepositDate: loan.isSavingsContract ? formatDateShort(resolvedFirstDepositDate, locale) : '', + savingsFirstDepositDateLong: loan.isSavingsContract ? formatDateLong(resolvedFirstDepositDate, locale) : '', + savingsLastDepositDate: loan.isSavingsContract ? formatDateShort(resolvedLastDepositDate, locale) : '', + savingsLastDepositDateLong: loan.isSavingsContract ? formatDateLong(resolvedLastDepositDate, locale) : '', + savingsSummary: formatSavingsSummary(loan, locale), balance: formatCurrency(loan.balance, locale), interest: formatCurrency(loan.interest, locale), deposits: formatCurrency(loan.deposits, locale), + depositsCount: String(loan.depositsCount ?? 0), + savingsPaymentStatus: formatSavingsPaymentStatus(loan), withdrawals: formatCurrency(loan.withdrawals, locale), interestPaid: formatCurrency(loan.interestPaid, locale), interestError: formatCurrency(loan.interestError, locale), diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c3bcd51c..faadc2f9 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -75,6 +75,12 @@ export function getDateFnsLocale(locale: string) { return enUS; } +/** Normalize a calendar date to UTC midnight (date-only, no local timezone shift). */ +export function toUTCDate(date: Date | undefined | null): Date | null { + if (!date) return null; + return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0)); +} + /** Template-friendly short date (e.g. 09.04.2026 in de-DE). */ export function formatDateShort(date: Date | string | null | undefined, locale: string): string { if (!date) return ''; diff --git a/src/messages/de/common.json b/src/messages/de/common.json index 93d4a31b..e85e4f26 100644 --- a/src/messages/de/common.json +++ b/src/messages/de/common.json @@ -76,6 +76,10 @@ "loading": "Wird geladen...", "noResults": "Keine Ergebnisse gefunden" }, + "boolean": { + "yes": "Ja", + "no": "Nein" + }, "form": { "required": "Erforderlich", "optional": "Optional", @@ -175,7 +179,7 @@ "terminationModalities": { "ENDDATE": "Laufzeit bis {date}", "TERMINATION": "Kündigungsfrist {duration}", - "DURATION": "Laufzeit {duration} (bis {date})" + "DURATION": "Laufzeit {duration}" }, "durationUnit": { "MONTHS": "Monate", diff --git a/src/messages/de/dashboard.json b/src/messages/de/dashboard.json index 190965c0..74dd0c1a 100644 --- a/src/messages/de/dashboard.json +++ b/src/messages/de/dashboard.json @@ -800,6 +800,7 @@ "durationType": "Laufzeiteinheit", "altInterestMethod": "Alternative Zinsmethode", "contractStatus": "Vertragsstatus", + "isSavingsContract": "Ansparvertrag", "signDate": "Vertragsdatum", "repayDate": "Rückzahlungsfrist", "loanTerm": "Kreditlaufzeit", @@ -810,7 +811,31 @@ "files": "Dateien", "notes": "Notizen", "deposits": "Einzahlungen", + "outstandingDepositSum": "Ausstehender Geldeingang (Betrag)", + "outstandingDepositSinceDate": "Ausstehender Geldeingang (Seit Datum)", + "outstandingDepositSinceDays": "Ausstehender Geldeingang (Seit Tagen)", + "depositsCount": "Geldeingänge (Anzahl)", + "requiredDepositsCount": "Benötigte Geldeingänge (Anzahl)", + "outstandingDepositsCount": "Ausstehende Geldeingänge (Anzahl)", "withdrawals": "Auszahlungen", + "savingsRateType": "Einzahlungsart", + "savingsMonthlyAmount": "Monatlicher Betrag", + "savingsDepositCountFixed": "Anzahl der Einzahlungen", + "savingsDepositCountVarying": "Anzahl erwarteter Einzahlungen", + "savingsFirstDepositDate": "Erste Einzahlung (erwartetes Datum)", + "savingsLastDepositDate": "Letzte Einzahlung", + "savingsLastDeposit": "Letzte Einzahlung:", + "savingsRuntime": "Laufzeit:", + "savingsContractSummaryLabel": "Ansparvertrag:", + "paymentStatus": "Zahlungstatus:", + "savingsContractFixedSummary": "Ansparvertrag mit {months, plural, one {# Rate} other {# Raten}} zu {amount}", + "savingsContractSummary": "Ansparvertrag mit {months, plural, one {# Rate} other {# Raten}}", + "savingsDepositPeriod": "Einzahlungszeitraum", + "savingsDepositReceipts": "Einzahlungsvorgänge", + "savingsInstallmentsPaid": "{paid} von {required} Raten eingezahlt", + "paymentOutstandingSince": "Zahlung seit {days} Tagen ausstehend", + "paymentOutstanding": "Zahlung ausstehend", + "paymentNotOutstanding": "Keine Zahlung ausstehend", "interestError": "Fehlerkorrektur", "currentBalance": "Kontostand", "notReclaimed": "Erlassungen", @@ -820,6 +845,7 @@ "interestRateAvg": "Zinssatz (durchschnittlich)", "amountTotal": "Kredithöhe (gesamt)", "for": "zu", + "until": "bis", "statistics": "Übersicht" }, "new": { @@ -844,6 +870,23 @@ "investmentTypeSearchPlaceholder": "Anlageart suchen…", "noInvestmentTypes": "Keine Anlagearten gefunden", "loadingInvestmentTypes": "Anlagearten werden geladen…", + "savingsInfo": "Ansparvertrag", + "savingsContract": "Ansparvertrag", + "savingsRateType": "Einzahlungsart", + "savingsRateTypeFixed": "Feste Rate", + "savingsRateTypeVarying": "Variable Raten", + "savingsMonthlyAmount": "Monatlicher Betrag", + "savingsDepositCountFixed": "Anzahl der Einzahlungen", + "savingsDepositCountVarying": "Anzahl erwarteter Einzahlungen", + "savingsFirstDepositDate": "Erste Einzahlung (erwartetes Datum)", + "savingsLastDepositDate": "Letzte Einzahlung", + "savingsFieldDefined": "Manuell festgelegt", + "savingsFieldDerived": "Automatisch berechnet", + "savingsFieldUnlock": "Zum Bearbeiten entsperren", + "savingsCalculateCount": "Anzahl berechnen", + "savingsCalculateCountError": "Zur Berechung ist der Vertragswert und der monatlichen Betrag nötig.", + "savingsLastDeposit": "Letzte Einzahlung: {date}", + "savingsRuntime": "Laufzeit: {months} Monate", "terminationInfo": "Kündigungsinformationen", "contractEnd": "Vertragsende", "contractEndByCancellation": "Durch Kündigung", @@ -875,7 +918,8 @@ }, "sanityChecks": { "investmentTypeCapacityExceededTotalAmount": "Die Kapazität der Anlageart ist überschritten ({used} von maximal {limit}).", - "investmentTypeCapacityExceededUnits": "Die maximale Anzahl an Anlagen würde durch diesen Kredit überschritten ({used} von maximal {limit})." + "investmentTypeCapacityExceededUnits": "Die maximale Anzahl an Anlagen würde durch diesen Kredit überschritten ({used} von maximal {limit}).", + "endDateBeforeSavingsLastDepositDate": "Das Vertragsende ({endDate}) liegt vor der letzten Einzahlung ({lastDepositDate})." }, "investmentType": { "title": "Anlageart", diff --git a/src/messages/de/fields.json b/src/messages/de/fields.json index 3c12d78f..84c5368f 100644 --- a/src/messages/de/fields.json +++ b/src/messages/de/fields.json @@ -46,10 +46,21 @@ "terminationDateLong": "Kündigungsdatum (lang)", "terminationType": "Kündigungsart", "contractStatus": "Vertragsstatus", + "isSavingsContract": "Ansparvertrag", + "savingsRateType": "Einzahlungsart", + "savingsMonthlyAmount": "Monatlicher Betrag", + "savingsDepositCount": "Anzahl der Einzahlungen", + "savingsFirstDepositDate": "Erste Einzahlung (erwartetes Datum) (kurz)", + "savingsFirstDepositDateLong": "Erste Einzahlung (erwartetes Datum) (lang)", + "savingsLastDepositDate": "Letzte Einzahlung (erwartetes Datum) (kurz)", + "savingsLastDepositDateLong": "Letzte Einzahlung (erwartetes Datum) (lang)", + "savingsSummary": "Ansparvertrag-Zusammenfassung", "status": "Status", "balance": "Kontostand", "interest": "Zinsen", "deposits": "Einzahlungen", + "depositsCount": "Eingezahlte Raten", + "savingsPaymentStatus": "Ansparvertrag-Zahlungsstatus", "withdrawals": "Auszahlungen", "interestPaid": "Ausgezahlte Zinsen", "interestError": "Fehlerkorrektur", diff --git a/src/types/boolean-filter-value.ts b/src/types/boolean-filter-value.ts new file mode 100644 index 00000000..a168552f --- /dev/null +++ b/src/types/boolean-filter-value.ts @@ -0,0 +1,21 @@ +export type BooleanFilterValue = 'true' | 'false' | ''; + +export function parseBooleanFilterValue(raw: unknown): BooleanFilterValue { + if (raw === 'true' || raw === 'false') { + return raw; + } + + // Legacy enum-filter shape used before the dedicated boolean filter. + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + const value = raw as { operator?: string; value?: unknown }; + if (value.operator === 'eq' && (value.value === 'true' || value.value === 'false')) { + return value.value; + } + } + + return ''; +} + +export function isInactiveBooleanFilterValue(raw: unknown): boolean { + return parseBooleanFilterValue(raw) === ''; +} diff --git a/src/types/form-warnings.ts b/src/types/form-warnings.ts index b9656941..7619cfc1 100644 --- a/src/types/form-warnings.ts +++ b/src/types/form-warnings.ts @@ -1,4 +1,31 @@ +export type FormWarningMessageNamespace = 'dashboard.loans.sanityChecks' | 'dashboard.lenders.sanityChecks'; + +export type FormWarningMessageValues = Record; + export type FormWarning = { id: string; - message: string; + message?: string; + messageKey?: string; + messageNamespace?: FormWarningMessageNamespace; + messageValues?: FormWarningMessageValues; }; + +export function areFormWarningsEqual(a: FormWarning, b: FormWarning): boolean { + if (a.id !== b.id) return false; + + if (a.message !== undefined || b.message !== undefined) { + return a.message === b.message; + } + + if (a.messageKey !== b.messageKey || a.messageNamespace !== b.messageNamespace) { + return false; + } + + const aValues = a.messageValues ?? {}; + const bValues = b.messageValues ?? {}; + const keys = Object.keys(aValues); + + if (keys.length !== Object.keys(bValues).length) return false; + + return keys.every((key) => aValues[key] === bValues[key]); +}