Skip to content
Open
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"!.next",
"!styled-system",
"!drizzle",
"!orga",
"!prisma/generated",
"!.pnpm-store"
]
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Loan" ADD COLUMN "savingsLastDepositDate" TIMESTAMP(3);
11 changes: 11 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -517,6 +523,11 @@ enum DurationType {
YEARS
}

enum SavingsRateType {
FIXED
VARYING
}

enum TransactionType {
INTEREST
DEPOSIT
Expand Down
209 changes: 190 additions & 19 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();

Expand All @@ -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);
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/actions/loans/mutations/create-loan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {},
Expand Down
7 changes: 7 additions & 0 deletions src/actions/loans/mutations/update-loan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +24,14 @@ export function EntityFilterControl({
const filterState: ColumnFilter | undefined = value === '' || value == null ? undefined : { id: 'filter', value };

switch (definition.type) {
case 'boolean':
return (
<BooleanFilter
filterState={filterState}
onFilterChange={(v) => onChange(v)}
size="sm"
/>
);
case 'select':
return (
<SelectFilter
Expand Down
36 changes: 36 additions & 0 deletions src/components/filters/boolean-filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use client';

import { useTranslations } from 'next-intl';
import { useMemo } from 'react';

import { filterValueSegmentClass, type FilterFieldSize } from '@/components/filters/filter-field-group';
import { cn } from '@/lib/utils';
import { parseBooleanFilterValue, type BooleanFilterValue } from '@/types/boolean-filter-value';

export function BooleanFilter({
value,
onChange,
size = 'default',
}: {
value: unknown;
onChange: (value: BooleanFilterValue) => void;
size?: FilterFieldSize;
}) {
const tCommon = useTranslations('common.ui');
const parsed = useMemo(() => parseBooleanFilterValue(value), [value]);

return (
<select
className={cn(
filterValueSegmentClass(size),
'w-full rounded-md border border-border bg-background px-3 py-1',
)}
value={parsed}
onChange={(e) => onChange(e.target.value as BooleanFilterValue)}
>
<option value="">{tCommon('table.all')}</option>
<option value="true">{tCommon('boolean.yes')}</option>
<option value="false">{tCommon('boolean.no')}</option>
</select>
);
}
Loading