From fe5c53097dcc9d1920f6abaf7bf2914405cc9b6e Mon Sep 17 00:00:00 2001 From: Thomas Huber Date: Thu, 2 Jul 2026 10:28:31 +0200 Subject: [PATCH 01/16] chore: more seeding data for the Development GmbH (cherry picked from commit c20ff04854fe784ef80804b0ff0dc240a4d0bb50) --- prisma/seed.ts | 209 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 190 insertions(+), 19 deletions(-) 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); } } } From 38f2772a3318776dbf54f1120de2eb1731542367 Mon Sep 17 00:00:00 2001 From: Thomas Huber Date: Mon, 29 Jun 2026 15:07:18 +0200 Subject: [PATCH 02/16] feat: #26 savings contracts edit/create + db schema (cherry picked from commit 9e6e6305fea262db26bc38407aee5aa2362160eb) --- biome.json | 1 + .../migration.sql | 9 + .../migration.sql | 2 + prisma/schema.prisma | 11 + src/actions/loans/mutations/create-loan.ts | 7 + src/actions/loans/mutations/update-loan.ts | 7 + src/components/form/form-date-picker.tsx | 68 +-- .../lenders/loan-accordion-card.tsx | 62 +++ src/components/loans/loan-form-fields.tsx | 30 +- src/components/loans/loan-form.tsx | 8 +- src/components/loans/savings-form-fields.tsx | 503 ++++++++++++++++++ src/components/ui/date-picker-input.tsx | 98 ++++ src/components/ui/form-section.tsx | 6 +- src/lib/calculations/loan-calculations.ts | 3 + .../loan-table-column-registry.ts | 36 ++ src/lib/entity-filters/filter-definitions.ts | 6 + src/lib/loans/savings-contract.ts | 266 +++++++++ src/lib/schemas/loan.ts | 67 ++- src/lib/table-column-utils.tsx | 27 + src/lib/utils.ts | 6 + src/messages/de/dashboard.json | 31 ++ 21 files changed, 1183 insertions(+), 71 deletions(-) create mode 100644 prisma/migrations/20260629130000_add_savings_contract_fields/migration.sql create mode 100644 prisma/migrations/20260702150500_add_savings_last_deposit_date/migration.sql create mode 100644 src/components/loans/savings-form-fields.tsx create mode 100644 src/components/ui/date-picker-input.tsx create mode 100644 src/lib/loans/savings-contract.ts 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/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/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/lenders/loan-accordion-card.tsx b/src/components/lenders/loan-accordion-card.tsx index 587e5954..873fde03 100644 --- a/src/components/lenders/loan-accordion-card.tsx +++ b/src/components/lenders/loan-accordion-card.tsx @@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/generic/confirm-dialog'; import { TemplateQuickActions } from '@/components/templates/template-quick-actions'; import { InfoItem } from '@/components/ui/info-item'; import { useRouter } from '@/i18n/navigation'; +import { resolveSavingsLastDepositDate } from '@/lib/loans/savings-contract'; import { formatTerminationModalities } from '@/lib/table-column-utils'; import { cn, formatCurrency, formatDateLong, formatDateShort, formatPercentage } from '@/lib/utils'; import type { LoanDetailsWithCalculations } from '@/types/loans'; @@ -59,6 +60,15 @@ export function LoanAccordionCard({ loan, defaultOpen = false }: LoanAccordionCa const getTerminationModalities = () => formatTerminationModalities(loan, commonT, (d) => formatDateLong(d, locale)); + const savingsLastDepositDate = + loan.isSavingsContract + ? resolveSavingsLastDepositDate( + loan.savingsFirstDepositDate, + loan.savingsLastDepositDate, + loan.savingsDepositCount, + ) + : null; + const handleDeleteLoan = async () => { const toastId = toast.loading(t('delete.loading')); try { @@ -181,6 +191,58 @@ export function LoanAccordionCard({ loan, defaultOpen = false }: LoanAccordionCa } /> + {loan.isSavingsContract && ( + <> + + {loan.savingsRateType === 'FIXED' && loan.savingsMonthlyAmount != null && ( + + )} + {loan.savingsDepositCount != null && ( + + )} + {loan.savingsFirstDepositDate && ( + + )} + {loan.savingsLastDepositDate && ( + + )} + {savingsLastDepositDate && !loan.savingsLastDepositDate && ( + + )} + {loan.savingsDepositCount != null && ( + + )} + + )}
diff --git a/src/components/loans/loan-form-fields.tsx b/src/components/loans/loan-form-fields.tsx index 8f06133e..e9b2d0ad 100644 --- a/src/components/loans/loan-form-fields.tsx +++ b/src/components/loans/loan-form-fields.tsx @@ -1,7 +1,7 @@ 'use client'; import { ContractStatus, type Lender } from '@prisma/client'; -import { FileX } from 'lucide-react'; +import { FileX, PiggyBank } from 'lucide-react'; import { useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { useFormContext } from 'react-hook-form'; @@ -11,11 +11,14 @@ import { FormNumberInput } from '@/components/form/form-number-input'; import { FormSelect } from '@/components/form/form-select'; import { InterestRateInput } from '@/components/loans/interest-rate-input'; import { LenderCombobox } from '@/components/loans/lender-combobox'; +import { CardTitle } from '@/components/ui/card'; import { FormSection } from '@/components/ui/form-section'; import type { LoanFormClientData } from '@/lib/schemas/loan'; import { FormAdditionalFields } from '../form/form-additional-fields'; +import { FormSwitch } from '../form/form-switch'; import { useProject } from '../providers/project-provider'; import { LoanInvestmentTypeSection } from './loan-investment-type-section'; +import { SavingsFormFields } from './savings-form-fields'; import { TerminationFormFields } from './termination-form-fields'; interface LoanFormFieldsProps { @@ -35,6 +38,7 @@ export function LoanFormFields({ lenders, isEditMode = false, currentLoanId }: L const lenderId = form.watch('lenderId'); const signDate = form.watch('signDate'); const interestRate = form.watch('interestRate'); + const isSavingsContract = form.watch('isSavingsContract'); const selectedLender = lenders.find((lender) => lender.id === lenderId); const deInvestmentActComplianceEnabled = project.configuration.deInvestmentActCompliance === true; const isInvestmentTypeSectionActive = selectedLender?.country === 'DE'; @@ -107,10 +111,26 @@ export function LoanFormFields({ lenders, isEditMode = false, currentLoanId }: L /> - {/* Termination Information Section */} - } title={t('new.form.terminationInfo')}> - - +
+ {/* Savings Contract Section */} + } + title={ +
+ {t('new.form.savingsInfo')} + +
+ } + > + {isSavingsContract ? : null} +
+ + {/* Termination Information Section */} + } title={t('new.form.terminationInfo')}> + + +
{/* Additional Information Section */} {((project.configuration.loanAdditionalFields.length ?? 0) > 0 || 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..b9d00419 --- /dev/null +++ b/src/components/loans/savings-form-fields.tsx @@ -0,0 +1,503 @@ +'use client'; + +import { SavingsRateType } from '@prisma/client'; +import { ChartColumn, Equal, Lock } from 'lucide-react'; +import { 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 { + calculateSavingsDepositCountFromDates, + calculateSavingsDepositCountFromMonthlyAmount, + calculateSavingsFirstDepositDate, + calculateSavingsLastDepositDate, + calculateSavingsMonthlyAmount, +} from '@/lib/loans/savings-contract'; +import type { LoanFormClientData } from '@/lib/schemas/loan'; +import { formatNumber, NumberParser } from '@/lib/utils'; + +type SavingsFieldKey = + | 'savingsFirstDepositDate' + | 'savingsLastDepositDate' + | 'savingsMonthlyAmount' + | 'savingsDepositCount'; +type SavingsDateFieldKey = 'savingsFirstDepositDate' | 'savingsLastDepositDate'; +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 => ({ + savingsFirstDepositDate: hasDateValue(values.savingsFirstDepositDate) ? 'defined' : null, + savingsLastDepositDate: hasDateValue(values.savingsLastDepositDate) ? 'defined' : null, + 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 { watch, setValue, control, getValues } = useFormContext(); + const hasInitializedModesRef = useRef(false); + + const isSavingsContract = watch('isSavingsContract'); + const savingsRateType = watch('savingsRateType'); + const amount = watch('amount'); + 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 [fieldModes, setFieldModes] = useState>(() => + getInitialFieldModes(getValues(), isFixedRate), + ); + const [datePickerOpen, setDatePickerOpen] = useState>({ + savingsFirstDepositDate: false, + savingsLastDepositDate: 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: Date | 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) => { + const values = getValues(); + const firstDepositDate = values.savingsFirstDepositDate; + const lastDepositDate = values.savingsLastDepositDate; + const depositCount = values.savingsDepositCount; + const monthlyAmount = parser.parse(values.savingsMonthlyAmount as string); + + const hasFirstDepositDate = hasDateValue(firstDepositDate); + const hasLastDepositDate = hasDateValue(lastDepositDate); + const hasDepositCount = hasCountValue(depositCount); + const hasMonthlyAmount = monthlyAmount != null && monthlyAmount > 0; + + const deriveMissingDepositDateFromCount = (count: number) => { + if (hasFirstDepositDate) { + const last = calculateSavingsLastDepositDate(firstDepositDate, count); + if (last) setDerivedValue('savingsLastDepositDate', last); + } else if (hasLastDepositDate) { + const first = calculateSavingsFirstDepositDate(lastDepositDate, count); + if (first) setDerivedValue('savingsFirstDepositDate', first); + } + }; + + if (changed === 'savingsFirstDepositDate') { + if (hasFirstDepositDate && hasDepositCount) { + const last = calculateSavingsLastDepositDate(firstDepositDate, depositCount); + if (last) setDerivedValue('savingsLastDepositDate', last); + } else if (hasFirstDepositDate && hasLastDepositDate) { + const count = calculateSavingsDepositCountFromDates(firstDepositDate, lastDepositDate); + if (count) { + setDerivedValue('savingsDepositCount', count); + if (isFixedRate && loanAmount > 0) { + const monthly = calculateSavingsMonthlyAmount(loanAmount, count); + if (monthly != null) setDerivedValue('savingsMonthlyAmount', formatNumber(monthly)); + } + } + } + return; + } + + if (changed === 'savingsLastDepositDate') { + if (hasFirstDepositDate && hasLastDepositDate) { + const count = calculateSavingsDepositCountFromDates(firstDepositDate, lastDepositDate); + if (count) { + setDerivedValue('savingsDepositCount', count); + if (isFixedRate && loanAmount > 0) { + const monthly = calculateSavingsMonthlyAmount(loanAmount, count); + if (monthly != null) setDerivedValue('savingsMonthlyAmount', formatNumber(monthly)); + } + } + } else if (hasLastDepositDate && hasDepositCount) { + const first = calculateSavingsFirstDepositDate(lastDepositDate, depositCount); + if (first) setDerivedValue('savingsFirstDepositDate', first); + } + return; + } + + if (changed === 'savingsMonthlyAmount' && isFixedRate && loanAmount > 0 && hasMonthlyAmount) { + const count = calculateSavingsDepositCountFromMonthlyAmount(loanAmount, monthlyAmount); + if (count) { + setDerivedValue('savingsDepositCount', count); + deriveMissingDepositDateFromCount(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)); + } + deriveMissingDepositDateFromCount(depositCount); + } + }, + [getValues, isFixedRate, loanAmount, parser, setDerivedValue], + ); + + const handleUserFieldChange = useCallback( + (field: SavingsFieldKey, value: Date | 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 handleUnlockField = useCallback( + (field: SavingsFieldKey) => { + if (fieldModes[field] !== 'derived') return; + setFieldMode(field, 'defined'); + applyDependencyRules(field); + }, + [applyDependencyRules, fieldModes, setFieldMode], + ); + + const handleUnlockDateField = useCallback( + (field: SavingsDateFieldKey) => { + if (fieldModes[field] !== 'derived') return; + setFieldMode(field, 'defined'); + applyDependencyRules(field); + setDatePickerOpen((current) => ({ ...current, [field]: true })); + }, + [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'); + + return ( + <> + {isSavingsContract && ( +
+
+ + + + + + + + + + + +
+ +
+ ( + + + handleUnlockDateField('savingsFirstDepositDate')} + > + handleUserFieldChange('savingsFirstDepositDate', date ?? '')} + placeholder={commonT('ui.form.enterPlaceholder')} + disabled={isFieldLocked('savingsFirstDepositDate', hasDateValue(field.value))} + open={datePickerOpen.savingsFirstDepositDate} + onOpenChange={(open) => { + if (isFieldLocked('savingsFirstDepositDate', hasDateValue(field.value))) return; + setDatePickerOpen((current) => ({ ...current, savingsFirstDepositDate: open })); + }} + /> + + + + )} + /> +
+ + {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" + /> + + + + + )} + /> +
+ )} + +
+ ( + + + handleUnlockDateField('savingsLastDepositDate')} + > + handleUserFieldChange('savingsLastDepositDate', date ?? '')} + placeholder={commonT('ui.form.enterPlaceholder')} + disabled={isFieldLocked('savingsLastDepositDate', hasDateValue(field.value))} + open={datePickerOpen.savingsLastDepositDate} + onOpenChange={(open) => { + if (isFieldLocked('savingsLastDepositDate', hasDateValue(field.value))) return; + setDatePickerOpen((current) => ({ ...current, savingsLastDepositDate: open })); + }} + /> + + + + )} + /> +
+
+ )} + + ); +} 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-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/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..f00b918b 100644 --- a/src/lib/dashboard/table-widget/loan-table-column-registry.ts +++ b/src/lib/dashboard/table-widget/loan-table-column-registry.ts @@ -14,6 +14,7 @@ import { createDateColumn, createDurationDaysColumn, createEnumBadgeColumn, + createNullableCurrencyColumn, createNumberColumn, createPercentageColumn, createTerminationModalitiesColumn, @@ -36,6 +37,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' }, @@ -106,6 +113,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), @@ -290,6 +320,12 @@ export const LOAN_TABLE_COLUMN_IDS = [ 'amount', 'balance', 'deposits', + 'outstandingDepositSum', + 'outstandingDepositSinceDate', + 'outstandingDepositSinceDays', + 'depositsCount', + 'requiredDepositsCount', + 'outstandingDepositsCount', 'withdrawals', 'notReclaimed', 'interestRate', diff --git a/src/lib/entity-filters/filter-definitions.ts b/src/lib/entity-filters/filter-definitions.ts index e9acf750..041ac77e 100644 --- a/src/lib/entity-filters/filter-definitions.ts +++ b/src/lib/entity-filters/filter-definitions.ts @@ -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') }, diff --git a/src/lib/loans/savings-contract.ts b/src/lib/loans/savings-contract.ts new file mode 100644 index 00000000..3a476a52 --- /dev/null +++ b/src/lib/loans/savings-contract.ts @@ -0,0 +1,266 @@ +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, + })); +}; + +export const getDefaultFirstDepositDate = (signDate: unknown) => { + const base = signDate instanceof Date ? signDate : signDate ? moment(signDate).toDate() : new Date(); + return moment(base).add(1, 'month').startOf('month').toDate(); +}; + +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 | null | undefined, + lastDepositDate: Date | null | undefined, + depositCount: number | null | undefined, +) => { + if (lastDepositDate) { + const lastMoment = moment(lastDepositDate); + if (lastMoment.isValid()) return lastMoment.toDate(); + } + + if (firstDepositDate && depositCount != null && depositCount >= 1) { + return calculateSavingsLastDepositDate(firstDepositDate, depositCount); + } + + return null; +}; + +export const getExpectedDepositSchedule = (loan: LoanForSchedule): ExpectedTransaction[] => { + if ( + loan.isSavingsContract && + loan.savingsFirstDepositDate && + loan.savingsDepositCount != null && + loan.savingsDepositCount >= 1 + ) { + const firstMoment = moment(loan.savingsFirstDepositDate); + if (!firstMoment.isValid()) { + return []; + } + + 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..5bf58d9c 100644 --- a/src/lib/table-column-utils.tsx +++ b/src/lib/table-column-utils.tsx @@ -292,6 +292,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, 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/dashboard.json b/src/messages/de/dashboard.json index 190965c0..28ad1497 100644 --- a/src/messages/de/dashboard.json +++ b/src/messages/de/dashboard.json @@ -810,7 +810,21 @@ "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", + "savingsLastDepositDate": "Letzte Einzahlung", + "savingsLastDeposit": "Letzte Einzahlung:", + "savingsRuntime": "Laufzeit:", "interestError": "Fehlerkorrektur", "currentBalance": "Kontostand", "notReclaimed": "Erlassungen", @@ -844,6 +858,23 @@ "investmentTypeSearchPlaceholder": "Anlageart suchen…", "noInvestmentTypes": "Keine Anlagearten gefunden", "loadingInvestmentTypes": "Anlagearten werden geladen…", + "savingsInfo": "Ansparvertrag", + "savingsContract": "Ansparvertrag", + "savingsRateType": "Einzahlungsart", + "savingsRateTypeFixed": "Feste Rate", + "savingsRateTypeVarying": "Ungleiche Raten", + "savingsMonthlyAmount": "Monatlicher Betrag", + "savingsDepositCountFixed": "Anzahl der Einzahlungen", + "savingsDepositCountVarying": "Anzahl erwarteter Einzahlungen", + "savingsFirstDepositDate": "Erste Einzahlung", + "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", From 079f9e0a8eb7d1847c3c461667c10296e01b95b2 Mon Sep 17 00:00:00 2001 From: Thomas Huber Date: Mon, 6 Jul 2026 12:07:12 +0200 Subject: [PATCH 03/16] feat: #26 autocalc montly rate if contract value changes (cherry picked from commit 17deab851eb7cf7a0be21078b2e3b09e038a76a9) --- src/components/loans/savings-form-fields.tsx | 37 ++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/components/loans/savings-form-fields.tsx b/src/components/loans/savings-form-fields.tsx index b9d00419..7af1487b 100644 --- a/src/components/loans/savings-form-fields.tsx +++ b/src/components/loans/savings-form-fields.tsx @@ -27,6 +27,7 @@ type SavingsFieldKey = | 'savingsMonthlyAmount' | 'savingsDepositCount'; type SavingsDateFieldKey = 'savingsFirstDepositDate' | 'savingsLastDepositDate'; +type SavingsDependencyTrigger = SavingsFieldKey | 'amount'; type FieldMode = 'defined' | 'derived'; const hasDateValue = (value: Date | '' | null | undefined): value is Date => @@ -105,6 +106,7 @@ export function SavingsFormFields() { 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), @@ -143,7 +145,7 @@ export function SavingsFormFields() { ); const applyDependencyRules = useCallback( - (changed: SavingsFieldKey) => { + (changed: SavingsDependencyTrigger) => { const values = getValues(); const firstDepositDate = values.savingsFirstDepositDate; const lastDepositDate = values.savingsLastDepositDate; @@ -214,11 +216,42 @@ export function SavingsFormFields() { if (monthly != null) setDerivedValue('savingsMonthlyAmount', formatNumber(monthly)); } deriveMissingDepositDateFromCount(depositCount); + return; + } + + if (changed === 'amount') { + if (!isFixedRate || loanAmount <= 0) return; + + if (fieldModes.savingsMonthlyAmount === 'defined' && hasMonthlyAmount) { + const count = calculateSavingsDepositCountFromMonthlyAmount(loanAmount, monthlyAmount); + if (count) { + setDerivedValue('savingsDepositCount', count); + deriveMissingDepositDateFromCount(count); + } + return; + } + + if (fieldModes.savingsDepositCount === 'defined' && hasDepositCount && typeof depositCount === 'number') { + const monthly = calculateSavingsMonthlyAmount(loanAmount, depositCount); + if (monthly != null) setDerivedValue('savingsMonthlyAmount', formatNumber(monthly)); + } } }, - [getValues, isFixedRate, loanAmount, parser, setDerivedValue], + [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]); + const handleUserFieldChange = useCallback( (field: SavingsFieldKey, value: Date | number | string | '' | null) => { setValue(field, value as never, { shouldDirty: true, shouldValidate: true }); From 2c8908b12bb6bf185bc6ef825058e4d44695268b Mon Sep 17 00:00:00 2001 From: Thomas Huber Date: Mon, 6 Jul 2026 12:50:26 +0200 Subject: [PATCH 04/16] feat: #26 improved lender-loan-section (cherry picked from commit cbc9be35bce795b71fd29a9d17f6c87468138960) --- .../lenders/loan-accordion-card.tsx | 99 ++++++++++--------- src/messages/de/dashboard.json | 11 +++ 2 files changed, 62 insertions(+), 48 deletions(-) diff --git a/src/components/lenders/loan-accordion-card.tsx b/src/components/lenders/loan-accordion-card.tsx index 873fde03..2eb3cab7 100644 --- a/src/components/lenders/loan-accordion-card.tsx +++ b/src/components/lenders/loan-accordion-card.tsx @@ -60,14 +60,9 @@ export function LoanAccordionCard({ loan, defaultOpen = false }: LoanAccordionCa const getTerminationModalities = () => formatTerminationModalities(loan, commonT, (d) => formatDateLong(d, locale)); - const savingsLastDepositDate = - loan.isSavingsContract - ? resolveSavingsLastDepositDate( - loan.savingsFirstDepositDate, - loan.savingsLastDepositDate, - loan.savingsDepositCount, - ) - : null; + const savingsLastDepositDate = loan.isSavingsContract + ? resolveSavingsLastDepositDate(loan.savingsFirstDepositDate, loan.savingsLastDepositDate, loan.savingsDepositCount) + : null; const handleDeleteLoan = async () => { const toastId = toast.loading(t('delete.loading')); @@ -191,60 +186,68 @@ export function LoanAccordionCard({ loan, defaultOpen = false }: LoanAccordionCa } /> - {loan.isSavingsContract && ( + + {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 && ( <> - {loan.savingsRateType === 'FIXED' && loan.savingsMonthlyAmount != null && ( + {(loan.savingsFirstDepositDate || savingsLastDepositDate) && ( - )} - {loan.savingsDepositCount != null && ( - + + {formatDateLong(loan.savingsFirstDepositDate, locale)} + {' '} + {t('table.until')}{' '} + {formatDateLong(savingsLastDepositDate, locale)} + + ) : loan.savingsFirstDepositDate ? ( + formatDateLong(loan.savingsFirstDepositDate, locale) + ) : ( + formatDateLong(savingsLastDepositDate, locale) + ) } - value={loan.savingsDepositCount} - /> - )} - {loan.savingsFirstDepositDate && ( - - )} - {loan.savingsLastDepositDate && ( - )} - {savingsLastDepositDate && !loan.savingsLastDepositDate && ( + {loan.requiredDepositsCount > 0 && ( - )} - {loan.savingsDepositCount != null && ( - )} )} -
-
{canTerminateLoan && (
{/* 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 savingsLastDepositDate = loan.isSavingsContract ? resolveSavingsLastDepositDate(loan.savingsFirstDepositDate, loan.savingsLastDepositDate, loan.savingsDepositCount) 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 f00b918b..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,6 +10,7 @@ import { getLenderSortValue } from '@/lib/dashboard/table-widget/lender-table-co import { createAdditionalFieldsColumns, createAdditionalFieldDefaultColumnVisibility, + createBooleanColumn, createCurrencyColumn, createDateColumn, createDurationDaysColumn, @@ -56,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 }; @@ -188,6 +190,7 @@ export function buildLoanTableColumns( } }, ), + createBooleanColumn('isSavingsContract', 'table.isSavingsContract', t, commonT), ...createAdditionalFieldsColumns( project.configuration.loanAdditionalFields, 'additionalFields', @@ -295,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) { @@ -339,4 +344,5 @@ export const LOAN_TABLE_COLUMN_IDS = [ 'status', 'altInterestMethod', 'contractStatus', + 'isSavingsContract', ] as const; diff --git a/src/lib/entity-filters/filter-definitions.ts b/src/lib/entity-filters/filter-definitions.ts index 041ac77e..a242b36b 100644 --- a/src/lib/entity-filters/filter-definitions.ts +++ b/src/lib/entity-filters/filter-definitions.ts @@ -107,6 +107,14 @@ export function buildLoanColumnFiltersMap( value, })), }, + isSavingsContract: { + type: 'select', + label: t('table.isSavingsContract'), + options: [ + { label: commonT('ui.boolean.yes'), value: 'true' }, + { label: commonT('ui.boolean.no'), value: 'false' }, + ], + }, ...createAdditionalFieldFilters('additionalFields', project.configuration.loanAdditionalFields), }; } 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/table-column-utils.tsx b/src/lib/table-column-utils.tsx index 5bf58d9c..12aebdac 100644 --- a/src/lib/table-column-utils.tsx +++ b/src/lib/table-column-utils.tsx @@ -1,6 +1,5 @@ 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'; @@ -21,6 +20,15 @@ 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) { + if (filterValue === '' || filterValue == null) { + return true; + } + const value = row.getValue(columnId) === true; + return filterValue === 'true' ? value : !value; +} + // Define the custom filter function for enum fields export function enumFilter(row: Row, columnId: string, filterValue: unknown) { const value = row.getValue(columnId); @@ -472,6 +480,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, @@ -902,11 +940,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..88c5d780 100644 --- a/src/lib/templates/template-data.ts +++ b/src/lib/templates/template-data.ts @@ -254,9 +254,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,6 +376,32 @@ function buildTransactionsYearlyList( return [opening, ...middle, closing]; } +function savingsRateTypeLabel(rateType: string | null | undefined) { + if (rateType === 'FIXED') return 'Feste Rate'; + if (rateType === 'VARYING') return 'Ungleiche Raten'; + return ''; +} + +function formatSavingsSummary(loan: TemplateLoanRecord, locale: string) { + if (!loan.isSavingsContract || loan.savingsDepositCount == null) return ''; + + const months = loan.savingsDepositCount === 1 ? '1 Monat' : `${loan.savingsDepositCount} Monate`; + if (loan.savingsRateType === 'FIXED' && loan.savingsMonthlyAmount != null) { + return `${months} zu je ${formatCurrency(loan.savingsMonthlyAmount, locale)}`; + } + + return months; +} + +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) { return { ...loan, @@ -380,9 +414,22 @@ 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(loan.savingsFirstDepositDate, locale) : '', + savingsFirstDepositDateLong: loan.isSavingsContract ? formatDateLong(loan.savingsFirstDepositDate, locale) : '', + savingsLastDepositDate: loan.isSavingsContract ? formatDateShort(loan.savingsLastDepositDate, locale) : '', + savingsLastDepositDateLong: loan.isSavingsContract ? formatDateLong(loan.savingsLastDepositDate, 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/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 0fe56528..e57905f1 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", @@ -833,8 +834,8 @@ "savingsDepositReceipts": "Zahlungseingänge", "savingsInstallmentsPaid": "{paid} von {required} Raten eingezahlt", "paymentOutstandingSince": "Zahlung seit {days} Tagen ausstehend", - "paymentOutstanding": "Zahlung ausstehend.", - "paymentNotOutstanding": "Keine Zahlung ausstehend.", + "paymentOutstanding": "Zahlung ausstehend", + "paymentNotOutstanding": "Keine Zahlung ausstehend", "interestError": "Fehlerkorrektur", "currentBalance": "Kontostand", "notReclaimed": "Erlassungen", diff --git a/src/messages/de/fields.json b/src/messages/de/fields.json index 3c12d78f..6548b1d3 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 (kurz)", + "savingsFirstDepositDateLong": "Erste Einzahlung (lang)", + "savingsLastDepositDate": "Letzte Einzahlung (kurz)", + "savingsLastDepositDateLong": "Letzte Einzahlung (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", From db5410049be824541d06a75abeed26215845e7b5 Mon Sep 17 00:00:00 2001 From: Thomas Huber Date: Tue, 7 Jul 2026 09:53:08 +0200 Subject: [PATCH 06/16] feat: #26: Addes sanity checks for cases when contract ends before lastPayment (cherry picked from commit 8dd99fc9c64072326184afdaf11a9b659e3f6635) --- .../form/form-sanity-checks-provider.tsx | 4 +- src/components/form/form-warning-message.tsx | 45 +++++++++ .../loans/loan-end-date-sanity-check.tsx | 98 +++++++++++++++++++ src/components/loans/loan-form-fields.tsx | 8 +- src/components/ui/form-actions.tsx | 9 +- src/components/ui/no-wrap.tsx | 5 + src/lib/loans/loan-end-date-sanity-check.ts | 76 ++++++++++++++ src/messages/de/dashboard.json | 3 +- src/types/form-warnings.ts | 29 +++++- 9 files changed, 268 insertions(+), 9 deletions(-) create mode 100644 src/components/form/form-warning-message.tsx create mode 100644 src/components/loans/loan-end-date-sanity-check.tsx create mode 100644 src/components/ui/no-wrap.tsx create mode 100644 src/lib/loans/loan-end-date-sanity-check.ts 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/loans/loan-end-date-sanity-check.tsx b/src/components/loans/loan-end-date-sanity-check.tsx new file mode 100644 index 00000000..d3707855 --- /dev/null +++ b/src/components/loans/loan-end-date-sanity-check.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useLocale } from 'next-intl'; +import { useEffect, useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { useFormSanityChecks } from '@/components/form/form-sanity-checks-provider'; +import { evaluateLoanEndDateSanityChecks, resolveContractEndDate } from '@/lib/loans/loan-end-date-sanity-check'; +import { resolveSavingsLastDepositDate } from '@/lib/loans/savings-contract'; +import type { LoanFormClientData } from '@/lib/schemas/loan'; +import { formatDateLong } from '@/lib/utils'; + +const BEFORE_SAVINGS_LAST_DEPOSIT_WARNING_ID = 'loan-end-before-savings-last-deposit'; + +export function LoanEndDateSanityCheck() { + const locale = useLocale(); + const { setWarning } = useFormSanityChecks(); + const form = useFormContext(); + + const terminationType = form.watch('terminationType'); + const signDate = form.watch('signDate'); + const endDate = form.watch('endDate'); + const duration = form.watch('duration'); + const durationType = form.watch('durationType'); + const isSavingsContract = form.watch('isSavingsContract'); + const savingsFirstDepositDate = form.watch('savingsFirstDepositDate'); + const savingsLastDepositDate = form.watch('savingsLastDepositDate'); + const savingsDepositCount = form.watch('savingsDepositCount'); + + const checkInput = useMemo( + () => ({ + terminationType, + signDate, + endDate, + duration, + durationType, + isSavingsContract, + savingsFirstDepositDate, + savingsLastDepositDate, + savingsDepositCount, + }), + [ + terminationType, + signDate, + endDate, + duration, + durationType, + isSavingsContract, + savingsFirstDepositDate, + savingsLastDepositDate, + savingsDepositCount, + ], + ); + + const failedChecks = useMemo(() => evaluateLoanEndDateSanityChecks(checkInput), [checkInput]); + const contractEndDate = useMemo(() => resolveContractEndDate(checkInput), [checkInput]); + + useEffect(() => { + const resolvedLastDepositDate = resolveSavingsLastDepositDate( + savingsFirstDepositDate instanceof Date + ? savingsFirstDepositDate + : savingsFirstDepositDate + ? new Date(savingsFirstDepositDate) + : null, + savingsLastDepositDate instanceof Date + ? savingsLastDepositDate + : savingsLastDepositDate + ? new Date(savingsLastDepositDate) + : null, + typeof savingsDepositCount === 'number' ? savingsDepositCount : null, + ); + + if (failedChecks.includes('beforeSavingsLastDepositDate') && contractEndDate && resolvedLastDepositDate) { + setWarning(BEFORE_SAVINGS_LAST_DEPOSIT_WARNING_ID, { + id: BEFORE_SAVINGS_LAST_DEPOSIT_WARNING_ID, + messageKey: 'endDateBeforeSavingsLastDepositDate', + messageNamespace: 'dashboard.loans.sanityChecks', + messageValues: { + endDate: formatDateLong(contractEndDate, locale), + lastDepositDate: formatDateLong(resolvedLastDepositDate, locale), + }, + }); + } else { + setWarning(BEFORE_SAVINGS_LAST_DEPOSIT_WARNING_ID, null); + } + + return () => setWarning(BEFORE_SAVINGS_LAST_DEPOSIT_WARNING_ID, null); + }, [ + contractEndDate, + failedChecks, + locale, + savingsDepositCount, + savingsFirstDepositDate, + savingsLastDepositDate, + setWarning, + ]); + + return null; +} diff --git a/src/components/loans/loan-form-fields.tsx b/src/components/loans/loan-form-fields.tsx index e9b2d0ad..e939926e 100644 --- a/src/components/loans/loan-form-fields.tsx +++ b/src/components/loans/loan-form-fields.tsx @@ -17,6 +17,7 @@ import type { LoanFormClientData } from '@/lib/schemas/loan'; import { FormAdditionalFields } from '../form/form-additional-fields'; import { FormSwitch } from '../form/form-switch'; import { useProject } from '../providers/project-provider'; +import { LoanEndDateSanityCheck } from './loan-end-date-sanity-check'; import { LoanInvestmentTypeSection } from './loan-investment-type-section'; import { SavingsFormFields } from './savings-form-fields'; import { TerminationFormFields } from './termination-form-fields'; @@ -46,7 +47,9 @@ export function LoanFormFields({ lenders, isEditMode = false, currentLoanId }: L deInvestmentActComplianceEnabled && !!selectedLender && !!signDate && interestRate !== ''; return ( -
+ <> + +
{/* General Information Section */} )} -
+
+ ); } 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/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/loans/loan-end-date-sanity-check.ts b/src/lib/loans/loan-end-date-sanity-check.ts new file mode 100644 index 00000000..45d9bd59 --- /dev/null +++ b/src/lib/loans/loan-end-date-sanity-check.ts @@ -0,0 +1,76 @@ +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, + ); + + if (savingsLastDepositDate && !isOnOrAfter(contractEndDate, savingsLastDepositDate)) { + results.push('beforeSavingsLastDepositDate'); + } + + return results; +} diff --git a/src/messages/de/dashboard.json b/src/messages/de/dashboard.json index e57905f1..b0c29cc2 100644 --- a/src/messages/de/dashboard.json +++ b/src/messages/de/dashboard.json @@ -918,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/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]); +} From 6bc9fbda0348325517edbe46d6849a3f6f07e014 Mon Sep 17 00:00:00 2001 From: Thomas Huber Date: Tue, 7 Jul 2026 11:07:07 +0200 Subject: [PATCH 07/16] feat: #26 improved UI (cherry picked from commit 4c2216ddb1b8a81ebee378d546fdd78343f78da7) --- src/components/lenders/loan-accordion-card.tsx | 9 +++------ src/components/loans/loan-form.tsx | 5 ++++- src/components/loans/savings-form-fields.tsx | 13 ++++++++++++- src/lib/schemas/loan.ts | 8 ++++++++ src/messages/de/dashboard.json | 12 ++++++------ src/messages/de/fields.json | 8 ++++---- 6 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/components/lenders/loan-accordion-card.tsx b/src/components/lenders/loan-accordion-card.tsx index 91979a6c..a49c49a7 100644 --- a/src/components/lenders/loan-accordion-card.tsx +++ b/src/components/lenders/loan-accordion-card.tsx @@ -222,12 +222,9 @@ export function LoanAccordionCard({ loan, defaultOpen = false }: LoanAccordionCa label={t('table.savingsDepositPeriod')} value={ loan.savingsFirstDepositDate && savingsLastDepositDate ? ( - - - {formatDateLong(loan.savingsFirstDepositDate, locale)} - {' '} - {t('table.until')}{' '} - {formatDateLong(savingsLastDepositDate, locale)} + + {formatDateLong(loan.savingsFirstDepositDate, locale)} –{' '} + {formatDateLong(savingsLastDepositDate, locale)} ) : loan.savingsFirstDepositDate ? ( formatDateLong(loan.savingsFirstDepositDate, locale) diff --git a/src/components/loans/loan-form.tsx b/src/components/loans/loan-form.tsx index 13e773a5..84767a8a 100644 --- a/src/components/loans/loan-form.tsx +++ b/src/components/loans/loan-form.tsx @@ -9,6 +9,7 @@ import { FormSanityChecksProvider } from '@/components/form/form-sanity-checks-p import { Form } from '@/components/ui/form'; import { FormActionsWithSanityWarnings } from '@/components/ui/form-actions'; import { FormLayout } from '@/components/ui/form-layout'; +import { getDefaultFirstDepositDate } from '@/lib/loans/savings-contract'; import type { AdditionalFieldValues } from '@/lib/schemas/common'; import type { LoanFormData } from '@/lib/schemas/loan'; import { loanFormSchema } from '@/lib/schemas/loan'; @@ -81,7 +82,9 @@ export function LoanForm({ savingsRateType: initialData?.savingsRateType ?? SavingsRateType.FIXED, savingsMonthlyAmount: formatNumber(initialData?.savingsMonthlyAmount) || ('' as const), savingsDepositCount: initialData?.savingsDepositCount ?? '', - savingsFirstDepositDate: initialData?.savingsFirstDepositDate || '', + savingsFirstDepositDate: + initialData?.savingsFirstDepositDate || + (initialData?.isSavingsContract && initialData?.signDate ? getDefaultFirstDepositDate(initialData.signDate) : ''), savingsLastDepositDate: initialData?.savingsLastDepositDate || '', additionalFields: additionalFieldDefaults( project.configuration.loanAdditionalFields || [], diff --git a/src/components/loans/savings-form-fields.tsx b/src/components/loans/savings-form-fields.tsx index 7af1487b..e7fc76e6 100644 --- a/src/components/loans/savings-form-fields.tsx +++ b/src/components/loans/savings-form-fields.tsx @@ -17,6 +17,7 @@ import { calculateSavingsFirstDepositDate, calculateSavingsLastDepositDate, calculateSavingsMonthlyAmount, + getDefaultFirstDepositDate, } from '@/lib/loans/savings-contract'; import type { LoanFormClientData } from '@/lib/schemas/loan'; import { formatNumber, NumberParser } from '@/lib/utils'; @@ -100,6 +101,7 @@ export function SavingsFormFields() { const isSavingsContract = watch('isSavingsContract'); const savingsRateType = watch('savingsRateType'); + const signDate = watch('signDate'); const amount = watch('amount'); const isFixedRate = savingsRateType === SavingsRateType.FIXED; const toggleValue = isFixedRate ? 'fixed' : 'varying'; @@ -128,6 +130,15 @@ export function SavingsFormFields() { setFieldModes(getInitialFieldModes(getValues(), isFixedRate)); }, [getValues, isFixedRate, isSavingsContract]); + useEffect(() => { + if (!isSavingsContract || !signDate || hasDateValue(getValues('savingsFirstDepositDate'))) return; + + setValue('savingsFirstDepositDate', getDefaultFirstDepositDate(signDate), { + shouldDirty: true, + shouldValidate: true, + }); + }, [isSavingsContract, signDate, getValues, setValue]); + const setFieldMode = useCallback((field: SavingsFieldKey, mode: FieldMode | null) => { setFieldModes((current) => ({ ...current, [field]: mode })); }, []); @@ -341,7 +352,7 @@ export function SavingsFormFields() { render={({ field }) => ( }); } + if (!data.savingsFirstDepositDate) { + ctx.addIssue({ + code: 'custom', + message: 'validation.common.required', + path: ['savingsFirstDepositDate'], + }); + } + if (data.savingsRateType === 'FIXED') { if (!data.savingsMonthlyAmount) { ctx.addIssue({ diff --git a/src/messages/de/dashboard.json b/src/messages/de/dashboard.json index b0c29cc2..b6c67873 100644 --- a/src/messages/de/dashboard.json +++ b/src/messages/de/dashboard.json @@ -822,16 +822,16 @@ "savingsMonthlyAmount": "Monatlicher Betrag", "savingsDepositCountFixed": "Anzahl der Einzahlungen", "savingsDepositCountVarying": "Anzahl erwarteter Einzahlungen", - "savingsFirstDepositDate": "Erste Einzahlung", - "savingsLastDepositDate": "Letzte Einzahlung", + "savingsFirstDepositDate": "Erste Einzahlung (erwartetes Datum)", + "savingsLastDepositDate": "Letzte Einzahlung (erwartetes Datum)", "savingsLastDeposit": "Letzte Einzahlung:", "savingsRuntime": "Laufzeit:", "savingsContractSummaryLabel": "Ansparvertrag:", "paymentStatus": "Zahlungstatus:", "savingsContractFixedSummary": "Über {months} Monate zu je {amount}", "savingsContractSummary": "Über {months} Monate", - "savingsDepositPeriod": "Erste und letzte Einzahlung", - "savingsDepositReceipts": "Zahlungseingänge", + "savingsDepositPeriod": "Einzahlungszeitraum", + "savingsDepositReceipts": "Einzahlungsvorgänge", "savingsInstallmentsPaid": "{paid} von {required} Raten eingezahlt", "paymentOutstandingSince": "Zahlung seit {days} Tagen ausstehend", "paymentOutstanding": "Zahlung ausstehend", @@ -878,8 +878,8 @@ "savingsMonthlyAmount": "Monatlicher Betrag", "savingsDepositCountFixed": "Anzahl der Einzahlungen", "savingsDepositCountVarying": "Anzahl erwarteter Einzahlungen", - "savingsFirstDepositDate": "Erste Einzahlung", - "savingsLastDepositDate": "Letzte Einzahlung", + "savingsFirstDepositDate": "Erste Einzahlung (erwartetes Datum)", + "savingsLastDepositDate": "Letzte Einzahlung (erwartetes Datum)", "savingsFieldDefined": "Manuell festgelegt", "savingsFieldDerived": "Automatisch berechnet", "savingsFieldUnlock": "Zum Bearbeiten entsperren", diff --git a/src/messages/de/fields.json b/src/messages/de/fields.json index 6548b1d3..84c5368f 100644 --- a/src/messages/de/fields.json +++ b/src/messages/de/fields.json @@ -50,10 +50,10 @@ "savingsRateType": "Einzahlungsart", "savingsMonthlyAmount": "Monatlicher Betrag", "savingsDepositCount": "Anzahl der Einzahlungen", - "savingsFirstDepositDate": "Erste Einzahlung (kurz)", - "savingsFirstDepositDateLong": "Erste Einzahlung (lang)", - "savingsLastDepositDate": "Letzte Einzahlung (kurz)", - "savingsLastDepositDateLong": "Letzte Einzahlung (lang)", + "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", From 42aa5840de9cb1674fbca23d56f625be40bf3d92 Mon Sep 17 00:00:00 2001 From: Thomas Huber Date: Thu, 16 Jul 2026 14:00:46 +0200 Subject: [PATCH 08/16] feat: #26 savingsFristDeposit is now optional with fallback to signDate. savingsLastDeposit is always calculated (cherry picked from commit 8c7af0480060a76d35576695d2bbff868af0ce90) --- .../lenders/loan-accordion-card.tsx | 22 +- .../loans/loan-end-date-sanity-check.tsx | 2 + src/components/loans/loan-form.tsx | 5 +- src/components/loans/savings-form-fields.tsx | 216 +++++------------- src/lib/loans/loan-end-date-sanity-check.ts | 1 + src/lib/loans/savings-contract.ts | 41 ++-- src/lib/schemas/loan.ts | 8 - src/lib/templates/template-data.ts | 21 +- src/messages/de/dashboard.json | 4 +- 9 files changed, 120 insertions(+), 200 deletions(-) diff --git a/src/components/lenders/loan-accordion-card.tsx b/src/components/lenders/loan-accordion-card.tsx index a49c49a7..158ed6f4 100644 --- a/src/components/lenders/loan-accordion-card.tsx +++ b/src/components/lenders/loan-accordion-card.tsx @@ -11,7 +11,7 @@ import { ConfirmDialog } from '@/components/generic/confirm-dialog'; import { TemplateQuickActions } from '@/components/templates/template-quick-actions'; import { InfoItem } from '@/components/ui/info-item'; import { useRouter } from '@/i18n/navigation'; -import { resolveSavingsLastDepositDate } from '@/lib/loans/savings-contract'; +import { resolveSavingsFirstDepositDate, resolveSavingsLastDepositDate } from '@/lib/loans/savings-contract'; import { formatTerminationModalities } from '@/lib/table-column-utils'; import { cn, formatCurrency, formatDateLong, formatDateShort, formatPercentage } from '@/lib/utils'; import type { LoanDetailsWithCalculations } from '@/types/loans'; @@ -60,8 +60,16 @@ export function LoanAccordionCard({ loan, defaultOpen = false }: LoanAccordionCa 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) + ? resolveSavingsLastDepositDate( + loan.savingsFirstDepositDate, + loan.savingsLastDepositDate, + loan.savingsDepositCount, + loan.signDate, + ) : null; const handleDeleteLoan = async () => { @@ -217,17 +225,17 @@ export function LoanAccordionCard({ loan, defaultOpen = false }: LoanAccordionCa : t('table.savingsContractSummary', { months: loan.savingsDepositCount }) } /> - {(loan.savingsFirstDepositDate || savingsLastDepositDate) && ( + {(savingsFirstDepositDate || savingsLastDepositDate) && ( - {formatDateLong(loan.savingsFirstDepositDate, locale)} –{' '} + {formatDateLong(savingsFirstDepositDate, locale)} –{' '} {formatDateLong(savingsLastDepositDate, locale)} - ) : loan.savingsFirstDepositDate ? ( - formatDateLong(loan.savingsFirstDepositDate, locale) + ) : savingsFirstDepositDate ? ( + formatDateLong(savingsFirstDepositDate, locale) ) : ( formatDateLong(savingsLastDepositDate, locale) ) diff --git a/src/components/loans/loan-end-date-sanity-check.tsx b/src/components/loans/loan-end-date-sanity-check.tsx index d3707855..847a0883 100644 --- a/src/components/loans/loan-end-date-sanity-check.tsx +++ b/src/components/loans/loan-end-date-sanity-check.tsx @@ -67,6 +67,7 @@ export function LoanEndDateSanityCheck() { ? new Date(savingsLastDepositDate) : null, typeof savingsDepositCount === 'number' ? savingsDepositCount : null, + signDate instanceof Date ? signDate : signDate ? new Date(signDate) : null, ); if (failedChecks.includes('beforeSavingsLastDepositDate') && contractEndDate && resolvedLastDepositDate) { @@ -92,6 +93,7 @@ export function LoanEndDateSanityCheck() { savingsFirstDepositDate, savingsLastDepositDate, setWarning, + signDate, ]); return null; diff --git a/src/components/loans/loan-form.tsx b/src/components/loans/loan-form.tsx index 84767a8a..13e773a5 100644 --- a/src/components/loans/loan-form.tsx +++ b/src/components/loans/loan-form.tsx @@ -9,7 +9,6 @@ import { FormSanityChecksProvider } from '@/components/form/form-sanity-checks-p import { Form } from '@/components/ui/form'; import { FormActionsWithSanityWarnings } from '@/components/ui/form-actions'; import { FormLayout } from '@/components/ui/form-layout'; -import { getDefaultFirstDepositDate } from '@/lib/loans/savings-contract'; import type { AdditionalFieldValues } from '@/lib/schemas/common'; import type { LoanFormData } from '@/lib/schemas/loan'; import { loanFormSchema } from '@/lib/schemas/loan'; @@ -82,9 +81,7 @@ export function LoanForm({ savingsRateType: initialData?.savingsRateType ?? SavingsRateType.FIXED, savingsMonthlyAmount: formatNumber(initialData?.savingsMonthlyAmount) || ('' as const), savingsDepositCount: initialData?.savingsDepositCount ?? '', - savingsFirstDepositDate: - initialData?.savingsFirstDepositDate || - (initialData?.isSavingsContract && initialData?.signDate ? getDefaultFirstDepositDate(initialData.signDate) : ''), + savingsFirstDepositDate: initialData?.savingsFirstDepositDate || '', savingsLastDepositDate: initialData?.savingsLastDepositDate || '', additionalFields: additionalFieldDefaults( project.configuration.loanAdditionalFields || [], diff --git a/src/components/loans/savings-form-fields.tsx b/src/components/loans/savings-form-fields.tsx index e7fc76e6..98718c35 100644 --- a/src/components/loans/savings-form-fields.tsx +++ b/src/components/loans/savings-form-fields.tsx @@ -2,7 +2,7 @@ import { SavingsRateType } from '@prisma/client'; import { ChartColumn, Equal, Lock } from 'lucide-react'; -import { useTranslations } from 'next-intl'; +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'; @@ -12,23 +12,15 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { - calculateSavingsDepositCountFromDates, calculateSavingsDepositCountFromMonthlyAmount, - calculateSavingsFirstDepositDate, calculateSavingsLastDepositDate, calculateSavingsMonthlyAmount, - getDefaultFirstDepositDate, + resolveSavingsFirstDepositDate, } from '@/lib/loans/savings-contract'; import type { LoanFormClientData } from '@/lib/schemas/loan'; -import { formatNumber, NumberParser } from '@/lib/utils'; - -type SavingsFieldKey = - | 'savingsFirstDepositDate' - | 'savingsLastDepositDate' - | 'savingsMonthlyAmount' - | 'savingsDepositCount'; -type SavingsDateFieldKey = 'savingsFirstDepositDate' | 'savingsLastDepositDate'; -type SavingsDependencyTrigger = SavingsFieldKey | 'amount'; +import { formatDateLong, formatNumber, NumberParser } from '@/lib/utils'; + +type SavingsFieldKey = 'savingsMonthlyAmount' | 'savingsDepositCount'; type FieldMode = 'defined' | 'derived'; const hasDateValue = (value: Date | '' | null | undefined): value is Date => @@ -45,8 +37,6 @@ const getInitialFieldModes = ( values: LoanFormClientData, isFixedRate: boolean, ): Record => ({ - savingsFirstDepositDate: hasDateValue(values.savingsFirstDepositDate) ? 'defined' : null, - savingsLastDepositDate: hasDateValue(values.savingsLastDepositDate) ? 'defined' : null, savingsMonthlyAmount: isFixedRate && hasAmountValue(values.savingsMonthlyAmount) ? 'defined' : null, savingsDepositCount: hasCountValue(values.savingsDepositCount) ? 'defined' : null, }); @@ -96,6 +86,7 @@ function SavingsFieldLabel({ label, isLocked }: { label: string; isLocked: boole 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); @@ -103,6 +94,8 @@ export function SavingsFormFields() { 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'; @@ -113,10 +106,7 @@ export function SavingsFormFields() { const [fieldModes, setFieldModes] = useState>(() => getInitialFieldModes(getValues(), isFixedRate), ); - const [datePickerOpen, setDatePickerOpen] = useState>({ - savingsFirstDepositDate: false, - savingsLastDepositDate: false, - }); + const [firstDepositDatePickerOpen, setFirstDepositDatePickerOpen] = useState(false); useEffect(() => { if (!isSavingsContract) { @@ -130,21 +120,12 @@ export function SavingsFormFields() { setFieldModes(getInitialFieldModes(getValues(), isFixedRate)); }, [getValues, isFixedRate, isSavingsContract]); - useEffect(() => { - if (!isSavingsContract || !signDate || hasDateValue(getValues('savingsFirstDepositDate'))) return; - - setValue('savingsFirstDepositDate', getDefaultFirstDepositDate(signDate), { - shouldDirty: true, - shouldValidate: true, - }); - }, [isSavingsContract, signDate, getValues, setValue]); - const setFieldMode = useCallback((field: SavingsFieldKey, mode: FieldMode | null) => { setFieldModes((current) => ({ ...current, [field]: mode })); }, []); const setDerivedValue = useCallback( - (field: SavingsFieldKey, value: Date | number | string | '' | null) => { + (field: SavingsFieldKey, value: number | string | '' | null) => { setValue(field, value as never, { shouldDirty: true, shouldValidate: true }); if (value === '' || value === null) { setFieldMode(field, null); @@ -156,68 +137,17 @@ export function SavingsFormFields() { ); const applyDependencyRules = useCallback( - (changed: SavingsDependencyTrigger) => { + (changed: SavingsFieldKey | 'amount') => { const values = getValues(); - const firstDepositDate = values.savingsFirstDepositDate; - const lastDepositDate = values.savingsLastDepositDate; const depositCount = values.savingsDepositCount; const monthlyAmount = parser.parse(values.savingsMonthlyAmount as string); - const hasFirstDepositDate = hasDateValue(firstDepositDate); - const hasLastDepositDate = hasDateValue(lastDepositDate); const hasDepositCount = hasCountValue(depositCount); const hasMonthlyAmount = monthlyAmount != null && monthlyAmount > 0; - const deriveMissingDepositDateFromCount = (count: number) => { - if (hasFirstDepositDate) { - const last = calculateSavingsLastDepositDate(firstDepositDate, count); - if (last) setDerivedValue('savingsLastDepositDate', last); - } else if (hasLastDepositDate) { - const first = calculateSavingsFirstDepositDate(lastDepositDate, count); - if (first) setDerivedValue('savingsFirstDepositDate', first); - } - }; - - if (changed === 'savingsFirstDepositDate') { - if (hasFirstDepositDate && hasDepositCount) { - const last = calculateSavingsLastDepositDate(firstDepositDate, depositCount); - if (last) setDerivedValue('savingsLastDepositDate', last); - } else if (hasFirstDepositDate && hasLastDepositDate) { - const count = calculateSavingsDepositCountFromDates(firstDepositDate, lastDepositDate); - if (count) { - setDerivedValue('savingsDepositCount', count); - if (isFixedRate && loanAmount > 0) { - const monthly = calculateSavingsMonthlyAmount(loanAmount, count); - if (monthly != null) setDerivedValue('savingsMonthlyAmount', formatNumber(monthly)); - } - } - } - return; - } - - if (changed === 'savingsLastDepositDate') { - if (hasFirstDepositDate && hasLastDepositDate) { - const count = calculateSavingsDepositCountFromDates(firstDepositDate, lastDepositDate); - if (count) { - setDerivedValue('savingsDepositCount', count); - if (isFixedRate && loanAmount > 0) { - const monthly = calculateSavingsMonthlyAmount(loanAmount, count); - if (monthly != null) setDerivedValue('savingsMonthlyAmount', formatNumber(monthly)); - } - } - } else if (hasLastDepositDate && hasDepositCount) { - const first = calculateSavingsFirstDepositDate(lastDepositDate, depositCount); - if (first) setDerivedValue('savingsFirstDepositDate', first); - } - return; - } - if (changed === 'savingsMonthlyAmount' && isFixedRate && loanAmount > 0 && hasMonthlyAmount) { const count = calculateSavingsDepositCountFromMonthlyAmount(loanAmount, monthlyAmount); - if (count) { - setDerivedValue('savingsDepositCount', count); - deriveMissingDepositDateFromCount(count); - } + if (count) setDerivedValue('savingsDepositCount', count); return; } @@ -226,7 +156,6 @@ export function SavingsFormFields() { const monthly = calculateSavingsMonthlyAmount(loanAmount, depositCount); if (monthly != null) setDerivedValue('savingsMonthlyAmount', formatNumber(monthly)); } - deriveMissingDepositDateFromCount(depositCount); return; } @@ -235,10 +164,7 @@ export function SavingsFormFields() { if (fieldModes.savingsMonthlyAmount === 'defined' && hasMonthlyAmount) { const count = calculateSavingsDepositCountFromMonthlyAmount(loanAmount, monthlyAmount); - if (count) { - setDerivedValue('savingsDepositCount', count); - deriveMissingDepositDateFromCount(count); - } + if (count) setDerivedValue('savingsDepositCount', count); return; } @@ -263,8 +189,25 @@ export function SavingsFormFields() { 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: Date | number | string | '' | null) => { + (field: SavingsFieldKey, value: number | string | '' | null) => { setValue(field, value as never, { shouldDirty: true, shouldValidate: true }); if (value === '' || value === null) { @@ -278,21 +221,18 @@ export function SavingsFormFields() { [applyDependencyRules, setFieldMode, setValue], ); - const handleUnlockField = useCallback( - (field: SavingsFieldKey) => { - if (fieldModes[field] !== 'derived') return; - setFieldMode(field, 'defined'); - applyDependencyRules(field); + const handleFirstDepositDateChange = useCallback( + (date: Date | '' | null) => { + setValue('savingsFirstDepositDate', date ?? '', { shouldDirty: true, shouldValidate: true }); }, - [applyDependencyRules, fieldModes, setFieldMode], + [setValue], ); - const handleUnlockDateField = useCallback( - (field: SavingsDateFieldKey) => { + const handleUnlockField = useCallback( + (field: SavingsFieldKey) => { if (fieldModes[field] !== 'derived') return; setFieldMode(field, 'defined'); applyDependencyRules(field); - setDatePickerOpen((current) => ({ ...current, [field]: true })); }, [applyDependencyRules, fieldModes, setFieldMode], ); @@ -323,6 +263,15 @@ export function SavingsFormFields() { 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 && ( @@ -345,40 +294,6 @@ export function SavingsFormFields() { -
- ( - - - handleUnlockDateField('savingsFirstDepositDate')} - > - handleUserFieldChange('savingsFirstDepositDate', date ?? '')} - placeholder={commonT('ui.form.enterPlaceholder')} - disabled={isFieldLocked('savingsFirstDepositDate', hasDateValue(field.value))} - open={datePickerOpen.savingsFirstDepositDate} - onOpenChange={(open) => { - if (isFieldLocked('savingsFirstDepositDate', hasDateValue(field.value))) return; - setDatePickerOpen((current) => ({ ...current, savingsFirstDepositDate: open })); - }} - /> - - - - )} - /> -
- {isFixedRate ? (
)} -
+
( - {t('new.form.savingsFirstDepositDate')} + handleFirstDepositDateChange(date ?? '')} + placeholder={commonT('ui.form.enterPlaceholder')} + open={firstDepositDatePickerOpen} + onOpenChange={setFirstDepositDatePickerOpen} /> - handleUnlockDateField('savingsLastDepositDate')} - > - handleUserFieldChange('savingsLastDepositDate', date ?? '')} - placeholder={commonT('ui.form.enterPlaceholder')} - disabled={isFieldLocked('savingsLastDepositDate', hasDateValue(field.value))} - open={datePickerOpen.savingsLastDepositDate} - onOpenChange={(open) => { - if (isFieldLocked('savingsLastDepositDate', hasDateValue(field.value))) return; - setDatePickerOpen((current) => ({ ...current, savingsLastDepositDate: open })); - }} - /> - )} /> + {calculatedLastDepositDate && ( +
+ +

{formatDateLong(calculatedLastDepositDate, locale)}

+
+ )}
)} diff --git a/src/lib/loans/loan-end-date-sanity-check.ts b/src/lib/loans/loan-end-date-sanity-check.ts index 45d9bd59..9f97d9ec 100644 --- a/src/lib/loans/loan-end-date-sanity-check.ts +++ b/src/lib/loans/loan-end-date-sanity-check.ts @@ -66,6 +66,7 @@ export function evaluateLoanEndDateSanityChecks( toDate(input.savingsFirstDepositDate), toDate(input.savingsLastDepositDate), depositCount, + toDate(input.signDate), ); if (savingsLastDepositDate && !isOnOrAfter(contractEndDate, savingsLastDepositDate)) { diff --git a/src/lib/loans/savings-contract.ts b/src/lib/loans/savings-contract.ts index 3a476a52..b94f50e1 100644 --- a/src/lib/loans/savings-contract.ts +++ b/src/lib/loans/savings-contract.ts @@ -116,11 +116,17 @@ export const buildCountBasedCoverageTimeline = ( })); }; -export const getDefaultFirstDepositDate = (signDate: unknown) => { - const base = signDate instanceof Date ? signDate : signDate ? moment(signDate).toDate() : new Date(); - return moment(base).add(1, 'month').startOf('month').toDate(); +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; @@ -164,34 +170,27 @@ export const calculateSavingsDepositCountFromMonthlyAmount = (loanAmount: number }; export const resolveSavingsLastDepositDate = ( - firstDepositDate: Date | null | undefined, - lastDepositDate: Date | null | undefined, + firstDepositDate: Date | string | null | undefined, + lastDepositDate: Date | string | null | undefined, depositCount: number | null | undefined, + signDate?: Date | string | null | undefined, ) => { - if (lastDepositDate) { - const lastMoment = moment(lastDepositDate); - if (lastMoment.isValid()) return lastMoment.toDate(); - } + const resolvedLast = toValidDate(lastDepositDate); + if (resolvedLast) return resolvedLast; - if (firstDepositDate && depositCount != null && depositCount >= 1) { - return calculateSavingsLastDepositDate(firstDepositDate, depositCount); + const resolvedFirst = resolveSavingsFirstDepositDate(firstDepositDate, signDate); + if (resolvedFirst && depositCount != null && depositCount >= 1) { + return calculateSavingsLastDepositDate(resolvedFirst, depositCount); } return null; }; export const getExpectedDepositSchedule = (loan: LoanForSchedule): ExpectedTransaction[] => { - if ( - loan.isSavingsContract && - loan.savingsFirstDepositDate && - loan.savingsDepositCount != null && - loan.savingsDepositCount >= 1 - ) { - const firstMoment = moment(loan.savingsFirstDepositDate); - if (!firstMoment.isValid()) { - return []; - } + 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 diff --git a/src/lib/schemas/loan.ts b/src/lib/schemas/loan.ts index dfbce0ca..2a35db2e 100644 --- a/src/lib/schemas/loan.ts +++ b/src/lib/schemas/loan.ts @@ -91,14 +91,6 @@ export const validateSavings = (data: LoanSavingsData, ctx: z.RefinementCtx) => }); } - if (!data.savingsFirstDepositDate) { - ctx.addIssue({ - code: 'custom', - message: 'validation.common.required', - path: ['savingsFirstDepositDate'], - }); - } - if (data.savingsRateType === 'FIXED') { if (!data.savingsMonthlyAmount) { ctx.addIssue({ diff --git a/src/lib/templates/template-data.ts b/src/lib/templates/template-data.ts index 88c5d780..f9f4beab 100644 --- a/src/lib/templates/template-data.ts +++ b/src/lib/templates/template-data.ts @@ -3,6 +3,7 @@ import { Prisma, type TemplateDataset, type Transaction, TransactionType } from import { calculateLenderFields } from '@/lib/calculations/lender-calculations'; import { calculateLoanFields, calculateLoanPerYear } from '@/lib/calculations/loan-calculations'; import { db } from '@/lib/db'; +import { resolveSavingsFirstDepositDate, resolveSavingsLastDepositDate } from '@/lib/loans/savings-contract'; import { getSoliloanProjectName } from '@/lib/project-name'; import { lenderFilesRelation, @@ -403,6 +404,18 @@ function formatSavingsPaymentStatus(loan: TemplateLoanRecord) { } 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), @@ -420,10 +433,10 @@ function formatLoanFields(loan: TemplateLoanRecord, locale: string) { loan.isSavingsContract && loan.savingsRateType === 'FIXED' ? formatCurrency(loan.savingsMonthlyAmount, locale) : '', savingsDepositCount: loan.isSavingsContract && loan.savingsDepositCount != null ? String(loan.savingsDepositCount) : '', - savingsFirstDepositDate: loan.isSavingsContract ? formatDateShort(loan.savingsFirstDepositDate, locale) : '', - savingsFirstDepositDateLong: loan.isSavingsContract ? formatDateLong(loan.savingsFirstDepositDate, locale) : '', - savingsLastDepositDate: loan.isSavingsContract ? formatDateShort(loan.savingsLastDepositDate, locale) : '', - savingsLastDepositDateLong: loan.isSavingsContract ? formatDateLong(loan.savingsLastDepositDate, locale) : '', + 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), diff --git a/src/messages/de/dashboard.json b/src/messages/de/dashboard.json index b6c67873..aff76965 100644 --- a/src/messages/de/dashboard.json +++ b/src/messages/de/dashboard.json @@ -823,7 +823,7 @@ "savingsDepositCountFixed": "Anzahl der Einzahlungen", "savingsDepositCountVarying": "Anzahl erwarteter Einzahlungen", "savingsFirstDepositDate": "Erste Einzahlung (erwartetes Datum)", - "savingsLastDepositDate": "Letzte Einzahlung (erwartetes Datum)", + "savingsLastDepositDate": "Letzte Einzahlung", "savingsLastDeposit": "Letzte Einzahlung:", "savingsRuntime": "Laufzeit:", "savingsContractSummaryLabel": "Ansparvertrag:", @@ -879,7 +879,7 @@ "savingsDepositCountFixed": "Anzahl der Einzahlungen", "savingsDepositCountVarying": "Anzahl erwarteter Einzahlungen", "savingsFirstDepositDate": "Erste Einzahlung (erwartetes Datum)", - "savingsLastDepositDate": "Letzte Einzahlung (erwartetes Datum)", + "savingsLastDepositDate": "Letzte Einzahlung", "savingsFieldDefined": "Manuell festgelegt", "savingsFieldDerived": "Automatisch berechnet", "savingsFieldUnlock": "Zum Bearbeiten entsperren", From eec5a353e2849706a5dfd2ade8f2626df0c72f59 Mon Sep 17 00:00:00 2001 From: Thomas Huber Date: Thu, 16 Jul 2026 17:18:56 +0200 Subject: [PATCH 09/16] feat: #26 improved UI (cherry picked from commit 42a8cb558d91792d0c867023f5d39639d140513c) --- .../widgets/filters/entity-filter-control.tsx | 9 +++++ src/components/filters/boolean-filter.tsx | 36 +++++++++++++++++++ src/components/loans/savings-form-fields.tsx | 2 +- .../ui/data-table-column-filters.tsx | 22 +++++++++--- .../boolean-filter.tsx | 15 ++++++++ .../ui/data-table-column-filters/index.ts | 1 + src/components/ui/data-table-header.tsx | 3 +- src/components/ui/data-table.tsx | 2 +- .../transaction-table-column-registry.tsx | 1 + src/lib/entity-filters/filter-definitions.ts | 8 ++--- src/lib/entity-filters/filter-matchers.ts | 12 +++++++ src/lib/table-column-utils.tsx | 15 +++++--- src/lib/templates/template-data.ts | 8 +++-- src/messages/de/dashboard.json | 2 +- src/types/boolean-filter-value.ts | 21 +++++++++++ 15 files changed, 135 insertions(+), 22 deletions(-) create mode 100644 src/components/filters/boolean-filter.tsx create mode 100644 src/components/ui/data-table-column-filters/boolean-filter.tsx create mode 100644 src/types/boolean-filter-value.ts 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/loans/savings-form-fields.tsx b/src/components/loans/savings-form-fields.tsx index 98718c35..e1fe609e 100644 --- a/src/components/loans/savings-form-fields.tsx +++ b/src/components/loans/savings-form-fields.tsx @@ -381,7 +381,7 @@ export function SavingsFormFields() { />
) : ( -
+
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/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 a242b36b..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; @@ -108,12 +108,8 @@ export function buildLoanColumnFiltersMap( })), }, isSavingsContract: { - type: 'select', + type: 'boolean', label: t('table.isSavingsContract'), - options: [ - { label: commonT('ui.boolean.yes'), value: 'true' }, - { label: commonT('ui.boolean.no'), value: 'false' }, - ], }, ...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/table-column-utils.tsx b/src/lib/table-column-utils.tsx index 12aebdac..664de393 100644 --- a/src/lib/table-column-utils.tsx +++ b/src/lib/table-column-utils.tsx @@ -4,7 +4,9 @@ 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'; @@ -22,11 +24,8 @@ export function compoundTextFilter(row: Row, columnId: string, filterValue // Define the custom filter function for boolean fields export function booleanFilter(row: Row, columnId: string, filterValue: unknown) { - if (filterValue === '' || filterValue == null) { - return true; - } - const value = row.getValue(columnId) === true; - return filterValue === 'true' ? value : !value; + const raw = row.getValue(columnId) === true ? 'true' : 'false'; + return matchesBooleanFilter(raw, filterValue); } // Define the custom filter function for enum fields @@ -867,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; diff --git a/src/lib/templates/template-data.ts b/src/lib/templates/template-data.ts index f9f4beab..b18b65da 100644 --- a/src/lib/templates/template-data.ts +++ b/src/lib/templates/template-data.ts @@ -4,13 +4,13 @@ import { calculateLenderFields } from '@/lib/calculations/lender-calculations'; import { calculateLoanFields, calculateLoanPerYear } from '@/lib/calculations/loan-calculations'; import { db } from '@/lib/db'; import { resolveSavingsFirstDepositDate, resolveSavingsLastDepositDate } from '@/lib/loans/savings-contract'; -import { getSoliloanProjectName } from '@/lib/project-name'; 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'; @@ -379,7 +379,7 @@ function buildTransactionsYearlyList( function savingsRateTypeLabel(rateType: string | null | undefined) { if (rateType === 'FIXED') return 'Feste Rate'; - if (rateType === 'VARYING') return 'Ungleiche Raten'; + if (rateType === 'VARYING') return 'Variable Raten'; return ''; } @@ -430,7 +430,9 @@ function formatLoanFields(loan: TemplateLoanRecord, locale: string) { isSavingsContract: loan.isSavingsContract ? 'Ja' : 'Nein', savingsRateType: loan.isSavingsContract ? savingsRateTypeLabel(loan.savingsRateType) : '', savingsMonthlyAmount: - loan.isSavingsContract && loan.savingsRateType === 'FIXED' ? formatCurrency(loan.savingsMonthlyAmount, locale) : '', + loan.isSavingsContract && loan.savingsRateType === 'FIXED' + ? formatCurrency(loan.savingsMonthlyAmount, locale) + : '', savingsDepositCount: loan.isSavingsContract && loan.savingsDepositCount != null ? String(loan.savingsDepositCount) : '', savingsFirstDepositDate: loan.isSavingsContract ? formatDateShort(resolvedFirstDepositDate, locale) : '', diff --git a/src/messages/de/dashboard.json b/src/messages/de/dashboard.json index aff76965..800061b4 100644 --- a/src/messages/de/dashboard.json +++ b/src/messages/de/dashboard.json @@ -874,7 +874,7 @@ "savingsContract": "Ansparvertrag", "savingsRateType": "Einzahlungsart", "savingsRateTypeFixed": "Feste Rate", - "savingsRateTypeVarying": "Ungleiche Raten", + "savingsRateTypeVarying": "Variable Raten", "savingsMonthlyAmount": "Monatlicher Betrag", "savingsDepositCountFixed": "Anzahl der Einzahlungen", "savingsDepositCountVarying": "Anzahl erwarteter Einzahlungen", 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) === ''; +} From e0f13c9de5461ef910f1f49b02c67cd0fa583354 Mon Sep 17 00:00:00 2001 From: Thomas Huber Date: Thu, 23 Jul 2026 09:24:22 +0200 Subject: [PATCH 10/16] feat: #26 improved the savings contracts summary placeholder (cherry picked from commit f23e44bd13b2ddc443c31dc86beb8ed50cb92b17) --- src/lib/templates/template-data.ts | 16 +++++++++++++--- src/messages/de/dashboard.json | 4 ++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/lib/templates/template-data.ts b/src/lib/templates/template-data.ts index b18b65da..7a8a1a6e 100644 --- a/src/lib/templates/template-data.ts +++ b/src/lib/templates/template-data.ts @@ -1,4 +1,5 @@ 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'; @@ -15,6 +16,7 @@ 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'; @@ -386,12 +388,20 @@ function savingsRateTypeLabel(rateType: string | null | undefined) { function formatSavingsSummary(loan: TemplateLoanRecord, locale: string) { if (!loan.isSavingsContract || loan.savingsDepositCount == null) return ''; - const months = loan.savingsDepositCount === 1 ? '1 Monat' : `${loan.savingsDepositCount} Monate`; + const t = createTranslator({ + locale: 'de', + messages: deDashboardMessages, + namespace: 'loans.table', + }); + if (loan.savingsRateType === 'FIXED' && loan.savingsMonthlyAmount != null) { - return `${months} zu je ${formatCurrency(loan.savingsMonthlyAmount, locale)}`; + return t('savingsContractFixedSummary', { + months: loan.savingsDepositCount, + amount: formatCurrency(loan.savingsMonthlyAmount, locale), + }); } - return months; + return t('savingsContractSummary', { months: loan.savingsDepositCount }); } function formatSavingsPaymentStatus(loan: TemplateLoanRecord) { diff --git a/src/messages/de/dashboard.json b/src/messages/de/dashboard.json index 800061b4..74dd0c1a 100644 --- a/src/messages/de/dashboard.json +++ b/src/messages/de/dashboard.json @@ -828,8 +828,8 @@ "savingsRuntime": "Laufzeit:", "savingsContractSummaryLabel": "Ansparvertrag:", "paymentStatus": "Zahlungstatus:", - "savingsContractFixedSummary": "Über {months} Monate zu je {amount}", - "savingsContractSummary": "Über {months} Monate", + "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", From 69dcd2535729caf9628792e7a74282c500d1cd7e Mon Sep 17 00:00:00 2001 From: Thomas Huber Date: Tue, 7 Jul 2026 12:10:29 +0200 Subject: [PATCH 11/16] feat: #103 filters with operators (cherry picked from commit b20d7d4556f54c903a7ec23b8caa381da9ccf8aa) --- .../projects/projects-page-content.tsx | 10 +- .../widgets/filters/entity-date-filter.tsx | 207 +----------- .../widgets/filters/entity-filter-control.tsx | 27 +- .../widgets/stat-delta-range-input.tsx | 11 +- .../filters/date-filter-with-operator.tsx | 310 ++++++++++++++++++ .../filters/enum-filter-with-operator.tsx | 176 ++++++++++ .../filters/filter-date-segment.tsx | 83 +++++ src/components/filters/filter-field-group.tsx | 112 +++++++ .../filters/number-filter-with-operator.tsx | 158 +++++++++ .../filters/text-filter-with-operator.tsx | 121 +++++++ .../ui/data-table-column-filters.tsx | 60 +++- .../data-table-column-filters/date-filter.tsx | 158 ++------- .../multi-select-filter.tsx | 70 ++-- .../number-filter.tsx | 50 ++- .../select-filter.tsx | 41 ++- .../data-table-column-filters/text-filter.tsx | 29 +- src/components/ui/data-table.tsx | 69 +--- src/lib/entity-filters/filter-definitions.ts | 53 +-- src/lib/entity-filters/filter-matchers.ts | 157 +++++++-- .../resolve-date-filter-range.ts | 85 ++++- src/lib/table-column-utils.tsx | 32 +- src/messages/de/dashboard.json | 48 ++- src/messages/de/dataTable.json | 49 ++- src/types/date-filter-value.ts | 178 ++++++++++ src/types/entity-date-filter.ts | 75 ----- src/types/enum-filter-value.ts | 113 +++++++ src/types/filter-operators.ts | 4 + src/types/number-filter-value.ts | 121 +++++++ src/types/text-filter-value.ts | 88 +++++ 29 files changed, 2023 insertions(+), 672 deletions(-) create mode 100644 src/components/filters/date-filter-with-operator.tsx create mode 100644 src/components/filters/enum-filter-with-operator.tsx create mode 100644 src/components/filters/filter-date-segment.tsx create mode 100644 src/components/filters/filter-field-group.tsx create mode 100644 src/components/filters/number-filter-with-operator.tsx create mode 100644 src/components/filters/text-filter-with-operator.tsx create mode 100644 src/types/date-filter-value.ts delete mode 100644 src/types/entity-date-filter.ts create mode 100644 src/types/enum-filter-value.ts create mode 100644 src/types/filter-operators.ts create mode 100644 src/types/number-filter-value.ts create mode 100644 src/types/text-filter-value.ts diff --git a/src/app/[locale]/(dashboard)/projects/projects-page-content.tsx b/src/app/[locale]/(dashboard)/projects/projects-page-content.tsx index c687f17b..e510db8d 100644 --- a/src/app/[locale]/(dashboard)/projects/projects-page-content.tsx +++ b/src/app/[locale]/(dashboard)/projects/projects-page-content.tsx @@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button'; import { DataTable } from '@/components/ui/data-table'; import { DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { useRouter } from '@/i18n/navigation'; +import { matchesTextFilter } from '@/lib/entity-filters/filter-matchers'; import { createColumn, createEnumBadgeColumn } from '@/lib/table-column-utils'; import type { ProjectWithConfiguration } from '@/types/projects'; @@ -78,10 +79,11 @@ export function ProjectsPageContent({ views, projects }: ProjectsPageContentProp }, filterFn: (row, _, filterValue) => { const managers = row.original.managers; - if (!managers || managers.length === 0) return false; - const managerNames = managers.map((m) => m.name.toLowerCase()).join(' '); - const searchValue = String(filterValue).toLowerCase(); - return managerNames.includes(searchValue); + if (!managers || managers.length === 0) { + return matchesTextFilter(null, filterValue); + } + const managerNames = managers.map((m) => m.name).join(' '); + return matchesTextFilter(managerNames, filterValue); }, sortingFn: (rowA, rowB) => { const managersA = rowA.original.managers.map((m) => m.name).join(', '); diff --git a/src/components/dashboard/widgets/filters/entity-date-filter.tsx b/src/components/dashboard/widgets/filters/entity-date-filter.tsx index 62f859fd..64301c45 100644 --- a/src/components/dashboard/widgets/filters/entity-date-filter.tsx +++ b/src/components/dashboard/widgets/filters/entity-date-filter.tsx @@ -1,203 +1,28 @@ 'use client'; -import { de, enUS } from 'date-fns/locale'; -import { X } from 'lucide-react'; -import { useLocale, useTranslations } from 'next-intl'; -import { useMemo } from 'react'; - -import { StatDeltaRangeInput } from '@/components/dashboard/widgets/stat-delta-range-input'; -import { Button } from '@/components/ui/button'; -import { Calendar } from '@/components/ui/calendar'; -import { Label } from '@/components/ui/label'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { cn, formatDateShort } from '@/lib/utils'; -import { STAT_DELTA_UNITS } from '@/types/dashboard-widgets/stat-widget'; -import { - createDefaultEntityDateFilterValue, - createDefaultRelativeEntityDateFilterValue, - type EntityDateFilterMode, - type EntityDateFilterValue, - parseEntityDateFilterValue, -} from '@/types/entity-date-filter'; - -function toIsoDateString(date: Date): string { - return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().split('T')[0]; -} - -function FixedDateButton({ - label, - value, - onChange, - onClear, - dateLocale, - locale, -}: { - label: string; - value: string | null; - onChange: (value: string | undefined) => void; - onClear: () => void; - dateLocale: typeof de; - locale: string; -}) { - return ( - - - -
- ) : ( - {label} - )} - - - - onChange(date ? toIsoDateString(date) : undefined)} - initialFocus - locale={dateLocale} - /> - - - ); -} +import { DateFilterWithOperator } from '@/components/filters/date-filter-with-operator'; +import type { DateFilterValue } from '@/types/date-filter-value'; export function EntityDateFilter({ value, onChange, + allowEmpty = false, + referenceDate, }: { value: unknown; - onChange: (value: EntityDateFilterValue) => void; + onChange: (value: DateFilterValue) => void; + allowEmpty?: boolean; + referenceDate?: Date; }) { - const t = useTranslations('dashboard.customizer.historyTable'); - const tStat = useTranslations('dashboard.customizer.stat'); - const locale = useLocale(); - const dateLocale = locale === 'de' ? de : enUS; - - const parsed = useMemo(() => parseEntityDateFilterValue(value), [value]); - - const unitOptions = useMemo( - () => - STAT_DELTA_UNITS.map((unit) => ({ - value: unit, - label: tStat(`deltaUnits.${unit}`), - })), - [tStat], - ); - - const setMode = (mode: EntityDateFilterMode) => { - if (mode === parsed.mode) { - return; - } - onChange(mode === 'relative' ? createDefaultRelativeEntityDateFilterValue() : createDefaultEntityDateFilterValue()); - }; - - if (parsed.mode === 'relative') { - return ( -
-
- - -
- onChange({ mode: 'relative', amount: next.amount, unit: next.unit })} - numberLabel={t('dateFilterRelative')} - unitOptions={unitOptions} - /> -
- ); - } - return ( -
-
- - -
-
- - onChange({ - mode: 'fixed', - start: start ?? null, - end: parsed.end, - }) - } - onClear={() => - onChange({ - mode: 'fixed', - start: null, - end: parsed.end, - }) - } - /> - {t('dateFilterTo')} - - onChange({ - mode: 'fixed', - start: parsed.start, - end: end ?? null, - }) - } - onClear={() => - onChange({ - mode: 'fixed', - start: parsed.start, - end: null, - }) - } - /> -
-
+ ); } diff --git a/src/components/dashboard/widgets/filters/entity-filter-control.tsx b/src/components/dashboard/widgets/filters/entity-filter-control.tsx index e0bb8f94..b8d80f02 100644 --- a/src/components/dashboard/widgets/filters/entity-filter-control.tsx +++ b/src/components/dashboard/widgets/filters/entity-filter-control.tsx @@ -37,7 +37,10 @@ export function EntityFilterControl({ onChange(v)} + variant="stacked" + size="sm" /> ); case 'multi-select': @@ -45,20 +48,40 @@ export function EntityFilterControl({ onChange(v)} + variant="stacked" + size="sm" /> ); case 'number': - return onChange(v)} />; + return ( + onChange(v)} + variant="stacked" + size="sm" + /> + ); case 'date': - return ; + return ( + + ); default: return ( onChange(v)} + variant="stacked" + size="sm" /> ); } diff --git a/src/components/dashboard/widgets/stat-delta-range-input.tsx b/src/components/dashboard/widgets/stat-delta-range-input.tsx index 02a0730f..4328eda4 100644 --- a/src/components/dashboard/widgets/stat-delta-range-input.tsx +++ b/src/components/dashboard/widgets/stat-delta-range-input.tsx @@ -12,16 +12,18 @@ export function StatDeltaRangeInput({ numberLabel, unitOptions, className, + hideLabel = false, }: { value: StatDeltaRange; onChange: (value: StatDeltaRange) => void; numberLabel: string; unitOptions: { value: StatDeltaUnit; label: string }[]; className?: string; + hideLabel?: boolean; }) { return ( -
- +
+ {hideLabel ? null : }
onChange(op as DateFilterOperatorWithLegacy)}> + + + + + {availableOperators.map((operator) => ( + + {operatorLabel(operator)} + + ))} + + + ); +} + +function RelativeDateAmountFields({ + value, + onChange, + size, + unitOptions, +}: { + value: DateFilterRelativeAmountValue; + onChange: (value: DateFilterRelativeAmountValue) => void; + size: FilterFieldSize; + unitOptions: { value: StatDeltaUnit; label: string }[]; +}) { + return ( + <> + { + const raw = e.target.value; + onChange({ + amount: raw === '' ? 0 : Number.parseInt(raw, 10) || 0, + unit: value.unit, + }); + }} + className={filterInputSegmentClass(size, 'amount')} + /> + + + ); +} + +function DateFilterPayload({ + parsed, + onChange, + variant, + size, + refDate, + t, + unitOptions, +}: { + parsed: DateFilterValue; + onChange: (value: DateFilterValue) => void; + variant: FilterFieldVariant; + size: FilterFieldSize; + refDate: Date; + t: ReturnType; + unitOptions: { value: StatDeltaUnit; label: string }[]; +}) { + switch (parsed.operator) { + case 'between': + return ( + <> + + onChange({ + operator: 'between', + start: start ?? null, + end: parsed.end, + }) + } + onClear={() => + onChange({ + operator: 'between', + start: null, + end: parsed.end, + }) + } + /> + + onChange({ + operator: 'between', + start: parsed.start, + end: end ?? null, + }) + } + onClear={() => + onChange({ + operator: 'between', + start: parsed.start, + end: null, + }) + } + /> + + ); + case 'last': + case 'next': + case 'olderThan': + case 'newerThan': + return ( + + onChange({ + operator: parsed.operator, + amount, + unit, + }) + } + size={size} + unitOptions={unitOptions} + /> + ); + case 'year': + return ( + { + const raw = e.target.value; + const year = raw === '' ? refDate.getFullYear() : Number.parseInt(raw, 10) || refDate.getFullYear(); + onChange({ operator: 'year', year }); + }} + className={filterInputSegmentClass(size, 'year')} + /> + ); + default: + return null; + } +} + +function hasPayload(operator: DateFilterOperatorWithLegacy): boolean { + return ( + operator === 'between' || + operator === 'last' || + operator === 'next' || + operator === 'olderThan' || + operator === 'newerThan' || + operator === 'year' + ); +} + +export function DateFilterWithOperator({ + value, + onChange, + allowEmpty = false, + referenceDate, + translationNamespace = 'dataTable', + variant = 'row', + size = 'default', +}: { + value: unknown; + onChange: (value: DateFilterValue) => void; + allowEmpty?: boolean; + referenceDate?: Date; + translationNamespace?: string; + variant?: FilterFieldVariant; + size?: FilterFieldSize; +}) { + const t = useTranslations(translationNamespace); + const tStat = useTranslations('dashboard.customizer.stat'); + const refDate = referenceDate ?? new Date(); + + const parsed = useMemo(() => parseDateFilterValue(value), [value]); + + const availableOperators = useMemo(() => { + const emptyOps = allowEmpty ? [...DATE_FILTER_EMPTY_OPERATORS] : []; + // Keep year parseable but only offer it when already selected (legacy values). + const legacy = + parsed.operator === 'year' ? DATE_FILTER_LEGACY_OPERATORS.filter((op) => op === 'year') : []; + return [...DATE_FILTER_OPERATORS, ...legacy, ...emptyOps]; + }, [allowEmpty, parsed.operator]); + + const unitOptions = useMemo( + () => + DATE_FILTER_LAST_UNITS.map((unit) => ({ + value: unit as StatDeltaUnit, + label: tStat(`deltaUnits.${unit}`), + })), + [tStat], + ); + + const setOperator = (operator: DateFilterOperatorWithLegacy) => { + if (operator === parsed.operator) { + return; + } + onChange(createDefaultDateFilterValueForOperator(operator, refDate)); + }; + + const operatorLabel = (operator: DateFilterOperatorWithLegacy) => t(`dateFilterOperators.${operator}`); + const showPayload = hasPayload(parsed.operator); + const useStackedLayout = variant === 'stacked' && showPayload; + + const operatorSelect = ( + + ); + + const payload = showPayload ? ( + + ) : null; + + if (useStackedLayout) { + return ; + } + + return ( + + {operatorSelect} + {payload} + + ); +} diff --git a/src/components/filters/enum-filter-with-operator.tsx b/src/components/filters/enum-filter-with-operator.tsx new file mode 100644 index 00000000..68f52223 --- /dev/null +++ b/src/components/filters/enum-filter-with-operator.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { ChevronDown } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useMemo, type ReactNode } from 'react'; + +import { + FilterFieldGroup, + FilterStackedFields, + filterOperatorSegmentClass, + filterValueSegmentClass, + type FilterFieldSize, + type FilterFieldVariant, +} from '@/components/filters/filter-field-group'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { cn } from '@/lib/utils'; +import { + createDefaultEnumFilterValueForOperator, + ENUM_FILTER_EMPTY_OPERATORS, + ENUM_FILTER_OPERATORS, + type EnumFilterOperator, + type EnumFilterOperatorWithEmpty, + type EnumFilterValue, + parseEnumFilterValue, +} from '@/types/enum-filter-value'; + +function hasPayload(operator: EnumFilterOperatorWithEmpty): boolean { + return operator !== 'empty' && operator !== 'notEmpty'; +} + +export function EnumFilterWithOperator({ + value, + onChange, + options, + allowEmpty = false, + defaultOperator = 'eq', + translationNamespace = 'dataTable', + variant = 'row', + size = 'default', +}: { + value: unknown; + onChange: (value: EnumFilterValue) => void; + options: { label: string; value: string }[]; + allowEmpty?: boolean; + defaultOperator?: EnumFilterOperator; + translationNamespace?: string; + variant?: FilterFieldVariant; + size?: FilterFieldSize; +}) { + const t = useTranslations(translationNamespace); + const tCommon = useTranslations('common.ui'); + const parsed = useMemo(() => parseEnumFilterValue(value, defaultOperator), [value, defaultOperator]); + + const availableOperators = useMemo(() => { + const emptyOps = allowEmpty ? [...ENUM_FILTER_EMPTY_OPERATORS] : []; + return [...ENUM_FILTER_OPERATORS, ...emptyOps]; + }, [allowEmpty]); + + const setOperator = (operator: EnumFilterOperatorWithEmpty) => { + if (operator === parsed.operator) { + return; + } + onChange(createDefaultEnumFilterValueForOperator(operator)); + }; + + const showPayload = hasPayload(parsed.operator); + const useStackedLayout = variant === 'stacked' && showPayload; + + const operatorSelect = ( + + ); + + let payload: ReactNode = null; + if (parsed.operator === 'eq') { + payload = ( + + ); + } else if (parsed.operator === 'in') { + const selected = parsed.values; + payload = ( + + + + + + {options.map((option) => { + const checked = selected.includes(option.value); + return ( + { + const next = + nextChecked === true + ? [...selected, option.value] + : selected.filter((v) => v !== option.value); + onChange({ operator: 'in', values: next }); + }} + onSelect={(e) => e.preventDefault()} + > + {option.label} + + ); + })} + + + ); + } + + if (useStackedLayout) { + return ; + } + + return ( + + {operatorSelect} + {payload} + + ); +} diff --git a/src/components/filters/filter-date-segment.tsx b/src/components/filters/filter-date-segment.tsx new file mode 100644 index 00000000..e1f337a7 --- /dev/null +++ b/src/components/filters/filter-date-segment.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { de, enUS } from 'date-fns/locale'; +import { X } from 'lucide-react'; +import { useLocale } from 'next-intl'; + +import { + filterDateSegmentClass, + type FilterFieldSize, + type FilterFieldVariant, +} from '@/components/filters/filter-field-group'; +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn, formatDateLong, formatDateShort } from '@/lib/utils'; + +function toIsoDateString(date: Date): string { + return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().split('T')[0]; +} + +export function FilterDateSegment({ + label, + value, + onChange, + onClear, + variant, + size = 'default', + className, +}: { + label: string; + value: string | null; + onChange: (value: string | undefined) => void; + onClear: () => void; + variant: FilterFieldVariant; + size?: FilterFieldSize; + className?: string; +}) { + const locale = useLocale(); + const dateLocale = locale === 'de' ? de : enUS; + const formatDateValue = size === 'sm' || variant === 'stacked' ? formatDateShort : formatDateLong; + + return ( + + + +
+ ) : ( + {label} + )} + + + + onChange(date ? toIsoDateString(date) : undefined)} + initialFocus + locale={dateLocale} + /> + + + ); +} diff --git a/src/components/filters/filter-field-group.tsx b/src/components/filters/filter-field-group.tsx new file mode 100644 index 00000000..eb6ee030 --- /dev/null +++ b/src/components/filters/filter-field-group.tsx @@ -0,0 +1,112 @@ +import type { ReactNode } from 'react'; + +import { cn } from '@/lib/utils'; + +export type FilterFieldVariant = 'row' | 'stacked'; +export type FilterFieldSize = 'default' | 'sm'; + +const horizontalFusionClass = + '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none'; + +const verticalFusionClass = + '[&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none'; + +export function FilterFieldGroup({ + children, + className, + orientation = 'horizontal', +}: { + children: ReactNode; + className?: string; + orientation?: 'horizontal' | 'vertical'; +}) { + return ( +
*]:focus-visible:relative [&>*]:focus-visible:z-10', + orientation === 'vertical' ? cn('flex-col', verticalFusionClass) : cn('flex-row', horizontalFusionClass), + className, + )} + > + {children} +
+ ); +} + +/** Operator on its own rounded row; value inputs fused on a second row below. */ +export function FilterStackedFields({ + operator, + payload, +}: { + operator: ReactNode; + payload: ReactNode; +}) { + return ( +
+ {operator} + {payload ? {payload} : null} +
+ ); +} + +function filterSizeClass(size: FilterFieldSize) { + // Override Input/SelectTrigger `text-base md:text-sm` at all breakpoints. + return size === 'sm' ? 'h-8 text-xs md:text-xs' : 'h-9'; +} + +export function filterOperatorSegmentClass(size: FilterFieldSize = 'default', fullWidth = false) { + return cn( + 'gap-1 shadow-none', + fullWidth ? 'w-full' : 'w-auto shrink-0', + size === 'sm' ? 'h-8 px-2 text-xs md:text-xs' : 'h-9 px-3', + '[&>span]:line-clamp-none [&>span]:whitespace-nowrap', + ); +} + +export function filterInputSegmentClass( + size: FilterFieldSize = 'default', + width: 'amount' | 'year' = 'amount', +) { + return cn( + 'shadow-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none', + filterSizeClass(size), + width === 'amount' ? 'w-14 shrink-0' : 'w-24 shrink-0', + ); +} + +export function filterDateSegmentClass(variant: FilterFieldVariant, size: FilterFieldSize = 'default') { + return cn( + 'min-w-0 justify-start px-2 text-left font-normal shadow-none', + variant === 'stacked' ? 'flex-1' : 'w-[7.25rem] shrink', + filterSizeClass(size), + ); +} + +export function filterUnitSegmentClass(size: FilterFieldSize = 'default') { + return cn('w-[100px] shrink-0 shadow-none', filterSizeClass(size)); +} + +export function filterValueSegmentClass(size: FilterFieldSize = 'default') { + return cn( + 'min-w-0 flex-1 shadow-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none', + filterSizeClass(size), + ); +} + +/** Comparison symbols (=, >, <, …) read small at default select size. */ +export const FILTER_SYMBOL_OPERATORS = new Set(['eq', 'gt', 'lt', 'gte', 'lte']); + +export function filterSymbolOperatorLabelClass(operator: string, size: FilterFieldSize = 'default') { + if (!FILTER_SYMBOL_OPERATORS.has(operator) || size === 'sm') { + return undefined; + } + return 'text-lg leading-none'; +} + +export function filterSymbolOperatorTriggerClass(operator: string, size: FilterFieldSize = 'default') { + if (!FILTER_SYMBOL_OPERATORS.has(operator) || size === 'sm') { + return undefined; + } + return 'text-lg'; +} diff --git a/src/components/filters/number-filter-with-operator.tsx b/src/components/filters/number-filter-with-operator.tsx new file mode 100644 index 00000000..51e8ee0e --- /dev/null +++ b/src/components/filters/number-filter-with-operator.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useMemo, type ReactNode } from 'react'; + +import { + FilterFieldGroup, + FilterStackedFields, + filterOperatorSegmentClass, + filterSymbolOperatorLabelClass, + filterSymbolOperatorTriggerClass, + filterValueSegmentClass, + type FilterFieldSize, + type FilterFieldVariant, +} from '@/components/filters/filter-field-group'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { cn } from '@/lib/utils'; +import { + createDefaultNumberFilterValueForOperator, + NUMBER_FILTER_EMPTY_OPERATORS, + NUMBER_FILTER_OPERATORS, + type NumberFilterOperatorWithEmpty, + type NumberFilterValue, + parseNumberFilterValue, +} from '@/types/number-filter-value'; + +function hasPayload(operator: NumberFilterOperatorWithEmpty): boolean { + return operator !== 'empty' && operator !== 'notEmpty'; +} + +export function NumberFilterWithOperator({ + value, + onChange, + allowEmpty = false, + translationNamespace = 'dataTable', + variant = 'row', + size = 'default', +}: { + value: unknown; + onChange: (value: NumberFilterValue) => void; + allowEmpty?: boolean; + translationNamespace?: string; + variant?: FilterFieldVariant; + size?: FilterFieldSize; +}) { + const t = useTranslations(translationNamespace); + const parsed = useMemo(() => parseNumberFilterValue(value), [value]); + + const availableOperators = useMemo(() => { + const emptyOps = allowEmpty ? [...NUMBER_FILTER_EMPTY_OPERATORS] : []; + return [...NUMBER_FILTER_OPERATORS, ...emptyOps]; + }, [allowEmpty]); + + const setOperator = (operator: NumberFilterOperatorWithEmpty) => { + if (operator === parsed.operator) { + return; + } + onChange(createDefaultNumberFilterValueForOperator(operator)); + }; + + const showPayload = hasPayload(parsed.operator); + const useStackedLayout = variant === 'stacked' && showPayload; + + const operatorSelect = ( + + ); + + let payload: ReactNode = null; + if (parsed.operator === 'between') { + payload = ( + <> + { + const next = e.target.value === '' ? null : Number(e.target.value); + onChange({ + operator: 'between', + min: next, + max: parsed.max, + }); + }} + className={filterValueSegmentClass(size)} + /> + { + const next = e.target.value === '' ? null : Number(e.target.value); + onChange({ + operator: 'between', + min: parsed.min, + max: next, + }); + }} + className={filterValueSegmentClass(size)} + /> + + ); + } else if ( + parsed.operator === 'eq' || + parsed.operator === 'gt' || + parsed.operator === 'lt' || + parsed.operator === 'gte' || + parsed.operator === 'lte' + ) { + payload = ( + { + const next = e.target.value === '' ? null : Number(e.target.value); + onChange({ + operator: parsed.operator, + value: next, + }); + }} + className={filterValueSegmentClass(size)} + /> + ); + } + + if (useStackedLayout) { + return ; + } + + return ( + + {operatorSelect} + {payload} + + ); +} diff --git a/src/components/filters/text-filter-with-operator.tsx b/src/components/filters/text-filter-with-operator.tsx new file mode 100644 index 00000000..a3905689 --- /dev/null +++ b/src/components/filters/text-filter-with-operator.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useMemo } from 'react'; + +import { + FilterFieldGroup, + FilterStackedFields, + filterOperatorSegmentClass, + filterSymbolOperatorLabelClass, + filterSymbolOperatorTriggerClass, + filterValueSegmentClass, + type FilterFieldSize, + type FilterFieldVariant, +} from '@/components/filters/filter-field-group'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { cn } from '@/lib/utils'; +import { + createDefaultTextFilterValueForOperator, + parseTextFilterValue, + TEXT_FILTER_EMPTY_OPERATORS, + TEXT_FILTER_OPERATORS, + type TextFilterOperatorWithEmpty, + type TextFilterValue, +} from '@/types/text-filter-value'; + +function hasPayload(operator: TextFilterOperatorWithEmpty): boolean { + return operator !== 'empty' && operator !== 'notEmpty'; +} + +export function TextFilterWithOperator({ + value, + onChange, + allowEmpty = false, + translationNamespace = 'dataTable', + variant = 'row', + size = 'default', + placeholder, +}: { + value: unknown; + onChange: (value: TextFilterValue) => void; + allowEmpty?: boolean; + translationNamespace?: string; + variant?: FilterFieldVariant; + size?: FilterFieldSize; + placeholder?: string; +}) { + const t = useTranslations(translationNamespace); + const parsed = useMemo(() => parseTextFilterValue(value), [value]); + + const availableOperators = useMemo(() => { + const emptyOps = allowEmpty ? [...TEXT_FILTER_EMPTY_OPERATORS] : []; + return [...TEXT_FILTER_OPERATORS, ...emptyOps]; + }, [allowEmpty]); + + const setOperator = (operator: TextFilterOperatorWithEmpty) => { + if (operator === parsed.operator) { + return; + } + onChange(createDefaultTextFilterValueForOperator(operator)); + }; + + const showPayload = hasPayload(parsed.operator); + const useStackedLayout = variant === 'stacked' && showPayload; + + const operatorSelect = ( + + ); + + const payload = + showPayload && + (parsed.operator === 'contains' || + parsed.operator === 'startsWith' || + parsed.operator === 'endsWith' || + parsed.operator === 'eq') ? ( + + onChange({ + operator: parsed.operator, + value: e.target.value, + }) + } + className={filterValueSegmentClass(size)} + /> + ) : null; + + if (useStackedLayout) { + return ; + } + + return ( + + {operatorSelect} + {payload} + + ); +} diff --git a/src/components/ui/data-table-column-filters.tsx b/src/components/ui/data-table-column-filters.tsx index a00f1a51..6524dcd7 100644 --- a/src/components/ui/data-table-column-filters.tsx +++ b/src/components/ui/data-table-column-filters.tsx @@ -1,5 +1,9 @@ import type { ColumnFiltersState } from '@tanstack/react-table'; +import { isInactiveDateFilterValue } from '@/types/date-filter-value'; +import { isInactiveEnumFilterValue } from '@/types/enum-filter-value'; +import { isInactiveNumberFilterValue } from '@/types/number-filter-value'; +import { isInactiveTextFilterValue } from '@/types/text-filter-value'; import type { SetTableUrlState, TableUrlState } from '@/lib/hooks/use-table-url-state'; import { isInactiveBooleanFilterValue } from '@/types/boolean-filter-value'; @@ -16,6 +20,7 @@ type ColumnFilterConfig = { type: 'text' | 'select' | 'multi-select' | 'number' | 'date' | 'boolean'; options?: { label: string; value: string }[]; label?: string; + allowEmpty?: boolean; }; interface DataTableColumnFiltersProps { @@ -30,11 +35,43 @@ interface DataTableColumnFiltersProps { }; } -function isEmptyFilterValue(value: unknown, type?: ColumnFilterConfig['type']): 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)); + + if (value && typeof value === 'object' && !Array.isArray(value) && 'operator' in value) { + const operator = (value as { operator: string }).operator; + if (operator === 'empty' || operator === 'notEmpty') { + return false; + } + switch (type) { + case 'date': + return isInactiveDateFilterValue(value); + case 'number': + return isInactiveNumberFilterValue(value); + case 'select': + return isInactiveEnumFilterValue(value, 'eq'); + case 'multi-select': + return isInactiveEnumFilterValue(value, 'in'); + default: + return isInactiveTextFilterValue(value); + } + } + + // Legacy shapes before operator migration + switch (type) { + case 'number': + return isInactiveNumberFilterValue(value); + case 'select': + return isInactiveEnumFilterValue(value, 'eq'); + case 'multi-select': + return isInactiveEnumFilterValue(value, 'in'); + case 'date': + return isInactiveDateFilterValue(value); + default: + return isInactiveTextFilterValue(value); + } } export function DataTableColumnFilters({ @@ -45,7 +82,7 @@ export function DataTableColumnFilters({ }: DataTableColumnFiltersProps) { const activeFilters = controlled?.columnFilters ?? tableState?.columnFilters ?? []; - const handleFilterChange = (columnId: string, value: unknown, type?: ColumnFilterConfig['type']) => { + const handleFilterChange = (columnId: string, value: unknown, type: ColumnFilterConfig['type']) => { const filters = activeFilters.filter((filter) => filter.id !== columnId); if (!isEmptyFilterValue(value, type)) { @@ -70,7 +107,7 @@ export function DataTableColumnFilters({ return (
{filterConfig.label || columnId}: -
+
{(() => { switch (filterConfig.type) { case 'boolean': @@ -87,8 +124,9 @@ export function DataTableColumnFilters({ { - handleFilterChange(columnId, value); + handleFilterChange(columnId, value, 'select'); }} /> ); @@ -97,8 +135,9 @@ export function DataTableColumnFilters({ { - handleFilterChange(columnId, value); + handleFilterChange(columnId, value, 'multi-select'); }} /> ); @@ -106,8 +145,9 @@ export function DataTableColumnFilters({ return ( { - handleFilterChange(columnId, value); + handleFilterChange(columnId, value, 'number'); }} /> ); @@ -115,8 +155,9 @@ export function DataTableColumnFilters({ return ( { - handleFilterChange(columnId, value); + handleFilterChange(columnId, value, 'date'); }} /> ); @@ -126,8 +167,9 @@ export function DataTableColumnFilters({ filterState={filterState} label={filterConfig.label} columnId={columnId} + allowEmpty={filterConfig.allowEmpty} onFilterChange={(value) => { - handleFilterChange(columnId, value); + handleFilterChange(columnId, value, 'text'); }} /> ); diff --git a/src/components/ui/data-table-column-filters/date-filter.tsx b/src/components/ui/data-table-column-filters/date-filter.tsx index 1ccb11b7..be07fc9a 100644 --- a/src/components/ui/data-table-column-filters/date-filter.tsx +++ b/src/components/ui/data-table-column-filters/date-filter.tsx @@ -1,146 +1,32 @@ import type { ColumnFilter } from '@tanstack/react-table'; -import { de, enUS } from 'date-fns/locale'; -import { X } from 'lucide-react'; -import { useLocale, useTranslations } from 'next-intl'; -import { Button } from '@/components/ui/button'; -import { Calendar } from '@/components/ui/calendar'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { cn, formatDateLong, formatDateShort } from '@/lib/utils'; +import type { FilterFieldSize, FilterFieldVariant } from '@/components/filters/filter-field-group'; +import { DateFilterWithOperator } from '@/components/filters/date-filter-with-operator'; +import type { DateFilterValue } from '@/types/date-filter-value'; interface DateFilterProps { filterState?: ColumnFilter; - onFilterChange: (value: unknown) => void; - dateFormat?: 'short' | 'long'; + onFilterChange: (value: DateFilterValue | undefined) => void; + allowEmpty?: boolean; + variant?: FilterFieldVariant; + size?: FilterFieldSize; } -export function DateFilter({ filterState, onFilterChange, dateFormat = 'long' }: DateFilterProps) { - const t = useTranslations('dataTable'); - const locale = useLocale(); - const dateLocale = locale === 'de' ? de : enUS; - const formatDateValue = dateFormat === 'short' ? formatDateShort : formatDateLong; - +export function DateFilter({ + filterState, + onFilterChange, + allowEmpty = false, + variant = 'row', + size = 'default', +}: DateFilterProps) { return ( -
-
-
- - - -
- ) : ( - {t('startDate') || 'Start date'} - )} - - - - { - const value = date - ? new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().split('T')[0] - : undefined; - const current = filterState?.value as [string, string] | undefined; - onFilterChange([value, current?.[1]]); - }} - initialFocus - locale={dateLocale} - /> - - -
- {t('to')} -
- - - -
- ) : ( - {t('endDate') || 'End date'} - )} - - - - { - const value = date - ? new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().split('T')[0] - : undefined; - const current = filterState?.value as [string, string] | undefined; - onFilterChange([current?.[0], value]); - }} - initialFocus - locale={dateLocale} - /> - - -
-
-
+ ); } diff --git a/src/components/ui/data-table-column-filters/multi-select-filter.tsx b/src/components/ui/data-table-column-filters/multi-select-filter.tsx index a7162f73..652318f2 100644 --- a/src/components/ui/data-table-column-filters/multi-select-filter.tsx +++ b/src/components/ui/data-table-column-filters/multi-select-filter.tsx @@ -1,57 +1,35 @@ import type { ColumnFilter } from '@tanstack/react-table'; -import { ChevronDown } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; +import type { FilterFieldSize, FilterFieldVariant } from '@/components/filters/filter-field-group'; +import { EnumFilterWithOperator } from '@/components/filters/enum-filter-with-operator'; interface MultiSelectFilterProps { filterState?: ColumnFilter; - onFilterChange: (value: string[]) => void; + onFilterChange: (value: unknown) => void; options: { label: string; value: string }[]; + allowEmpty?: boolean; + variant?: FilterFieldVariant; + size?: FilterFieldSize; } -export function MultiSelectFilter({ filterState, options, onFilterChange }: MultiSelectFilterProps) { - const t = useTranslations('common.ui'); - const value = (filterState?.value as string[] | undefined) ?? []; - +export function MultiSelectFilter({ + filterState, + options, + onFilterChange, + allowEmpty = false, + variant = 'row', + size = 'default', +}: MultiSelectFilterProps) { return ( - - - - - - {options.map((option) => { - const checked = value.includes(option.value); - return ( - { - const next = nextChecked === true ? [...value, option.value] : value.filter((v) => v !== option.value); - onFilterChange(next); - }} - onSelect={(e) => e.preventDefault()} - > - {option.label} - - ); - })} - - + ); } diff --git a/src/components/ui/data-table-column-filters/number-filter.tsx b/src/components/ui/data-table-column-filters/number-filter.tsx index 9b005f72..d70f92e1 100644 --- a/src/components/ui/data-table-column-filters/number-filter.tsx +++ b/src/components/ui/data-table-column-filters/number-filter.tsx @@ -1,41 +1,31 @@ import type { ColumnFilter } from '@tanstack/react-table'; -import { useTranslations } from 'next-intl'; -import { Input } from '@/components/ui/input'; +import type { FilterFieldSize, FilterFieldVariant } from '@/components/filters/filter-field-group'; +import { NumberFilterWithOperator } from '@/components/filters/number-filter-with-operator'; interface NumberFilterProps { filterState?: ColumnFilter; onFilterChange: (value: unknown) => void; + allowEmpty?: boolean; + variant?: FilterFieldVariant; + size?: FilterFieldSize; } -export function NumberFilter({ filterState, onFilterChange }: NumberFilterProps) { - const t = useTranslations('dataTable'); - +export function NumberFilter({ + filterState, + onFilterChange, + allowEmpty = false, + variant = 'row', + size = 'default', +}: NumberFilterProps) { return ( -
- { - const value = e.target.value ? Number(e.target.value) : null; - const current = filterState?.value as [number, number] | undefined; - onFilterChange([value, current?.[1] ?? null]); - }} - className="h-8 w-full [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> - {t('to')} - { - const value = e.target.value ? Number(e.target.value) : null; - const current = filterState?.value as [number, number] | undefined; - onFilterChange([current?.[0] ?? null, value]); - }} - className="h-8 w-full [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> -
+ ); } diff --git a/src/components/ui/data-table-column-filters/select-filter.tsx b/src/components/ui/data-table-column-filters/select-filter.tsx index a10e4991..52c08f84 100644 --- a/src/components/ui/data-table-column-filters/select-filter.tsx +++ b/src/components/ui/data-table-column-filters/select-filter.tsx @@ -1,26 +1,35 @@ import type { ColumnFilter } from '@tanstack/react-table'; -import { useTranslations } from 'next-intl'; + +import type { FilterFieldSize, FilterFieldVariant } from '@/components/filters/filter-field-group'; +import { EnumFilterWithOperator } from '@/components/filters/enum-filter-with-operator'; interface SelectFilterProps { filterState?: ColumnFilter; - onFilterChange: (value: string) => void; + onFilterChange: (value: unknown) => void; options: { label: string; value: string }[]; + allowEmpty?: boolean; + variant?: FilterFieldVariant; + size?: FilterFieldSize; } -export function SelectFilter({ filterState, options, onFilterChange }: SelectFilterProps) { - const t = useTranslations('common.ui'); +export function SelectFilter({ + filterState, + options, + onFilterChange, + allowEmpty = false, + variant = 'row', + size = 'default', +}: SelectFilterProps) { return ( - + ); } diff --git a/src/components/ui/data-table-column-filters/text-filter.tsx b/src/components/ui/data-table-column-filters/text-filter.tsx index 897a5e28..0d747d9c 100644 --- a/src/components/ui/data-table-column-filters/text-filter.tsx +++ b/src/components/ui/data-table-column-filters/text-filter.tsx @@ -1,21 +1,36 @@ import type { ColumnFilter } from '@tanstack/react-table'; -import { Input } from '@/components/ui/input'; +import type { FilterFieldSize, FilterFieldVariant } from '@/components/filters/filter-field-group'; +import { TextFilterWithOperator } from '@/components/filters/text-filter-with-operator'; interface TextFilterProps { filterState?: ColumnFilter; - onFilterChange: (value: string) => void; + onFilterChange: (value: unknown) => void; label?: string; columnId: string; + allowEmpty?: boolean; + variant?: FilterFieldVariant; + size?: FilterFieldSize; } -export function TextFilter({ filterState, label, columnId, onFilterChange }: TextFilterProps) { +export function TextFilter({ + filterState, + label, + columnId, + onFilterChange, + allowEmpty = false, + variant = 'row', + size = 'default', +}: TextFilterProps) { return ( - onFilterChange(event.target.value)} - className="h-8 w-full" /> ); } diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index 9e6b25b2..a6340871 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -16,6 +16,11 @@ import { MoreHorizontal } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type SetTableUrlState, type TableUrlState, useTableUrlState } from '@/lib/hooks/use-table-url-state'; +import { + matchesDateFilter, + matchesNumberRangeFilter, + matchesTextFilter, +} from '@/lib/entity-filters/filter-matchers'; import { cn } from '@/lib/utils'; import { Checkbox } from './checkbox'; @@ -65,75 +70,18 @@ declare module '@tanstack/react-table' { // Define the custom filter function for compound text fields export const compoundTextFilter: FilterFn = (row, columnId, filterValue) => { - const value = row.getValue(columnId); - if (!value) return false; - - // Convert both the value and filter to lowercase for case-insensitive search - const searchValue = String(value).toLowerCase(); - const searchFilter = String(filterValue).toLowerCase(); - - return searchValue.includes(searchFilter); + return matchesTextFilter(row.getValue(columnId), filterValue); }; // Define the custom number range filter function for number filtering export const inNumberRangeFilter: FilterFn = (row, columnId, filterValue) => { - const value = row.getValue(columnId); - if (value === null || value === undefined) return false; - - // If no filter is applied, show all rows - if (!filterValue || (!filterValue[0] && !filterValue[1])) return true; - - // Convert the row value to a number - const rowValue = Number(value); - - let result = true; - // Check if the number is within the range - if (filterValue[0] !== null && filterValue[1] !== null) { - result = rowValue >= filterValue[0] && rowValue <= filterValue[1]; - } else if (filterValue[0] !== null) { - // Only min value is set - result = rowValue >= filterValue[0]; - } else if (filterValue[1] !== null) { - // Only max value is set - result = rowValue <= filterValue[1]; - } - - return result; + return matchesNumberRangeFilter(row.getValue(columnId), filterValue); }; // Define the custom date filter function for date range filtering export const dateRangeFilter: FilterFn = (row, columnId, filterValue) => { const value = row.getValue(columnId); - if (!value || typeof value !== 'string') return false; - - // If no filter is applied, show all rows - if (!filterValue || (!filterValue[0] && !filterValue[1])) return true; - - // Convert the row value to a Date object - const rowDate = new Date(value); - - let result = true; - - // Check if the date is within the range - if (filterValue[0] && filterValue[1]) { - const startDate = new Date(filterValue[0]); - const endDate = new Date(filterValue[1]); - // Set end date to end of day to include the entire day - endDate.setUTCHours(23, 59, 59, 999); - result = rowDate >= startDate && rowDate <= endDate; - } else if (filterValue[0]) { - // Only start date is set - const startDate = new Date(filterValue[0]); - result = rowDate >= startDate; - } else if (filterValue[1]) { - // Only end date is set - const endDate = new Date(filterValue[1]); - // Set end date to end of day to include the entire day - endDate.setUTCHours(23, 59, 59, 999); - result = rowDate <= endDate; - } - - return result; + return matchesDateFilter(value, filterValue, new Date()); }; export type DataTableColumnFilters = { @@ -141,6 +89,7 @@ export type DataTableColumnFilters = { type: 'text' | 'select' | 'multi-select' | 'number' | 'date' | 'boolean'; options?: { label: string; value: string }[]; label?: string; + allowEmpty?: boolean; }; }; diff --git a/src/lib/entity-filters/filter-definitions.ts b/src/lib/entity-filters/filter-definitions.ts index 8fc2f314..d297b4e6 100644 --- a/src/lib/entity-filters/filter-definitions.ts +++ b/src/lib/entity-filters/filter-definitions.ts @@ -11,7 +11,7 @@ import { import type { DataTableColumnFilters } from '@/components/ui/data-table'; import { createAdditionalFieldFilters } from '@/lib/table-column-utils'; -import type { EntityFilterFieldOption, EntityFilterEntity } from '@/types/entity-filters'; +import type { EntityFilterEntity, EntityFilterFieldOption } from '@/types/entity-filters'; import type { ProjectWithConfiguration } from '@/types/projects'; export type DataTableColumnFilterType = 'text' | 'select' | 'multi-select' | 'number' | 'date' | 'boolean'; @@ -20,6 +20,7 @@ export type DataTableColumnFilterDefinition = { type: DataTableColumnFilterType; label: string; options?: { label: string; value: string }[]; + allowEmpty?: boolean; }; const LOAN_STATUS_OPTIONS = [ @@ -53,12 +54,16 @@ export function buildLoanColumnFiltersMap( ): Record { return { loanNumber: { type: 'number', label: t('table.loanNumber') }, - signDate: { type: 'date', label: t('table.signDate') }, + signDate: { type: 'date', label: t('table.signDate'), allowEmpty: false }, 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') }, + outstandingDepositSinceDate: { + type: 'date', + label: t('table.outstandingDepositSinceDate'), + allowEmpty: true, + }, outstandingDepositSinceDays: { type: 'number', label: t('table.outstandingDepositSinceDays') }, depositsCount: { type: 'number', label: t('table.depositsCount') }, requiredDepositsCount: { type: 'number', label: t('table.requiredDepositsCount') }, @@ -79,10 +84,11 @@ export function buildLoanColumnFiltersMap( terminationModalities: { type: 'text', label: t('table.terminationModalities'), + allowEmpty: true, }, - repayDate: { type: 'date', label: t('table.repayDate') }, - loanTermDays: { type: 'number', label: t('table.loanTerm') }, - repaymentPeriodDays: { type: 'number', label: t('table.repaymentPeriod') }, + repayDate: { type: 'date', label: t('table.repayDate'), allowEmpty: true }, + loanTermDays: { type: 'number', label: t('table.loanTerm'), allowEmpty: true }, + repaymentPeriodDays: { type: 'number', label: t('table.repaymentPeriod'), allowEmpty: true }, status: { type: 'select', label: t('table.status'), @@ -94,6 +100,7 @@ export function buildLoanColumnFiltersMap( altInterestMethod: { type: 'select', label: t('table.altInterestMethod'), + allowEmpty: true, options: Object.entries(InterestMethod).map(([key, value]) => ({ label: commonT(`enums.interestMethod.${key}`), value, @@ -138,27 +145,27 @@ export function buildLenderProfileColumnFiltersMap( { label: commonT('enums.lender.type.ORGANISATION'), value: 'ORGANISATION' }, ], }, - name: { type: 'text', label: t('table.name') }, - firstName: { type: 'text', label: t('table.firstName') }, - lastName: { type: 'text', label: t('table.lastName') }, - organisationName: { type: 'text', label: t('table.organisationName') }, - titlePrefix: { type: 'text', label: t('table.titlePrefix') }, - titleSuffix: { type: 'text', label: t('table.titleSuffix') }, - email: { type: 'text', label: t('table.email') }, - telNo: { type: 'text', label: t('table.telNo') }, - address: { type: 'text', label: t('table.address') }, - street: { type: 'text', label: t('table.street') }, - addon: { type: 'text', label: t('table.addon') }, - zip: { type: 'text', label: t('table.zip') }, - place: { type: 'text', label: t('table.place') }, + name: { type: 'text', label: t('table.name'), allowEmpty: true }, + firstName: { type: 'text', label: t('table.firstName'), allowEmpty: true }, + lastName: { type: 'text', label: t('table.lastName'), allowEmpty: true }, + organisationName: { type: 'text', label: t('table.organisationName'), allowEmpty: true }, + titlePrefix: { type: 'text', label: t('table.titlePrefix'), allowEmpty: true }, + titleSuffix: { type: 'text', label: t('table.titleSuffix'), allowEmpty: true }, + email: { type: 'text', label: t('table.email'), allowEmpty: true }, + telNo: { type: 'text', label: t('table.telNo'), allowEmpty: true }, + address: { type: 'text', label: t('table.address'), allowEmpty: true }, + street: { type: 'text', label: t('table.street'), allowEmpty: true }, + addon: { type: 'text', label: t('table.addon'), allowEmpty: true }, + zip: { type: 'text', label: t('table.zip'), allowEmpty: true }, + place: { type: 'text', label: t('table.place'), allowEmpty: true }, country: { type: 'select', label: t('table.country'), options: buildCountryFilterOptions(commonT), }, - banking: { type: 'text', label: t('table.banking') }, - iban: { type: 'text', label: t('table.iban') }, - bic: { type: 'text', label: t('table.bic') }, + banking: { type: 'text', label: t('table.banking'), allowEmpty: true }, + iban: { type: 'text', label: t('table.iban'), allowEmpty: true }, + bic: { type: 'text', label: t('table.bic'), allowEmpty: true }, salutation: { type: 'select', label: t('table.salutation'), @@ -234,7 +241,7 @@ export function buildTransactionColumnFiltersMap( value, })), }, - 'transaction.date': { type: 'date', label: t('table.date') }, + 'transaction.date': { type: 'date', label: t('table.date'), allowEmpty: true }, 'transaction.amount': { type: 'number', label: t('table.amount') }, 'transaction.paymentType': { type: 'select', diff --git a/src/lib/entity-filters/filter-matchers.ts b/src/lib/entity-filters/filter-matchers.ts index 64ab6351..e3a07604 100644 --- a/src/lib/entity-filters/filter-matchers.ts +++ b/src/lib/entity-filters/filter-matchers.ts @@ -1,57 +1,137 @@ import type { DataTableColumnFilterType } from '@/lib/entity-filters/filter-definitions'; -import { resolveEntityDateFilterBounds } from '@/lib/entity-filters/resolve-date-filter-range'; +import { resolveDateFilterBounds } from '@/lib/entity-filters/resolve-date-filter-range'; import { parseBooleanFilterValue } from '@/types/boolean-filter-value'; +import { parseDateFilterValue } from '@/types/date-filter-value'; +import { parseEnumFilterValue } from '@/types/enum-filter-value'; +import { parseNumberFilterValue } from '@/types/number-filter-value'; +import { parseTextFilterValue } from '@/types/text-filter-value'; + +function isNullishOrBlank(value: unknown): boolean { + if (value === null || value === undefined) { + return true; + } + if (typeof value === 'string') { + return value.trim() === ''; + } + return false; +} export function matchesTextFilter(value: unknown, filterValue: unknown): boolean { + const parsed = parseTextFilterValue(filterValue); + + if (parsed.operator === 'empty') { + return isNullishOrBlank(value); + } + if (parsed.operator === 'notEmpty') { + return !isNullishOrBlank(value); + } + + if (parsed.value.trim() === '') { + return true; + } + if (value === null || value === undefined) { return false; } + const searchValue = String(value).toLowerCase(); - const searchFilter = String(filterValue).toLowerCase(); - return searchValue.includes(searchFilter); + const searchFilter = parsed.value.toLowerCase(); + + switch (parsed.operator) { + case 'contains': + return searchValue.includes(searchFilter); + case 'startsWith': + return searchValue.startsWith(searchFilter); + case 'endsWith': + return searchValue.endsWith(searchFilter); + case 'eq': + return searchValue === searchFilter; + } } export function matchesNumberRangeFilter(value: unknown, filterValue: unknown): boolean { - if (value === null || value === undefined) { - return false; + const parsed = parseNumberFilterValue(filterValue); + + if (parsed.operator === 'empty') { + return value === null || value === undefined; } - if (!filterValue || (!Array.isArray(filterValue) && typeof filterValue !== 'object')) { - return true; + if (parsed.operator === 'notEmpty') { + return value !== null && value !== undefined; } - const range = filterValue as [number | null, number | null]; - if (range[0] == null && range[1] == null) { + + if ( + (parsed.operator === 'eq' || + parsed.operator === 'gt' || + parsed.operator === 'lt' || + parsed.operator === 'gte' || + parsed.operator === 'lte') && + parsed.value == null + ) { return true; } + + if (value === null || value === undefined) { + return false; + } + const rowValue = Number(value); if (Number.isNaN(rowValue)) { return false; } - if (range[0] !== null && range[1] !== null) { - return rowValue >= range[0] && rowValue <= range[1]; - } - if (range[0] !== null) { - return rowValue >= range[0]; - } - if (range[1] !== null) { - return rowValue <= range[1]; + + switch (parsed.operator) { + case 'between': { + if (parsed.min == null && parsed.max == null) { + return true; + } + if (parsed.min !== null && parsed.max !== null) { + return rowValue >= parsed.min && rowValue <= parsed.max; + } + if (parsed.min !== null) { + return rowValue >= parsed.min; + } + if (parsed.max !== null) { + return rowValue <= parsed.max; + } + return true; + } + case 'eq': + return rowValue === parsed.value; + case 'gt': + return rowValue > (parsed.value as number); + case 'lt': + return rowValue < (parsed.value as number); + case 'gte': + return rowValue >= (parsed.value as number); + case 'lte': + return rowValue <= (parsed.value as number); } - return true; } -export function matchesDateRangeFilter( +export function matchesDateFilter( value: unknown, filterValue: unknown, referenceDate: Date = new Date(), ): boolean { + const parsed = parseDateFilterValue(filterValue); + + if (parsed.operator === 'empty') { + return value === null || value === undefined; + } + if (parsed.operator === 'notEmpty') { + return value !== null && value !== undefined; + } + if (!value) { return false; } + const dateValue = value instanceof Date ? value : new Date(String(value)); if (Number.isNaN(dateValue.getTime())) { return false; } - const bounds = resolveEntityDateFilterBounds(filterValue, referenceDate); + const bounds = resolveDateFilterBounds(filterValue, referenceDate); if (!bounds) { return true; } @@ -69,18 +149,39 @@ export function matchesDateRangeFilter( return true; } -export function matchesSelectFilter(value: unknown, filterValue: unknown): boolean { - if (filterValue === '' || filterValue == null) { +export function matchesEnumFilter( + value: unknown, + filterValue: unknown, + defaultOperator: 'eq' | 'in' = 'eq', +): boolean { + const parsed = parseEnumFilterValue(filterValue, defaultOperator); + + if (parsed.operator === 'empty') { + return isNullishOrBlank(value); + } + if (parsed.operator === 'notEmpty') { + return !isNullishOrBlank(value); + } + + if (parsed.operator === 'eq') { + if (parsed.value === '') { + return true; + } + return String(value) === parsed.value; + } + + if (parsed.values.length === 0) { return true; } - return String(value) === String(filterValue); + return parsed.values.includes(String(value)); +} + +export function matchesSelectFilter(value: unknown, filterValue: unknown): boolean { + return matchesEnumFilter(value, filterValue, 'eq'); } export function matchesMultiSelectFilter(value: unknown, filterValue: unknown): boolean { - if (!Array.isArray(filterValue) || filterValue.length === 0) { - return true; - } - return filterValue.includes(String(value)); + return matchesEnumFilter(value, filterValue, 'in'); } export function matchesBooleanFilter(value: unknown, filterValue: unknown): boolean { @@ -106,7 +207,7 @@ export function matchesFilterByType( case 'number': return matchesNumberRangeFilter(value, filterValue); case 'date': - return matchesDateRangeFilter(value, filterValue, options?.referenceDate); + return matchesDateFilter(value, filterValue, options?.referenceDate); case 'select': return matchesSelectFilter(value, filterValue); case 'multi-select': diff --git a/src/lib/entity-filters/resolve-date-filter-range.ts b/src/lib/entity-filters/resolve-date-filter-range.ts index b596a69e..9bcb74eb 100644 --- a/src/lib/entity-filters/resolve-date-filter-range.ts +++ b/src/lib/entity-filters/resolve-date-filter-range.ts @@ -1,6 +1,6 @@ import moment from 'moment'; -import { parseEntityDateFilterValue } from '@/types/entity-date-filter'; +import { parseDateFilterValue } from '@/types/date-filter-value'; function endOfDay(date: Date): Date { const end = new Date(date); @@ -8,7 +8,7 @@ function endOfDay(date: Date): Date { return end; } -export function resolveEntityDateFilterBounds( +export function resolveDateFilterBounds( filterValue: unknown, referenceDate: Date, ): { start: Date | null; end: Date | null } | null { @@ -16,20 +16,73 @@ export function resolveEntityDateFilterBounds( return null; } - const parsed = parseEntityDateFilterValue(filterValue); + const parsed = parseDateFilterValue(filterValue); - if (parsed.mode === 'relative') { - const end = moment(referenceDate).endOf('day'); - const start = moment(referenceDate).subtract(parsed.amount, parsed.unit).startOf('day'); - return { start: start.toDate(), end: end.toDate() }; + switch (parsed.operator) { + case 'empty': + case 'notEmpty': + return null; + case 'between': { + if (!parsed.start && !parsed.end) { + return null; + } + return { + start: parsed.start ? new Date(parsed.start) : null, + end: parsed.end ? endOfDay(new Date(parsed.end)) : null, + }; + } + case 'last': { + const pointA = moment(referenceDate).subtract(parsed.amount, parsed.unit); + const pointB = moment(referenceDate); + const start = moment.min(pointA, pointB).startOf('day'); + const end = moment.max(pointA, pointB).endOf('day'); + return { start: start.toDate(), end: end.toDate() }; + } + case 'next': { + const pointA = moment(referenceDate); + const pointB = moment(referenceDate).add(parsed.amount, parsed.unit); + const start = moment.min(pointA, pointB).startOf('day'); + const end = moment.max(pointA, pointB).endOf('day'); + return { start: start.toDate(), end: end.toDate() }; + } + case 'olderThan': { + const end = moment(referenceDate).subtract(parsed.amount, parsed.unit).endOf('day'); + return { start: null, end: end.toDate() }; + } + case 'newerThan': { + const start = moment(referenceDate).subtract(parsed.amount, parsed.unit).startOf('day'); + return { start: start.toDate(), end: null }; + } + case 'thisMonth': { + const start = moment(referenceDate).startOf('month').startOf('day'); + const endOfMonth = moment(referenceDate).endOf('month').endOf('day'); + const end = moment.min(endOfMonth, moment(referenceDate).endOf('day')); + return { start: start.toDate(), end: end.toDate() }; + } + case 'lastMonth': { + const month = moment(referenceDate).subtract(1, 'month'); + return { + start: month.startOf('month').startOf('day').toDate(), + end: month.endOf('month').endOf('day').toDate(), + }; + } + case 'thisYear': { + const start = moment(referenceDate).startOf('year').startOf('day'); + const endOfYear = moment(referenceDate).endOf('year').endOf('day'); + const end = moment.min(endOfYear, moment(referenceDate).endOf('day')); + return { start: start.toDate(), end: end.toDate() }; + } + case 'lastYear': { + const year = moment(referenceDate).subtract(1, 'year'); + return { + start: year.startOf('year').startOf('day').toDate(), + end: year.endOf('year').endOf('day').toDate(), + }; + } + case 'year': { + const start = moment().year(parsed.year).startOf('year').startOf('day'); + const end = moment().year(parsed.year).endOf('year').endOf('day'); + return { start: start.toDate(), end: end.toDate() }; + } } - - if (!parsed.start && !parsed.end) { - return null; - } - - return { - start: parsed.start ? new Date(parsed.start) : null, - end: parsed.end ? endOfDay(new Date(parsed.end)) : null, - }; } diff --git a/src/lib/table-column-utils.tsx b/src/lib/table-column-utils.tsx index 664de393..268c10c1 100644 --- a/src/lib/table-column-utils.tsx +++ b/src/lib/table-column-utils.tsx @@ -4,7 +4,11 @@ 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 { + matchesBooleanFilter, + matchesEnumFilter, + matchesTextFilter, +} from '@/lib/entity-filters/filter-matchers'; import { formatDurationDays } from '@/lib/format-duration'; import { formatCurrency, formatPercentage, getLenderName, NumberParser, resolveIntlLocaleForDates } from '@/lib/utils'; @@ -12,14 +16,7 @@ import { type AdditionalFieldConfig, AdditionalFieldType, AdditionalNumberFormat // Define the custom filter function for compound text fields export function compoundTextFilter(row: Row, columnId: string, filterValue: unknown) { - const value = row.getValue(columnId); - if (!value) return false; - - // Convert both the value and filter to lowercase for case-insensitive search - const searchValue = String(value).toLowerCase(); - const searchFilter = String(filterValue).toLowerCase(); - - return searchValue.includes(searchFilter); + return matchesTextFilter(row.getValue(columnId), filterValue); } // Define the custom filter function for boolean fields @@ -30,16 +27,7 @@ export function booleanFilter(row: Row, columnId: string, filterValue: unk // Define the custom filter function for enum fields export function enumFilter(row: Row, columnId: string, filterValue: unknown) { - const value = row.getValue(columnId); - - // Support single- and multi-select enum filters. - if (Array.isArray(filterValue)) { - if (filterValue.length === 0) return true; - return filterValue.includes(String(value)); - } - - // For enum fields, we do an exact match - return value === filterValue || filterValue === ''; + return matchesEnumFilter(row.getValue(columnId), filterValue, 'eq'); } // Define the custom filter function type @@ -85,7 +73,7 @@ export function createColumn(config: ColumnConfig, t: (key: string) => str ...config, header: ({ column }) => config.header ? : undefined, - filterFn: config.filterFn || 'includesString', + filterFn: config.filterFn || compoundTextFilter, sortingFn: config.sortingFn || ((rowA, rowB, columnId) => { @@ -845,12 +833,14 @@ export function createAdditionalFieldFilters( filters[`${accessorKey}.${field.id}`] = { type: 'text' as const, label: field.name, + allowEmpty: true, }; } if (field.type === AdditionalFieldType.SELECT) { filters[`${accessorKey}.${field.id}`] = { type: 'select' as const, label: field.name, + allowEmpty: true, options: field.selectOptions.map((option) => ({ label: option, value: option })), }; } @@ -858,12 +848,14 @@ export function createAdditionalFieldFilters( filters[`${accessorKey}.${field.id}`] = { type: 'date' as const, label: field.name, + allowEmpty: true, }; } if (field.type === AdditionalFieldType.NUMBER) { filters[`${accessorKey}.${field.id}`] = { type: 'number' as const, label: field.name, + allowEmpty: true, }; } if (field.type === AdditionalFieldType.BOOLEAN) { diff --git a/src/messages/de/dashboard.json b/src/messages/de/dashboard.json index 74dd0c1a..60fb5f1d 100644 --- a/src/messages/de/dashboard.json +++ b/src/messages/de/dashboard.json @@ -135,13 +135,53 @@ "aggregationDelta": "Veränderung im Zeitraum", "aggregationCumulative": "Aufsummiert", "filters": "Filter", - "dateFilterMode": "Zeitraum", - "dateFilterModeFixed": "Festes Datum", - "dateFilterModeRelative": "Relativ", + "dateFilterOperator": "Zeitraum", + "dateFilterOperators": { + "between": "Von Bis", + "olderThan": "Älter als", + "newerThan": "Neuer als", + "thisMonth": "Diesen Monat", + "lastMonth": "Letzten Monat", + "thisYear": "Dieses Jahr", + "lastYear": "Letztes Jahr", + "last": "Letzte…", + "next": "Nächste…", + "year": "Jahr", + "empty": "Ist leer", + "notEmpty": "Ist nicht leer" + }, + "numberFilterOperators": { + "between": "Von Bis", + "eq": "=", + "gt": ">", + "lt": "<", + "gte": "≥", + "lte": "≤", + "empty": "Ist leer", + "notEmpty": "Ist nicht leer" + }, + "numberFilterMin": "Min", + "numberFilterMax": "Max", + "numberFilterValue": "Wert", + "textFilterOperators": { + "contains": "Enthält", + "startsWith": "Beginnt mit", + "endsWith": "Endet mit", + "eq": "=", + "empty": "Ist leer", + "notEmpty": "Ist nicht leer" + }, + "textFilterValue": "Text", + "enumFilterOperators": { + "eq": "Auswahl", + "in": "Mehrfachauswahl", + "empty": "Ist leer", + "notEmpty": "Ist nicht leer" + }, + "enumFilterSelectedCount": "{count} ausgewählt", "dateFilterRelative": "Letzte", "dateFilterStart": "Von", "dateFilterEnd": "Bis", - "dateFilterTo": "bis", "addFilter": "Filter hinzufügen", "filterField": "Feld", "filterGroupLoan": "Kredit", diff --git a/src/messages/de/dataTable.json b/src/messages/de/dataTable.json index 3dd6b506..63ce533a 100644 --- a/src/messages/de/dataTable.json +++ b/src/messages/de/dataTable.json @@ -21,9 +21,54 @@ "rowsPerPage": "Zeilen pro Seite", "noResults": "Keine Ergebnisse.", "to": "bis", + "dateFilterOperator": "Zeitraum", + "dateFilterOperators": { + "between": "Von Bis", + "olderThan": "Älter als", + "newerThan": "Neuer als", + "thisMonth": "Diesen Monat", + "lastMonth": "Letzten Monat", + "thisYear": "Dieses Jahr", + "lastYear": "Letztes Jahr", + "last": "Letzte…", + "next": "Nächste…", + "year": "Jahr", + "empty": "Ist leer", + "notEmpty": "Ist nicht leer" + }, + "numberFilterOperators": { + "between": "Von Bis", + "eq": "=", + "gt": ">", + "lt": "<", + "gte": "≥", + "lte": "≤", + "empty": "Ist leer", + "notEmpty": "Ist nicht leer" + }, + "numberFilterMin": "Min", + "numberFilterMax": "Max", + "numberFilterValue": "Wert", + "textFilterOperators": { + "contains": "Enthält", + "startsWith": "Beginnt mit", + "endsWith": "Endet mit", + "eq": "=", + "empty": "Ist leer", + "notEmpty": "Ist nicht leer" + }, + "textFilterValue": "Text", + "enumFilterOperators": { + "eq": "Auswahl", + "in": "Mehrfachauswahl", + "empty": "Ist leer", + "notEmpty": "Ist nicht leer" + }, + "enumFilterSelectedCount": "{count} ausgewählt", + "dateFilterRelative": "Letzte", + "dateFilterStart": "Startdatum", + "dateFilterEnd": "Enddatum", "globalFilter": "In allen Spalten suchen...", - "startDate": "Startdatum", - "endDate": "Enddatum", "bulkBar": { "selected": "{count} ausgewählt", "deselect": "Auswahl aufheben" diff --git a/src/types/date-filter-value.ts b/src/types/date-filter-value.ts new file mode 100644 index 00000000..64b87e2d --- /dev/null +++ b/src/types/date-filter-value.ts @@ -0,0 +1,178 @@ +import type { FilterOperatorValue } from '@/types/filter-operators'; + +export const DATE_FILTER_OPERATORS = [ + 'between', + 'olderThan', + 'newerThan', + 'thisMonth', + 'lastMonth', + 'thisYear', + 'lastYear', + 'last', + 'next', +] as const; + +export const DATE_FILTER_LEGACY_OPERATORS = ['year'] as const; +export const DATE_FILTER_EMPTY_OPERATORS = ['empty', 'notEmpty'] as const; + +export type DateFilterOperator = (typeof DATE_FILTER_OPERATORS)[number]; +export type DateFilterLegacyOperator = (typeof DATE_FILTER_LEGACY_OPERATORS)[number]; +export type DateFilterEmptyOperator = (typeof DATE_FILTER_EMPTY_OPERATORS)[number]; +export type DateFilterOperatorWithLegacy = + | DateFilterOperator + | DateFilterLegacyOperator + | DateFilterEmptyOperator; + +export const DATE_FILTER_LAST_UNITS = ['days', 'months'] as const; + +export type DateFilterLastUnit = (typeof DATE_FILTER_LAST_UNITS)[number]; + +export type DateFilterRelativeAmountValue = { + amount: number; + unit: DateFilterLastUnit; +}; + +export type DateFilterValue = + | FilterOperatorValue<'between', { start: string | null; end: string | null }> + | FilterOperatorValue<'last', DateFilterRelativeAmountValue> + | FilterOperatorValue<'next', DateFilterRelativeAmountValue> + | FilterOperatorValue<'olderThan', DateFilterRelativeAmountValue> + | FilterOperatorValue<'newerThan', DateFilterRelativeAmountValue> + | FilterOperatorValue<'thisMonth'> + | FilterOperatorValue<'lastMonth'> + | FilterOperatorValue<'thisYear'> + | FilterOperatorValue<'lastYear'> + | FilterOperatorValue<'year', { year: number }> + | FilterOperatorValue<'empty'> + | FilterOperatorValue<'notEmpty'>; + +export function createDefaultDateFilterValue(): DateFilterValue { + return { + operator: 'between', + start: null, + end: null, + }; +} + +export function createDefaultDateFilterValueForOperator( + operator: DateFilterOperatorWithLegacy, + referenceDate: Date = new Date(), +): DateFilterValue { + switch (operator) { + case 'between': + return createDefaultDateFilterValue(); + case 'olderThan': + return { operator: 'olderThan', amount: 30, unit: 'days' }; + case 'newerThan': + return { operator: 'newerThan', amount: 30, unit: 'days' }; + case 'thisMonth': + return { operator: 'thisMonth' }; + case 'lastMonth': + return { operator: 'lastMonth' }; + case 'thisYear': + return { operator: 'thisYear' }; + case 'lastYear': + return { operator: 'lastYear' }; + case 'last': + return { operator: 'last', amount: 12, unit: 'months' }; + case 'next': + return { operator: 'next', amount: 12, unit: 'months' }; + case 'year': + return { operator: 'year', year: referenceDate.getFullYear() }; + case 'empty': + return { operator: 'empty' }; + case 'notEmpty': + return { operator: 'notEmpty' }; + } +} + +function isDateFilterLastUnit(value: unknown): value is DateFilterLastUnit { + return typeof value === 'string' && (DATE_FILTER_LAST_UNITS as readonly string[]).includes(value); +} + +function parseRelativeAmount(value: unknown, fallback: number): number { + const parsedAmount = Number(value); + return Number.isFinite(parsedAmount) ? Math.round(parsedAmount) : fallback; +} + +function parseRelativeAmountFilter( + value: unknown, + operator: 'last' | 'next' | 'olderThan' | 'newerThan', + defaults: DateFilterRelativeAmountValue, +): FilterOperatorValue { + const { amount, unit } = value as { amount?: number; unit?: DateFilterLastUnit }; + return { + operator, + amount: parseRelativeAmount(amount, defaults.amount), + unit: isDateFilterLastUnit(unit) ? unit : defaults.unit, + }; +} + +function isDateFilterOperator(value: unknown): value is DateFilterOperatorWithLegacy { + return ( + typeof value === 'string' && + ([ + ...DATE_FILTER_OPERATORS, + ...DATE_FILTER_LEGACY_OPERATORS, + ...DATE_FILTER_EMPTY_OPERATORS, + ] as readonly string[]).includes(value) + ); +} + +export function parseDateFilterValue(raw: unknown): DateFilterValue { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return createDefaultDateFilterValue(); + } + + const value = raw as { operator?: DateFilterOperatorWithLegacy }; + if (!isDateFilterOperator(value.operator)) { + return createDefaultDateFilterValue(); + } + + switch (value.operator) { + case 'between': { + const { start, end } = value as { start?: string | null; end?: string | null }; + return { + operator: 'between', + start: typeof start === 'string' ? start : null, + end: typeof end === 'string' ? end : null, + }; + } + case 'last': + return parseRelativeAmountFilter(value, 'last', { amount: 12, unit: 'months' }); + case 'next': + return parseRelativeAmountFilter(value, 'next', { amount: 12, unit: 'months' }); + case 'olderThan': + return parseRelativeAmountFilter(value, 'olderThan', { amount: 30, unit: 'days' }); + case 'newerThan': + return parseRelativeAmountFilter(value, 'newerThan', { amount: 30, unit: 'days' }); + case 'thisMonth': + return { operator: 'thisMonth' }; + case 'lastMonth': + return { operator: 'lastMonth' }; + case 'thisYear': + return { operator: 'thisYear' }; + case 'lastYear': + return { operator: 'lastYear' }; + case 'year': { + const { year } = value as { year?: number }; + const parsedYear = Number(year); + return { + operator: 'year', + year: Number.isFinite(parsedYear) ? Math.round(parsedYear) : new Date().getFullYear(), + }; + } + case 'empty': + return { operator: 'empty' }; + case 'notEmpty': + return { operator: 'notEmpty' }; + } +} + +export function isInactiveDateFilterValue(raw: unknown): boolean { + const parsed = parseDateFilterValue(raw); + if (parsed.operator === 'empty' || parsed.operator === 'notEmpty') { + return false; + } + return parsed.operator === 'between' && !parsed.start && !parsed.end; +} diff --git a/src/types/entity-date-filter.ts b/src/types/entity-date-filter.ts deleted file mode 100644 index 9d2ff60f..00000000 --- a/src/types/entity-date-filter.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { StatDeltaUnit } from '@/types/dashboard-widgets/stat-widget'; -import { STAT_DELTA_UNITS } from '@/types/dashboard-widgets/stat-widget'; - -export const ENTITY_DATE_FILTER_MODES = ['fixed', 'relative'] as const; - -export type EntityDateFilterMode = (typeof ENTITY_DATE_FILTER_MODES)[number]; - -export type EntityDateFilterValue = - | { - mode: 'fixed'; - start: string | null; - end: string | null; - } - | { - mode: 'relative'; - amount: number; - unit: StatDeltaUnit; - }; - -export function createDefaultEntityDateFilterValue(): EntityDateFilterValue { - return { - mode: 'fixed', - start: null, - end: null, - }; -} - -export function createDefaultRelativeEntityDateFilterValue(): EntityDateFilterValue { - return { - mode: 'relative', - amount: 12, - unit: 'months', - }; -} - -function isStatDeltaUnit(value: unknown): value is StatDeltaUnit { - return typeof value === 'string' && (STAT_DELTA_UNITS as readonly string[]).includes(value); -} - -export function parseEntityDateFilterValue(raw: unknown): EntityDateFilterValue { - if (Array.isArray(raw)) { - const [start, end] = raw as [string | null | undefined, string | null | undefined]; - return { - mode: 'fixed', - start: start ?? null, - end: end ?? null, - }; - } - - if (!raw || typeof raw !== 'object') { - return createDefaultEntityDateFilterValue(); - } - - const value = raw as { - mode?: EntityDateFilterMode; - start?: string | null; - end?: string | null; - amount?: number; - unit?: StatDeltaUnit; - }; - if (value.mode === 'relative') { - const amount = Number(value.amount); - return { - mode: 'relative', - amount: Number.isFinite(amount) && amount > 0 ? Math.round(amount) : 12, - unit: isStatDeltaUnit(value.unit) ? value.unit : 'months', - }; - } - - return { - mode: 'fixed', - start: typeof value.start === 'string' ? value.start : null, - end: typeof value.end === 'string' ? value.end : null, - }; -} diff --git a/src/types/enum-filter-value.ts b/src/types/enum-filter-value.ts new file mode 100644 index 00000000..3c30e71d --- /dev/null +++ b/src/types/enum-filter-value.ts @@ -0,0 +1,113 @@ +import type { FilterOperatorValue } from '@/types/filter-operators'; + +export const ENUM_FILTER_OPERATORS = ['eq', 'in'] as const; + +export const ENUM_FILTER_EMPTY_OPERATORS = ['empty', 'notEmpty'] as const; + +export type EnumFilterOperator = (typeof ENUM_FILTER_OPERATORS)[number]; +export type EnumFilterEmptyOperator = (typeof ENUM_FILTER_EMPTY_OPERATORS)[number]; +export type EnumFilterOperatorWithEmpty = EnumFilterOperator | EnumFilterEmptyOperator; + +export type EnumFilterValue = + | FilterOperatorValue<'eq', { value: string }> + | FilterOperatorValue<'in', { values: string[] }> + | FilterOperatorValue<'empty'> + | FilterOperatorValue<'notEmpty'>; + +export function createDefaultEnumFilterValue( + defaultOperator: EnumFilterOperator = 'eq', +): EnumFilterValue { + return createDefaultEnumFilterValueForOperator(defaultOperator); +} + +export function createDefaultEnumFilterValueForOperator( + operator: EnumFilterOperatorWithEmpty, +): EnumFilterValue { + switch (operator) { + case 'eq': + return { operator: 'eq', value: '' }; + case 'in': + return { operator: 'in', values: [] }; + case 'empty': + return { operator: 'empty' }; + case 'notEmpty': + return { operator: 'notEmpty' }; + } +} + +function isEnumFilterOperator(value: unknown): value is EnumFilterOperatorWithEmpty { + return ( + typeof value === 'string' && + ([...ENUM_FILTER_OPERATORS, ...ENUM_FILTER_EMPTY_OPERATORS] as readonly string[]).includes(value) + ); +} + +export function parseEnumFilterValue( + raw: unknown, + defaultOperator: EnumFilterOperator = 'eq', +): EnumFilterValue { + if (typeof raw === 'string') { + return { + operator: 'eq', + value: raw, + }; + } + + if (Array.isArray(raw)) { + return { + operator: 'in', + values: raw.filter((item): item is string => typeof item === 'string'), + }; + } + + if (!raw || typeof raw !== 'object') { + return createDefaultEnumFilterValue(defaultOperator); + } + + const value = raw as { operator?: EnumFilterOperatorWithEmpty }; + if (!isEnumFilterOperator(value.operator)) { + return createDefaultEnumFilterValue(defaultOperator); + } + + switch (value.operator) { + case 'eq': { + const { value: single } = value as { value?: string }; + return { + operator: 'eq', + value: typeof single === 'string' ? single : '', + }; + } + case 'in': { + const { values } = value as { values?: unknown }; + return { + operator: 'in', + values: Array.isArray(values) + ? values.filter((item): item is string => typeof item === 'string') + : [], + }; + } + case 'empty': + return { operator: 'empty' }; + case 'notEmpty': + return { operator: 'notEmpty' }; + } +} + +export function isInactiveEnumFilterValue( + raw: unknown, + defaultOperator: EnumFilterOperator = 'eq', +): boolean { + const parsed = parseEnumFilterValue(raw, defaultOperator); + // Only the field's default operator with an empty payload is inactive. + // Switching to the other operator keeps the filter so the select can stick. + if (parsed.operator === 'empty' || parsed.operator === 'notEmpty') { + return false; + } + if (parsed.operator !== defaultOperator) { + return false; + } + if (parsed.operator === 'eq') { + return parsed.value === ''; + } + return parsed.values.length === 0; +} diff --git a/src/types/filter-operators.ts b/src/types/filter-operators.ts new file mode 100644 index 00000000..4a46b751 --- /dev/null +++ b/src/types/filter-operators.ts @@ -0,0 +1,4 @@ +/** Discriminator all operator-based filter values share (date, text, number, enum, …) */ +export type FilterOperatorValue = { + operator: TOperator; +} & TPayload; diff --git a/src/types/number-filter-value.ts b/src/types/number-filter-value.ts new file mode 100644 index 00000000..333feb22 --- /dev/null +++ b/src/types/number-filter-value.ts @@ -0,0 +1,121 @@ +import type { FilterOperatorValue } from '@/types/filter-operators'; + +export const NUMBER_FILTER_OPERATORS = ['between', 'eq', 'gt', 'lt', 'gte', 'lte'] as const; + +export const NUMBER_FILTER_EMPTY_OPERATORS = ['empty', 'notEmpty'] as const; + +export type NumberFilterOperator = (typeof NUMBER_FILTER_OPERATORS)[number]; +export type NumberFilterEmptyOperator = (typeof NUMBER_FILTER_EMPTY_OPERATORS)[number]; +export type NumberFilterOperatorWithEmpty = NumberFilterOperator | NumberFilterEmptyOperator; + +export type NumberFilterValue = + | FilterOperatorValue<'between', { min: number | null; max: number | null }> + | FilterOperatorValue<'eq', { value: number | null }> + | FilterOperatorValue<'gt', { value: number | null }> + | FilterOperatorValue<'lt', { value: number | null }> + | FilterOperatorValue<'gte', { value: number | null }> + | FilterOperatorValue<'lte', { value: number | null }> + | FilterOperatorValue<'empty'> + | FilterOperatorValue<'notEmpty'>; + +export function createDefaultNumberFilterValue(): NumberFilterValue { + return { + operator: 'between', + min: null, + max: null, + }; +} + +export function createDefaultNumberFilterValueForOperator( + operator: NumberFilterOperatorWithEmpty, +): NumberFilterValue { + switch (operator) { + case 'between': + return createDefaultNumberFilterValue(); + case 'eq': + case 'gt': + case 'lt': + case 'gte': + case 'lte': + return { operator, value: null }; + case 'empty': + return { operator: 'empty' }; + case 'notEmpty': + return { operator: 'notEmpty' }; + } +} + +function isNumberFilterOperator(value: unknown): value is NumberFilterOperatorWithEmpty { + return ( + typeof value === 'string' && + ([...NUMBER_FILTER_OPERATORS, ...NUMBER_FILTER_EMPTY_OPERATORS] as readonly string[]).includes(value) + ); +} + +function parseNullableNumber(value: unknown): number | null { + if (value === null || value === undefined || value === '') { + return null; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseLegacyNumberRange(raw: unknown): NumberFilterValue | null { + if (!Array.isArray(raw) || raw.length < 2) { + return null; + } + return { + operator: 'between', + min: parseNullableNumber(raw[0]), + max: parseNullableNumber(raw[1]), + }; +} + +export function parseNumberFilterValue(raw: unknown): NumberFilterValue { + const legacy = parseLegacyNumberRange(raw); + if (legacy) { + return legacy; + } + + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return createDefaultNumberFilterValue(); + } + + const value = raw as { operator?: NumberFilterOperatorWithEmpty }; + if (!isNumberFilterOperator(value.operator)) { + return createDefaultNumberFilterValue(); + } + + switch (value.operator) { + case 'between': { + const { min, max } = value as { min?: number | null; max?: number | null }; + return { + operator: 'between', + min: parseNullableNumber(min), + max: parseNullableNumber(max), + }; + } + case 'eq': + case 'gt': + case 'lt': + case 'gte': + case 'lte': { + const { value: single } = value as { value?: number | null }; + return { + operator: value.operator, + value: parseNullableNumber(single), + }; + } + case 'empty': + return { operator: 'empty' }; + case 'notEmpty': + return { operator: 'notEmpty' }; + } +} + +export function isInactiveNumberFilterValue(raw: unknown): boolean { + const parsed = parseNumberFilterValue(raw); + // Mirror dates: only the default operator with an empty payload is inactive. + // Other operators keep the filter in state so the operator select can stick. + return parsed.operator === 'between' && parsed.min == null && parsed.max == null; +} diff --git a/src/types/text-filter-value.ts b/src/types/text-filter-value.ts new file mode 100644 index 00000000..68ec1207 --- /dev/null +++ b/src/types/text-filter-value.ts @@ -0,0 +1,88 @@ +import type { FilterOperatorValue } from '@/types/filter-operators'; + +export const TEXT_FILTER_OPERATORS = ['contains', 'startsWith', 'endsWith', 'eq'] as const; + +export const TEXT_FILTER_EMPTY_OPERATORS = ['empty', 'notEmpty'] as const; + +export type TextFilterOperator = (typeof TEXT_FILTER_OPERATORS)[number]; +export type TextFilterEmptyOperator = (typeof TEXT_FILTER_EMPTY_OPERATORS)[number]; +export type TextFilterOperatorWithEmpty = TextFilterOperator | TextFilterEmptyOperator; + +export type TextFilterValue = + | FilterOperatorValue<'contains', { value: string }> + | FilterOperatorValue<'startsWith', { value: string }> + | FilterOperatorValue<'endsWith', { value: string }> + | FilterOperatorValue<'eq', { value: string }> + | FilterOperatorValue<'empty'> + | FilterOperatorValue<'notEmpty'>; + +export function createDefaultTextFilterValue(): TextFilterValue { + return { + operator: 'contains', + value: '', + }; +} + +export function createDefaultTextFilterValueForOperator( + operator: TextFilterOperatorWithEmpty, +): TextFilterValue { + switch (operator) { + case 'contains': + case 'startsWith': + case 'endsWith': + case 'eq': + return { operator, value: '' }; + case 'empty': + return { operator: 'empty' }; + case 'notEmpty': + return { operator: 'notEmpty' }; + } +} + +function isTextFilterOperator(value: unknown): value is TextFilterOperatorWithEmpty { + return ( + typeof value === 'string' && + ([...TEXT_FILTER_OPERATORS, ...TEXT_FILTER_EMPTY_OPERATORS] as readonly string[]).includes(value) + ); +} + +export function parseTextFilterValue(raw: unknown): TextFilterValue { + if (typeof raw === 'string') { + return { + operator: 'contains', + value: raw, + }; + } + + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return createDefaultTextFilterValue(); + } + + const value = raw as { operator?: TextFilterOperatorWithEmpty }; + if (!isTextFilterOperator(value.operator)) { + return createDefaultTextFilterValue(); + } + + switch (value.operator) { + case 'contains': + case 'startsWith': + case 'endsWith': + case 'eq': { + const { value: text } = value as { value?: string }; + return { + operator: value.operator, + value: typeof text === 'string' ? text : '', + }; + } + case 'empty': + return { operator: 'empty' }; + case 'notEmpty': + return { operator: 'notEmpty' }; + } +} + +export function isInactiveTextFilterValue(raw: unknown): boolean { + const parsed = parseTextFilterValue(raw); + // Mirror dates: only the default operator with an empty payload is inactive. + return parsed.operator === 'contains' && parsed.value.trim() === ''; +} From 1c7923f74ce3e3b71c8b0a6991c2affd98375cb3 Mon Sep 17 00:00:00 2001 From: Florian Humer Date: Sat, 15 Aug 2026 14:29:49 +0200 Subject: [PATCH 12/16] feat: compress savings contract info into one info item; do not show payment status for non savings contracts --- .../lenders/loan-accordion-card.tsx | 72 +------------ .../loans/savings-contract-info-item.tsx | 102 ++++++++++++++++++ src/components/ui/info-item.tsx | 4 +- src/messages/de/dashboard.json | 5 +- 4 files changed, 108 insertions(+), 75 deletions(-) create mode 100644 src/components/loans/savings-contract-info-item.tsx diff --git a/src/components/lenders/loan-accordion-card.tsx b/src/components/lenders/loan-accordion-card.tsx index 158ed6f4..0dc77416 100644 --- a/src/components/lenders/loan-accordion-card.tsx +++ b/src/components/lenders/loan-accordion-card.tsx @@ -11,7 +11,6 @@ import { ConfirmDialog } from '@/components/generic/confirm-dialog'; import { TemplateQuickActions } from '@/components/templates/template-quick-actions'; import { InfoItem } from '@/components/ui/info-item'; import { useRouter } from '@/i18n/navigation'; -import { resolveSavingsFirstDepositDate, resolveSavingsLastDepositDate } from '@/lib/loans/savings-contract'; import { formatTerminationModalities } from '@/lib/table-column-utils'; import { cn, formatCurrency, formatDateLong, formatDateShort, formatPercentage } from '@/lib/utils'; import type { LoanDetailsWithCalculations } from '@/types/loans'; @@ -21,6 +20,7 @@ import { LoanAddTransactionControl } from '../loans/loan-add-transaction-control import { LoanBalanceSummary } from '../loans/loan-balance-summary'; import { LoanStatusBadge } from '../loans/loan-status-badge'; import { LoanTransactions } from '../loans/loan-transactions'; +import { SavingsContractInfoItem } from '../loans/savings-contract-info-item'; import { TerminationDialog } from '../loans/termination-dialog'; import { useProject } from '../providers/project-provider'; import { Button } from '../ui/button'; @@ -60,18 +60,6 @@ export function LoanAccordionCard({ loan, defaultOpen = false }: LoanAccordionCa 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')); try { @@ -194,65 +182,9 @@ 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.isSavingsContract && loan.savingsDepositCount != null && ( - <> - - {(savingsFirstDepositDate || savingsLastDepositDate) && ( - - {formatDateLong(savingsFirstDepositDate, locale)} –{' '} - {formatDateLong(savingsLastDepositDate, locale)} - - ) : savingsFirstDepositDate ? ( - formatDateLong(savingsFirstDepositDate, locale) - ) : ( - formatDateLong(savingsLastDepositDate, locale) - ) - } - /> - )} - {loan.requiredDepositsCount > 0 && ( - - )} - - )} {canTerminateLoan && (
{requiredDeposits > 0 && ( -
+
{installments}
Date: Sat, 15 Aug 2026 14:42:41 +0200 Subject: [PATCH 14/16] fix: dont show new columns by default in transactions table --- .../loan-table-column-registry.ts | 2 +- .../transaction-table-column-registry.tsx | 28 ++++--------------- 2 files changed, 6 insertions(+), 24 deletions(-) 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 0990daa6..3eaefee6 100644 --- a/src/lib/dashboard/table-widget/loan-table-column-registry.ts +++ b/src/lib/dashboard/table-widget/loan-table-column-registry.ts @@ -32,7 +32,7 @@ export type LoanTableColumnMeta = { useLendersTranslations?: boolean; }; -const LOAN_TABLE_STATIC_COLUMN_META: { id: string; labelKey: string }[] = [ +export const LOAN_TABLE_STATIC_COLUMN_META: { id: string; labelKey: string }[] = [ { id: 'loanNumber', labelKey: 'table.loanNumber' }, { id: 'signDate', labelKey: 'table.signDate' }, { id: 'amount', labelKey: 'table.amount' }, 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 636af7e3..96831010 100644 --- a/src/lib/dashboard/table-widget/transaction-table-column-registry.tsx +++ b/src/lib/dashboard/table-widget/transaction-table-column-registry.tsx @@ -7,7 +7,11 @@ import { buildLenderProfileColumns, buildLenderProfileDefaultColumnVisibility, } from '@/lib/dashboard/table-widget/lender-profile-columns'; -import { buildLoanTableColumns, getLoanSortValue } from '@/lib/dashboard/table-widget/loan-table-column-registry'; +import { + buildLoanTableColumns, + getLoanSortValue, + LOAN_TABLE_STATIC_COLUMN_META, +} from '@/lib/dashboard/table-widget/loan-table-column-registry'; import { getLenderSortValue } from '@/lib/dashboard/table-widget/lender-table-column-registry'; import { createColumn, @@ -36,28 +40,6 @@ const TRANSACTION_TABLE_STATIC_COLUMN_META: { id: string; labelKey: string }[] = { id: 'transaction.paymentType', labelKey: 'table.paymentType' }, ]; -const LOAN_TABLE_STATIC_COLUMN_META: { id: string; labelKey: string }[] = [ - { id: 'loanNumber', labelKey: 'table.loanNumber' }, - { id: 'signDate', labelKey: 'table.signDate' }, - { id: 'amount', labelKey: 'table.amount' }, - { id: 'balance', labelKey: 'table.balance' }, - { id: 'deposits', labelKey: 'table.deposits' }, - { id: 'withdrawals', labelKey: 'table.withdrawals' }, - { id: 'notReclaimed', labelKey: 'table.notReclaimed' }, - { id: 'interestRate', labelKey: 'table.interestRate' }, - { id: 'interest', labelKey: 'table.interest' }, - { id: 'interestPaid', labelKey: 'table.interestPaid' }, - { id: 'terminationType', labelKey: 'table.terminationType' }, - { id: 'terminationModalities', labelKey: 'table.terminationModalities' }, - { id: 'repayDate', labelKey: 'table.repayDate' }, - { id: 'loanTermDays', labelKey: 'table.loanTerm' }, - { id: 'repaymentPeriodDays', labelKey: 'table.repaymentPeriod' }, - { 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 = [ 'lender.lenderNumber', 'lender.name', From e46cc0b3f37e097aae88872b90b052498f99e377 Mon Sep 17 00:00:00 2001 From: Florian Humer Date: Sat, 15 Aug 2026 14:56:38 +0200 Subject: [PATCH 15/16] fix: small fixes to transaction table --- src/components/loans/loan-balance-summary.tsx | 30 +++++++++++++++++++ .../transactions/transaction-table.tsx | 2 ++ src/components/ui/data-table-header.tsx | 14 +++++---- src/components/ui/data-table.tsx | 4 +++ .../transaction-table-column-registry.tsx | 23 ++++++++++++-- .../hooks/use-transaction-table-url-state.ts | 6 ++-- 6 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/components/loans/loan-balance-summary.tsx b/src/components/loans/loan-balance-summary.tsx index d29e086e..07a15775 100644 --- a/src/components/loans/loan-balance-summary.tsx +++ b/src/components/loans/loan-balance-summary.tsx @@ -20,6 +20,8 @@ export function transactionIcon(type: Transaction['type']) { case 'WITHDRAWAL': case 'INTERESTPAYMENT': case 'TERMINATION': + case 'NOTRECLAIMED': + case 'NOTRECLAIMEDPARTIAL': return ; default: return ; @@ -34,12 +36,40 @@ export function transactionIconBackground(type: Transaction['type']) { case 'WITHDRAWAL': case 'INTERESTPAYMENT': case 'TERMINATION': + case 'NOTRECLAIMED': + case 'NOTRECLAIMEDPARTIAL': return 'bg-info/20'; default: return 'bg-muted'; } } +export function isIncomingTransaction(type: Transaction['type']) { + return type === 'DEPOSIT' || type === 'INTEREST'; +} + +export function isOutgoingTransaction(type: Transaction['type']) { + return ( + type === 'WITHDRAWAL' || + type === 'INTERESTPAYMENT' || + type === 'TERMINATION' || + type === 'NOTRECLAIMED' || + type === 'NOTRECLAIMEDPARTIAL' + ); +} + +export function transactionAmountClassName(type: Transaction['type']) { + if (isIncomingTransaction(type)) return 'text-success-foreground'; + if (isOutgoingTransaction(type)) return 'text-info-foreground'; + return undefined; +} + +export function transactionTypeBadgeClassName(type: Transaction['type']) { + if (isIncomingTransaction(type)) return 'border-success/30 bg-success/20 text-success-foreground'; + if (isOutgoingTransaction(type)) return 'border-info/30 bg-info/20 text-info-foreground'; + return undefined; +} + export type LoanBalanceSummaryProps = { loan: LoanDetailsWithCalculations; readOnly: boolean; diff --git a/src/components/transactions/transaction-table.tsx b/src/components/transactions/transaction-table.tsx index db6e04fd..545ff979 100644 --- a/src/components/transactions/transaction-table.tsx +++ b/src/components/transactions/transaction-table.tsx @@ -23,6 +23,7 @@ import { import { buildTransactionTableColumnFilters } from '@/lib/entity-filters/filter-definitions'; import { useSelectedViewName } from '@/lib/hooks/use-selected-view-name'; import { + DEFAULT_TRANSACTION_TABLE_SORTING, getTransactionTimeRangeFromState, useTransactionTableUrlState, } from '@/lib/hooks/use-transaction-table-url-state'; @@ -156,6 +157,7 @@ export function TransactionTable({ data={filteredTransactions} columnFilters={columnFilters} defaultColumnVisibility={defaultColumnVisibility} + defaultSorting={DEFAULT_TRANSACTION_TABLE_SORTING} viewType={ViewType.TRANSACTION} views={views} allowSidebarViews diff --git a/src/components/ui/data-table-header.tsx b/src/components/ui/data-table-header.tsx index a75b8273..2704e09d 100644 --- a/src/components/ui/data-table-header.tsx +++ b/src/components/ui/data-table-header.tsx @@ -2,7 +2,7 @@ import type { View, ViewType } from '@prisma/client'; import { useQueryClient } from '@tanstack/react-query'; -import type { Table, VisibilityState } from '@tanstack/react-table'; +import type { SortingState, Table, VisibilityState } from '@tanstack/react-table'; import { isEqual } from 'lodash'; import { ChevronDown, FileDown, Save, SlidersHorizontal } from 'lucide-react'; import { useTranslations } from 'next-intl'; @@ -30,6 +30,8 @@ import { DataTableExportDialog } from './data-table-export-dialog'; import { SaveViewDialog } from './save-view-dialog'; import { ViewManager } from './view-manager'; +const EMPTY_SORTING: SortingState = []; + interface DataTableHeaderProps { table: Table; showColumnVisibility?: boolean; @@ -46,6 +48,7 @@ interface DataTableHeaderProps { views: View[]; hasActiveFilters: () => boolean; defaultColumnVisibility: VisibilityState; + defaultSorting?: SortingState; tableState: TableUrlState; setTableState: SetTableUrlState; allowSidebarViews?: boolean; @@ -64,6 +67,7 @@ export function DataTableHeader({ showFilter = true, columnFilters = {}, defaultColumnVisibility, + defaultSorting = EMPTY_SORTING, viewType, views, hasActiveFilters, @@ -188,7 +192,7 @@ export function DataTableHeader({ const viewData = (views.find((v) => v.id === tableState.selectedView)?.data as any) ?? { selectedView: '', columnVisibility: defaultColumnVisibility, - sorting: [], + sorting: defaultSorting, columnFilters: [], globalFilter: '', pagination: { pageIndex: 0, pageSize: 25 }, @@ -198,11 +202,11 @@ export function DataTableHeader({ tableState.globalFilter !== (viewData.globalFilter ?? '') || tableState.pageSize !== (viewData.pagination?.pageSize ?? viewData.pageSize ?? 25) || !isEqual(tableState.columnVisibility, viewData.columnVisibility ?? defaultColumnVisibility) || - !isEqual(tableState.sorting, viewData.sorting ?? []) || + !isEqual(tableState.sorting, viewData.sorting ?? defaultSorting) || !isEqual(tableState.columnFilters, viewData.columnFilters ?? []) || (isExtraViewDataDirty?.(viewData) ?? false) ); - }, [views, tableState, defaultColumnVisibility, isExtraViewDataDirty]); + }, [views, tableState, defaultColumnVisibility, defaultSorting, isExtraViewDataDirty]); const groupedHideableColumns = useMemo(() => { const hideableColumns = table.getAllColumns().filter((column) => column.getCanHide()); @@ -266,7 +270,7 @@ export function DataTableHeader({ setTableState({ selectedView: '', columnVisibility: defaultColumnVisibility, - sorting: [], + sorting: defaultSorting, columnFilters: [], globalFilter: '', pageIndex: 0, diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index a6340871..ffb11985 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -4,6 +4,7 @@ import type { View, ViewType } from '@prisma/client'; import { type ColumnDef, type FilterFn, + type SortingState, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, @@ -111,6 +112,7 @@ interface DataTableProps { showFilter?: boolean; columnFilters?: DataTableColumnFilters; defaultColumnVisibility?: VisibilityState; + defaultSorting?: SortingState; viewType?: ViewType; isLoading?: boolean; /** Render `DropdownMenuItem` (and optional `DropdownMenuSeparator`) children; shown inside the row … menu. */ @@ -149,6 +151,7 @@ export function DataTable({ showFilter = true, columnFilters = {}, defaultColumnVisibility, + defaultSorting, viewType, views, allowSidebarViews = false, @@ -435,6 +438,7 @@ export function DataTable({ showFilter={showFilter} columnFilters={columnFilters} defaultColumnVisibility={defaultColumnVisibility ?? EMPTY_COLUMN_VISIBILITY} + defaultSorting={defaultSorting} views={views || []} viewType={viewType} hasActiveFilters={hasActiveFilters} 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 96831010..02c0dc93 100644 --- a/src/lib/dashboard/table-widget/transaction-table-column-registry.tsx +++ b/src/lib/dashboard/table-widget/transaction-table-column-registry.tsx @@ -1,5 +1,10 @@ import type { ColumnDef, FilterFn, VisibilityState } from '@tanstack/react-table'; +import { + isIncomingTransaction, + transactionAmountClassName, + transactionTypeBadgeClassName, +} from '@/components/loans/loan-balance-summary'; import { Badge } from '@/components/ui/badge'; import { dateRangeFilter } from '@/components/ui/data-table'; import { @@ -20,7 +25,7 @@ import { remapColumnsForNestedAccessor, withColumnGroup, } from '@/lib/table-column-utils'; -import { formatCurrency, resolveIntlLocaleForDates } from '@/lib/utils'; +import { cn, formatCurrency, resolveIntlLocaleForDates } from '@/lib/utils'; import type { LoanWithCalculations } from '@/types/loans'; import type { ProjectWithConfiguration } from '@/types/projects'; import type { TransactionListItem } from '@/types/transactions'; @@ -116,7 +121,11 @@ function buildTransactionColumns( cell: ({ row }) => { const value = row.original.type; if (!value) return ''; - return {commonT(`enums.transaction.type.${value}`)}; + return ( + + {commonT(`enums.transaction.type.${value}`)} + + ); }, filterFn: enumFilter, meta: { @@ -162,7 +171,15 @@ function buildTransactionColumns( header: 'table.amount', align: 'right', accessorFn: (row: TransactionListItem) => row.amount, - cell: ({ row }) =>
{formatCurrency(row.original.amount)}
, + cell: ({ row }) => { + const { type, amount } = row.original; + return ( +
+ {isIncomingTransaction(type) ? '+' : ''} + {formatCurrency(amount)} +
+ ); + }, filterFn: 'inNumberRange', meta: { export: { type: 'currency' }, diff --git a/src/lib/hooks/use-transaction-table-url-state.ts b/src/lib/hooks/use-transaction-table-url-state.ts index e5388cba..cecfa3f1 100644 --- a/src/lib/hooks/use-transaction-table-url-state.ts +++ b/src/lib/hooks/use-transaction-table-url-state.ts @@ -1,7 +1,7 @@ 'use client'; import type { View } from '@prisma/client'; -import type { VisibilityState } from '@tanstack/react-table'; +import type { SortingState, VisibilityState } from '@tanstack/react-table'; import { isEqual } from 'lodash'; import { useQueryStates } from 'nuqs'; import { useCallback, useMemo } from 'react'; @@ -22,9 +22,11 @@ import { export type TransactionTableUrlState = TableUrlState & TransactionTableExtraViewData; +export const DEFAULT_TRANSACTION_TABLE_SORTING: SortingState = [{ id: 'transaction.date', desc: true }]; + const DEFAULT_BASELINE: Omit = { globalFilter: '', - sorting: [{ id: 'transaction.date', desc: true }], + sorting: DEFAULT_TRANSACTION_TABLE_SORTING, columnFilters: [], columnVisibility: {}, pageIndex: 0, From b6e6a666b02b630e2bae9b3a78243f042f7ed422 Mon Sep 17 00:00:00 2001 From: Florian Humer Date: Sat, 15 Aug 2026 14:59:11 +0200 Subject: [PATCH 16/16] fix: dont grow left card on loan form when savings contract is enabled --- src/components/loans/loan-form-fields.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/loans/loan-form-fields.tsx b/src/components/loans/loan-form-fields.tsx index e939926e..570a8cd7 100644 --- a/src/components/loans/loan-form-fields.tsx +++ b/src/components/loans/loan-form-fields.tsx @@ -49,9 +49,10 @@ export function LoanFormFields({ lenders, isEditMode = false, currentLoanId }: L return ( <> -
+
+
{/* General Information Section */} - + -
+
{/* Savings Contract Section */}
+
{/* Additional Information Section */} {((project.configuration.loanAdditionalFields.length ?? 0) > 0 ||