Refactor financial transaction handling: Consolidate Einnahmen and Ausgaben into Buchung model, update routes and UI components, and add new migration scripts for database schema changes.
This commit is contained in:
@@ -1,10 +1,9 @@
|
|||||||
import { getApiUser } from "@/session.server";
|
import { getApiUser } from "@/session.server";
|
||||||
import prisma from "@/lib/prisma.server";
|
import prisma from "@/lib/prisma.server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AusgabeKategorie } from "@prisma/client";
|
|
||||||
|
|
||||||
const updateSchema = z.object({
|
const updateSchema = z.object({
|
||||||
kategorie: z.nativeEnum(AusgabeKategorie),
|
kategorie: z.string().min(1),
|
||||||
betrag: z.number().positive(),
|
betrag: z.number().positive(),
|
||||||
steuersatz: z.number().min(0).default(0),
|
steuersatz: z.number().min(0).default(0),
|
||||||
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
|
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
|
||||||
@@ -16,13 +15,13 @@ export async function action({ request, params }: { request: Request; params: {
|
|||||||
const user = await getApiUser(request);
|
const user = await getApiUser(request);
|
||||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const ausgabe = await prisma.betriebsausgabe.findFirst({
|
const buchung = await prisma.buchung.findFirst({
|
||||||
where: { id: params.id, company: { userId: user.id } },
|
where: { id: params.id, company: { userId: user.id }, type: "ENTNAHME", isBusinessRecord: true },
|
||||||
});
|
});
|
||||||
if (!ausgabe) return Response.json({ error: "Not found" }, { status: 404 });
|
if (!buchung) return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
if (request.method === "DELETE") {
|
if (request.method === "DELETE") {
|
||||||
await prisma.betriebsausgabe.delete({ where: { id: params.id } });
|
await prisma.buchung.delete({ where: { id: params.id } });
|
||||||
return Response.json({ ok: true });
|
return Response.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,22 +29,22 @@ export async function action({ request, params }: { request: Request; params: {
|
|||||||
const parsed = updateSchema.safeParse(body);
|
const parsed = updateSchema.safeParse(body);
|
||||||
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
|
|
||||||
const updated = await prisma.betriebsausgabe.update({
|
const updated = await prisma.buchung.update({
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
data: {
|
data: {
|
||||||
kategorie: parsed.data.kategorie,
|
kategorie: parsed.data.kategorie,
|
||||||
betrag: parsed.data.betrag,
|
amount: parsed.data.betrag,
|
||||||
steuersatz: parsed.data.steuersatz,
|
steuersatz: parsed.data.steuersatz,
|
||||||
zahlungsart: parsed.data.zahlungsart,
|
zahlungsart: parsed.data.zahlungsart,
|
||||||
datum: new Date(parsed.data.datum),
|
account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK",
|
||||||
beschreibung: parsed.data.beschreibung,
|
date: new Date(parsed.data.datum),
|
||||||
|
description: parsed.data.beschreibung,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
...updated,
|
...updated,
|
||||||
betrag: Number(updated.betrag),
|
amount: Number(updated.amount),
|
||||||
steuersatz: Number(updated.steuersatz),
|
date: updated.date.toISOString(),
|
||||||
datum: updated.datum.toISOString(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-15
@@ -1,11 +1,10 @@
|
|||||||
import { getApiUser } from "@/session.server";
|
import { getApiUser } from "@/session.server";
|
||||||
import prisma from "@/lib/prisma.server";
|
import prisma from "@/lib/prisma.server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AusgabeKategorie } from "@prisma/client";
|
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
companyId: z.string().min(1),
|
companyId: z.string().min(1),
|
||||||
kategorie: z.nativeEnum(AusgabeKategorie),
|
kategorie: z.string().min(1),
|
||||||
betrag: z.number().positive(),
|
betrag: z.number().positive(),
|
||||||
steuersatz: z.number().min(0).default(0),
|
steuersatz: z.number().min(0).default(0),
|
||||||
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
|
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
|
||||||
@@ -26,25 +25,26 @@ export async function loader({ request }: { request: Request }) {
|
|||||||
const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
|
const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
|
||||||
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
|
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
const ausgaben = await prisma.betriebsausgabe.findMany({
|
const ausgaben = await prisma.buchung.findMany({
|
||||||
where: {
|
where: {
|
||||||
companyId,
|
companyId,
|
||||||
|
type: "ENTNAHME",
|
||||||
|
isBusinessRecord: true,
|
||||||
...(year ? {
|
...(year ? {
|
||||||
datum: {
|
date: {
|
||||||
gte: new Date(`${year}-01-01`),
|
gte: new Date(`${year}-01-01`),
|
||||||
lt: new Date(`${year + 1}-01-01`),
|
lt: new Date(`${year + 1}-01-01`),
|
||||||
},
|
},
|
||||||
} : {}),
|
} : {}),
|
||||||
},
|
},
|
||||||
orderBy: { datum: "desc" },
|
orderBy: { date: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
ausgaben.map((a) => ({
|
ausgaben.map((a) => ({
|
||||||
...a,
|
...a,
|
||||||
betrag: Number(a.betrag),
|
amount: Number(a.amount),
|
||||||
steuersatz: Number(a.steuersatz),
|
date: a.date.toISOString(),
|
||||||
datum: a.datum.toISOString(),
|
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -62,22 +62,24 @@ export async function action({ request }: { request: Request }) {
|
|||||||
});
|
});
|
||||||
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
|
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
|
||||||
|
|
||||||
const ausgabe = await prisma.betriebsausgabe.create({
|
const ausgabe = await prisma.buchung.create({
|
||||||
data: {
|
data: {
|
||||||
companyId: parsed.data.companyId,
|
companyId: parsed.data.companyId,
|
||||||
|
account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK",
|
||||||
|
type: "ENTNAHME",
|
||||||
|
amount: parsed.data.betrag,
|
||||||
|
date: new Date(parsed.data.datum),
|
||||||
|
description: parsed.data.beschreibung,
|
||||||
kategorie: parsed.data.kategorie,
|
kategorie: parsed.data.kategorie,
|
||||||
betrag: parsed.data.betrag,
|
|
||||||
steuersatz: parsed.data.steuersatz,
|
steuersatz: parsed.data.steuersatz,
|
||||||
zahlungsart: parsed.data.zahlungsart,
|
zahlungsart: parsed.data.zahlungsart,
|
||||||
datum: new Date(parsed.data.datum),
|
isBusinessRecord: true,
|
||||||
beschreibung: parsed.data.beschreibung,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
...ausgabe,
|
...ausgabe,
|
||||||
betrag: Number(ausgabe.betrag),
|
amount: Number(ausgabe.amount),
|
||||||
steuersatz: Number(ausgabe.steuersatz),
|
date: ausgabe.date.toISOString(),
|
||||||
datum: ausgabe.datum.toISOString(),
|
|
||||||
}, { status: 201 });
|
}, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-18
@@ -63,42 +63,42 @@ export async function loader({ request }: { request: Request }) {
|
|||||||
const bank = Number(bankAgg._sum.grossTotal ?? 0);
|
const bank = Number(bankAgg._sum.grossTotal ?? 0);
|
||||||
const summeAktiva = forderungen + bank;
|
const summeAktiva = forderungen + bank;
|
||||||
|
|
||||||
// Betriebsausgaben für das Jahr
|
// Betriebsausgaben für das Jahr (from buchungen with type=ENTNAHME and isBusinessRecord=true)
|
||||||
const ausgaben = await prisma.betriebsausgabe.findMany({
|
const ausgaben = await prisma.buchung.findMany({
|
||||||
where: { companyId, datum: { gte: yearStart, lt: yearEnd } },
|
where: { companyId, type: "ENTNAHME", isBusinessRecord: true, date: { gte: yearStart, lt: yearEnd } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const ausgabenGesamt = ausgaben.reduce((s, a) => s + Number(a.betrag), 0);
|
const ausgabenGesamt = ausgaben.reduce((s, a) => s + Number(a.amount), 0);
|
||||||
const ausgabenVorsteuer = ausgaben.reduce((s, a) => {
|
const ausgabenVorsteuer = ausgaben.reduce((s, a) => {
|
||||||
const brutto = Number(a.betrag);
|
const brutto = Number(a.amount);
|
||||||
const rate = Number(a.steuersatz) / 100;
|
const rate = (a.steuersatz || 0) / 100;
|
||||||
return s + (rate > 0 ? Math.round((brutto / (1 + rate)) * rate * 100) / 100 : 0);
|
return s + (rate > 0 ? Math.round((brutto / (1 + rate)) * rate * 100) / 100 : 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Ausgaben nach Kategorie
|
// Ausgaben nach Kategorie
|
||||||
const ausgabenByKategorieMap: Record<string, number> = {};
|
const ausgabenByKategorieMap: Record<string, number> = {};
|
||||||
for (const a of ausgaben) {
|
for (const a of ausgaben) {
|
||||||
const k = a.kategorie;
|
const k = a.kategorie || "Sonstige";
|
||||||
ausgabenByKategorieMap[k] = (ausgabenByKategorieMap[k] ?? 0) + Number(a.betrag);
|
ausgabenByKategorieMap[k] = (ausgabenByKategorieMap[k] ?? 0) + Number(a.amount);
|
||||||
}
|
}
|
||||||
const ausgabenByKategorie = Object.entries(ausgabenByKategorieMap).map(([kategorie, betrag]) => ({ kategorie, betrag }));
|
const ausgabenByKategorie = Object.entries(ausgabenByKategorieMap).map(([kategorie, betrag]) => ({ kategorie, betrag }));
|
||||||
|
|
||||||
// Sonstige Einnahmen für das Jahr
|
// Sonstige Einnahmen für das Jahr (from buchungen with type=EINLAGE and isBusinessRecord=true)
|
||||||
const einnahmen = await prisma.betriebseinnahme.findMany({
|
const einnahmen = await prisma.buchung.findMany({
|
||||||
where: { companyId, datum: { gte: yearStart, lt: yearEnd } },
|
where: { companyId, type: "EINLAGE", isBusinessRecord: true, date: { gte: yearStart, lt: yearEnd } },
|
||||||
});
|
});
|
||||||
const sonstigeEinnahmen = einnahmen.reduce((s, e) => s + Number(e.betrag), 0);
|
const sonstigeEinnahmen = einnahmen.reduce((s, e) => s + Number(e.amount), 0);
|
||||||
const einnahmenUst = einnahmen.reduce((s, e) => {
|
const einnahmenUst = einnahmen.reduce((s, e) => {
|
||||||
const brutto = Number(e.betrag);
|
const brutto = Number(e.amount);
|
||||||
const rate = Number(e.steuersatz) / 100;
|
const rate = (e.steuersatz || 0) / 100;
|
||||||
return s + (rate > 0 ? Math.round((brutto / (1 + rate)) * rate * 100) / 100 : 0);
|
return s + (rate > 0 ? Math.round((brutto / (1 + rate)) * rate * 100) / 100 : 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Kasse / Bank aus sonstigen Einnahmen und Ausgaben (nach Zahlungsart)
|
// Kasse / Bank aus sonstigen Einnahmen und Ausgaben (nach Zahlungsart)
|
||||||
const einnahmenKasse = einnahmen.filter((e) => e.zahlungsart === "KASSE").reduce((s, e) => s + Number(e.betrag), 0);
|
const einnahmenKasse = einnahmen.filter((e) => e.zahlungsart === "KASSE").reduce((s, e) => s + Number(e.amount), 0);
|
||||||
const ausgabenKasse = ausgaben.filter((a) => a.zahlungsart === "KASSE").reduce((s, a) => s + Number(a.betrag), 0);
|
const ausgabenKasse = ausgaben.filter((a) => a.zahlungsart === "KASSE").reduce((s, a) => s + Number(a.amount), 0);
|
||||||
const einnahmenBank = einnahmen.filter((e) => e.zahlungsart === "BANK").reduce((s, e) => s + Number(e.betrag), 0);
|
const einnahmenBank = einnahmen.filter((e) => e.zahlungsart === "BANK").reduce((s, e) => s + Number(e.amount), 0);
|
||||||
const ausgabenBank = ausgaben.filter((a) => a.zahlungsart === "BANK").reduce((s, a) => s + Number(a.betrag), 0);
|
const ausgabenBank = ausgaben.filter((a) => a.zahlungsart === "BANK").reduce((s, a) => s + Number(a.amount), 0);
|
||||||
|
|
||||||
// Kasse-Saldo = bezahlte Rechnungen (Kasse-Anteil wird nicht getrennt) + sonstige Einnahmen Kasse - Ausgaben Kasse
|
// Kasse-Saldo = bezahlte Rechnungen (Kasse-Anteil wird nicht getrennt) + sonstige Einnahmen Kasse - Ausgaben Kasse
|
||||||
// Bank-Näherung = bezahlte Rechnungen + sonstige Einnahmen Bank - Ausgaben Bank
|
// Bank-Näherung = bezahlte Rechnungen + sonstige Einnahmen Bank - Ausgaben Bank
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { getApiUser } from "@/session.server";
|
||||||
|
import prisma from "@/lib/prisma.server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
typ: z.enum(["EINNAHME", "AUSGABE"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loader: GET all categories for a company
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - typ (optional): "EINNAHME" or "AUSGABE" to filter by type
|
||||||
|
*
|
||||||
|
* Returns a JSON array of BuchungKategorie records.
|
||||||
|
*/
|
||||||
|
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||||
|
const user = await getApiUser(request);
|
||||||
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const company = await prisma.company.findFirst({
|
||||||
|
where: { id: params.id, userId: user.id },
|
||||||
|
});
|
||||||
|
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const typ = searchParams.get("typ");
|
||||||
|
|
||||||
|
const kategorien = await prisma.buchungKategorie.findMany({
|
||||||
|
where: {
|
||||||
|
companyId: params.id,
|
||||||
|
...(typ ? { typ } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(kategorien);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action: POST to create a new category, DELETE to remove one
|
||||||
|
*
|
||||||
|
* POST body:
|
||||||
|
* { name: string, typ: "EINNAHME" | "AUSGABE" }
|
||||||
|
*
|
||||||
|
* DELETE query param:
|
||||||
|
* - kategorieId: the id of the category to delete
|
||||||
|
*/
|
||||||
|
export async function action({ request, params }: { request: Request; params: { id: string } }) {
|
||||||
|
const user = await getApiUser(request);
|
||||||
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const company = await prisma.company.findFirst({
|
||||||
|
where: { id: params.id, userId: user.id },
|
||||||
|
});
|
||||||
|
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
|
||||||
|
|
||||||
|
if (request.method === "DELETE") {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const kategorieId = searchParams.get("kategorieId");
|
||||||
|
|
||||||
|
if (!kategorieId) {
|
||||||
|
return Response.json({ error: "kategorieId required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that kategorie belongs to this company
|
||||||
|
const kategorie = await prisma.buchungKategorie.findFirst({
|
||||||
|
where: { id: kategorieId, companyId: params.id },
|
||||||
|
});
|
||||||
|
if (!kategorie) return Response.json({ error: "Category not found" }, { status: 404 });
|
||||||
|
|
||||||
|
// Check if kategorie is in use
|
||||||
|
const inUse = await prisma.buchung.findFirst({
|
||||||
|
where: { kategorie: kategorie.name, companyId: params.id },
|
||||||
|
});
|
||||||
|
if (inUse) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Category is in use and cannot be deleted" },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.buchungKategorie.delete({ where: { id: kategorieId } });
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = createSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
|
|
||||||
|
const kategorie = await prisma.buchungKategorie.create({
|
||||||
|
data: {
|
||||||
|
companyId: params.id,
|
||||||
|
name: parsed.data.name,
|
||||||
|
typ: parsed.data.typ,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(kategorie, { status: 201 });
|
||||||
|
}
|
||||||
@@ -8,9 +8,11 @@ type Transaction = {
|
|||||||
type: "einlage" | "entnahme";
|
type: "einlage" | "entnahme";
|
||||||
amount: number;
|
amount: number;
|
||||||
description: string;
|
description: string;
|
||||||
|
isBusinessRecord: boolean;
|
||||||
|
kategorie: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toTransaction(buchung: { id: string; date: Date; account: "KASSE" | "BANK"; type: "EINLAGE" | "ENTNAHME"; amount: { toString(): string } | number | string; description: string | null | undefined; }): Transaction {
|
function toTransaction(buchung: { id: string; date: Date; account: "KASSE" | "BANK"; type: "EINLAGE" | "ENTNAHME"; amount: { toString(): string } | number | string; description: string | null | undefined; isBusinessRecord: boolean; kategorie: string | null | undefined }): Transaction {
|
||||||
return {
|
return {
|
||||||
id: buchung.id,
|
id: buchung.id,
|
||||||
date: buchung.date.toISOString().split("T")[0],
|
date: buchung.date.toISOString().split("T")[0],
|
||||||
@@ -18,6 +20,8 @@ function toTransaction(buchung: { id: string; date: Date; account: "KASSE" | "BA
|
|||||||
type: buchung.type === "EINLAGE" ? "einlage" : "entnahme",
|
type: buchung.type === "EINLAGE" ? "einlage" : "entnahme",
|
||||||
amount: Number(buchung.amount),
|
amount: Number(buchung.amount),
|
||||||
description: buchung.description || "",
|
description: buchung.description || "",
|
||||||
|
isBusinessRecord: buchung.isBusinessRecord,
|
||||||
|
kategorie: buchung.kategorie || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,10 +36,21 @@ export async function loader({ request, params }: { request: Request; params: {
|
|||||||
|
|
||||||
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
|
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
|
||||||
|
|
||||||
const buchungen = await prisma.buchung.findMany({
|
const buchungen = (await prisma.buchung.findMany({
|
||||||
where: { companyId: id },
|
where: { companyId: id },
|
||||||
orderBy: { date: "desc" },
|
orderBy: { date: "desc" },
|
||||||
});
|
select: {
|
||||||
|
id: true,
|
||||||
|
date: true,
|
||||||
|
account: true,
|
||||||
|
type: true,
|
||||||
|
amount: true,
|
||||||
|
description: true,
|
||||||
|
isBusinessRecord: true,
|
||||||
|
kategorie: true,
|
||||||
|
},
|
||||||
|
})) as unknown as Array<{ id: string; date: Date; account: "KASSE" | "BANK"; type: "EINLAGE" | "ENTNAHME"; amount: any; description: string | null; isBusinessRecord: boolean; kategorie: string | null }>;
|
||||||
|
|
||||||
|
|
||||||
const transactions = buchungen.map(toTransaction);
|
const transactions = buchungen.map(toTransaction);
|
||||||
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number);
|
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number);
|
||||||
@@ -60,20 +75,58 @@ export async function action({ request, params }: { request: Request; params: {
|
|||||||
|
|
||||||
if (method === "POST") {
|
if (method === "POST") {
|
||||||
const amount = Number(data.amount);
|
const amount = Number(data.amount);
|
||||||
if (!data.date || !data.account || !data.type || Number.isNaN(amount) || amount <= 0) {
|
if (!data.date || !data.account || Number.isNaN(amount) || amount <= 0) {
|
||||||
return Response.json({ error: "Ungültige Daten" }, { status: 400 });
|
return Response.json({ error: "Ungültige Daten" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.buchung.create({
|
// Check if this is an Umbuchung (transfer between accounts)
|
||||||
data: {
|
if (data.type === "umbuchung") {
|
||||||
companyId: id,
|
if (!data.toAccount) {
|
||||||
date: new Date(data.date),
|
return Response.json({ error: "toAccount erforderlich für Umbuchung" }, { status: 400 });
|
||||||
account: data.account === "bank" ? "BANK" : "KASSE",
|
}
|
||||||
type: data.type === "entnahme" ? "ENTNAHME" : "EINLAGE",
|
|
||||||
amount: amount,
|
await prisma.$transaction(async (tx) => {
|
||||||
description: data.description || "",
|
// ENTNAHME from source account
|
||||||
},
|
const entnahme = await tx.buchung.create({
|
||||||
});
|
data: {
|
||||||
|
companyId: id,
|
||||||
|
date: new Date(data.date),
|
||||||
|
account: data.account === "bank" ? "BANK" : "KASSE",
|
||||||
|
type: "ENTNAHME",
|
||||||
|
amount: amount,
|
||||||
|
description: data.description || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// EINLAGE to target account, linked to the entnahme
|
||||||
|
await tx.buchung.create({
|
||||||
|
data: {
|
||||||
|
companyId: id,
|
||||||
|
date: new Date(data.date),
|
||||||
|
account: data.toAccount === "bank" ? "BANK" : "KASSE",
|
||||||
|
type: "EINLAGE",
|
||||||
|
amount: amount,
|
||||||
|
description: data.description || "",
|
||||||
|
linkedBuchungId: entnahme.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!data.type) {
|
||||||
|
return Response.json({ error: "type erforderlich" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.buchung.create({
|
||||||
|
data: {
|
||||||
|
companyId: id,
|
||||||
|
date: new Date(data.date),
|
||||||
|
account: data.account === "bank" ? "BANK" : "KASSE",
|
||||||
|
type: data.type === "entnahme" ? "ENTNAHME" : "EINLAGE",
|
||||||
|
amount: amount,
|
||||||
|
description: data.description || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (method === "PUT") {
|
} else if (method === "PUT") {
|
||||||
if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 });
|
if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 });
|
||||||
const amount = Number(data.amount);
|
const amount = Number(data.amount);
|
||||||
@@ -81,9 +134,28 @@ export async function action({ request, params }: { request: Request; params: {
|
|||||||
return Response.json({ error: "Ungültige Daten" }, { status: 400 });
|
return Response.json({ error: "Ungültige Daten" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const exist = await prisma.buchung.findFirst({ where: { id: transactionId, companyId: id } });
|
const exist = (await prisma.buchung.findFirst({
|
||||||
|
where: { id: transactionId, companyId: id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
date: true,
|
||||||
|
account: true,
|
||||||
|
type: true,
|
||||||
|
amount: true,
|
||||||
|
description: true,
|
||||||
|
isBusinessRecord: true,
|
||||||
|
},
|
||||||
|
})) as unknown as { id: string; date: Date; account: "KASSE" | "BANK"; type: "EINLAGE" | "ENTNAHME"; amount: any; description: string | null; isBusinessRecord: boolean } | null;
|
||||||
if (!exist) return Response.json({ error: "Transaction not found" }, { status: 404 });
|
if (!exist) return Response.json({ error: "Transaction not found" }, { status: 404 });
|
||||||
|
|
||||||
|
// Block edit if this is an auto-created Buchung (from Einnahme/Ausgabe)
|
||||||
|
if (exist.isBusinessRecord) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Automatisch erstellte Transaktionen können nicht direkt bearbeitet werden" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.buchung.update({
|
await prisma.buchung.update({
|
||||||
where: { id: transactionId },
|
where: { id: transactionId },
|
||||||
data: {
|
data: {
|
||||||
@@ -97,12 +169,50 @@ export async function action({ request, params }: { request: Request; params: {
|
|||||||
} else if (method === "DELETE") {
|
} else if (method === "DELETE") {
|
||||||
if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 });
|
if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 });
|
||||||
|
|
||||||
await prisma.buchung.deleteMany({ where: { id: transactionId, companyId: id } });
|
const exist = await prisma.buchung.findFirst({ where: { id: transactionId, companyId: id } });
|
||||||
|
if (!exist) return Response.json({ error: "Transaction not found" }, { status: 404 });
|
||||||
|
|
||||||
|
// For Umbuchung (linked transactions), delete both
|
||||||
|
const linkedId = (exist as any).linkedBuchungId;
|
||||||
|
const isLinkedFrom = await prisma.buchung.findFirst({
|
||||||
|
where: { linkedBuchungId: transactionId } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (linkedId || isLinkedFrom) {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// If this is the ENTNAHME, delete linked EINLAGE
|
||||||
|
if (linkedId) {
|
||||||
|
await tx.buchung.deleteMany({ where: { id: linkedId } });
|
||||||
|
}
|
||||||
|
// If this is the EINLAGE, delete linked ENTNAHME
|
||||||
|
if (isLinkedFrom) {
|
||||||
|
await tx.buchung.deleteMany({ where: { id: isLinkedFrom.id } });
|
||||||
|
}
|
||||||
|
// Delete this entry
|
||||||
|
await tx.buchung.deleteMany({ where: { id: transactionId, companyId: id } });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Regular transaction
|
||||||
|
await prisma.buchung.deleteMany({ where: { id: transactionId, companyId: id } });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const buchungen = await prisma.buchung.findMany({ where: { companyId: id }, orderBy: { date: "desc" } });
|
const buchungen = await prisma.buchung.findMany({
|
||||||
|
where: { companyId: id },
|
||||||
|
orderBy: { date: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
date: true,
|
||||||
|
account: true,
|
||||||
|
type: true,
|
||||||
|
amount: true,
|
||||||
|
description: true,
|
||||||
|
isBusinessRecord: true,
|
||||||
|
kategorie: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
const transactions = buchungen.map(toTransaction);
|
const transactions = buchungen.map(toTransaction);
|
||||||
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number);
|
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { getApiUser } from "@/session.server";
|
import { getApiUser } from "@/session.server";
|
||||||
import prisma from "@/lib/prisma.server";
|
import prisma from "@/lib/prisma.server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { EinnahmeKategorie } from "@prisma/client";
|
|
||||||
|
|
||||||
const updateSchema = z.object({
|
const updateSchema = z.object({
|
||||||
kategorie: z.nativeEnum(EinnahmeKategorie),
|
kategorie: z.string().min(1),
|
||||||
betrag: z.number().positive(),
|
betrag: z.number().positive(),
|
||||||
steuersatz: z.number().min(0).default(0),
|
steuersatz: z.number().min(0).default(0),
|
||||||
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
|
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
|
||||||
@@ -13,29 +12,25 @@ const updateSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles an API request to create, update or delete a einnahme.
|
* Handles an API request to update or delete a einnahme (Buchung).
|
||||||
*
|
*
|
||||||
* @param {Request} request - The request object.
|
* @param {Request} request - The request object.
|
||||||
* @param {Object} params - The route parameters.
|
* @param {Object} params - The route parameters.
|
||||||
* @param {string} params.id - The id of the einnahme to update or delete.
|
* @param {string} params.id - The id of the Buchung (einnahme) to update or delete.
|
||||||
*
|
*
|
||||||
* @returns {Promise<Response>} - A promise resolving to a Response object.
|
* @returns {Promise<Response>} - A promise resolving to a Response object.
|
||||||
*
|
|
||||||
* @throws {Response} - If the request is unauthorized, returns a 401 response with an error message.
|
|
||||||
* @throws {Response} - If the einnahme is not found, returns a 404 response with an error message.
|
|
||||||
* @throws {Response} - If the request body is invalid, returns a 400 response with an error message containing the validation errors.
|
|
||||||
*/
|
*/
|
||||||
export async function action({ request, params }: { request: Request; params: { id: string } }) {
|
export async function action({ request, params }: { request: Request; params: { id: string } }) {
|
||||||
const user = await getApiUser(request);
|
const user = await getApiUser(request);
|
||||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const einnahme = await prisma.betriebseinnahme.findFirst({
|
const buchung = await prisma.buchung.findFirst({
|
||||||
where: { id: params.id, company: { userId: user.id } },
|
where: { id: params.id, company: { userId: user.id }, type: "EINLAGE", isBusinessRecord: true },
|
||||||
});
|
});
|
||||||
if (!einnahme) return Response.json({ error: "Not found" }, { status: 404 });
|
if (!buchung) return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
if (request.method === "DELETE") {
|
if (request.method === "DELETE") {
|
||||||
await prisma.betriebseinnahme.delete({ where: { id: params.id } });
|
await prisma.buchung.delete({ where: { id: params.id } });
|
||||||
return Response.json({ ok: true });
|
return Response.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,22 +38,22 @@ export async function action({ request, params }: { request: Request; params: {
|
|||||||
const parsed = updateSchema.safeParse(body);
|
const parsed = updateSchema.safeParse(body);
|
||||||
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
|
|
||||||
const updated = await prisma.betriebseinnahme.update({
|
const updated = await prisma.buchung.update({
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
data: {
|
data: {
|
||||||
kategorie: parsed.data.kategorie,
|
kategorie: parsed.data.kategorie,
|
||||||
betrag: parsed.data.betrag,
|
amount: parsed.data.betrag,
|
||||||
steuersatz: parsed.data.steuersatz,
|
steuersatz: parsed.data.steuersatz,
|
||||||
zahlungsart: parsed.data.zahlungsart,
|
zahlungsart: parsed.data.zahlungsart,
|
||||||
datum: new Date(parsed.data.datum),
|
account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK",
|
||||||
beschreibung: parsed.data.beschreibung,
|
date: new Date(parsed.data.datum),
|
||||||
|
description: parsed.data.beschreibung,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
...updated,
|
...updated,
|
||||||
betrag: Number(updated.betrag),
|
amount: Number(updated.amount),
|
||||||
steuersatz: Number(updated.steuersatz),
|
date: updated.date.toISOString(),
|
||||||
datum: updated.datum.toISOString(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-19
@@ -1,11 +1,10 @@
|
|||||||
import { getApiUser } from "@/session.server";
|
import { getApiUser } from "@/session.server";
|
||||||
import prisma from "@/lib/prisma.server";
|
import prisma from "@/lib/prisma.server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { EinnahmeKategorie } from "@prisma/client";
|
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
companyId: z.string().min(1),
|
companyId: z.string().min(1),
|
||||||
kategorie: z.nativeEnum(EinnahmeKategorie),
|
kategorie: z.string().min(1),
|
||||||
betrag: z.number().positive(),
|
betrag: z.number().positive(),
|
||||||
steuersatz: z.number().min(0).default(0),
|
steuersatz: z.number().min(0).default(0),
|
||||||
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
|
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
|
||||||
@@ -18,7 +17,7 @@ const createSchema = z.object({
|
|||||||
*
|
*
|
||||||
* Requires a companyId search parameter. If year is provided, filters einnahmen for the given year.
|
* Requires a companyId search parameter. If year is provided, filters einnahmen for the given year.
|
||||||
*
|
*
|
||||||
* Returns a list of einnahmen as a JSON object.
|
* Returns a list of einnahmen (Buchungen with isBusinessRecord=true, type=EINLAGE) as a JSON object.
|
||||||
*
|
*
|
||||||
* If the request is unauthorized, returns a 401 response with an error message.
|
* If the request is unauthorized, returns a 401 response with an error message.
|
||||||
* If the request body is invalid, returns a 400 response with an error message containing the validation errors.
|
* If the request body is invalid, returns a 400 response with an error message containing the validation errors.
|
||||||
@@ -37,42 +36,45 @@ export async function loader({ request }: { request: Request }) {
|
|||||||
const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
|
const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
|
||||||
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
|
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
const einnahmen = await prisma.betriebseinnahme.findMany({
|
const einnahmen = await prisma.buchung.findMany({
|
||||||
where: {
|
where: {
|
||||||
companyId,
|
companyId,
|
||||||
|
type: "EINLAGE",
|
||||||
|
isBusinessRecord: true,
|
||||||
...(year ? {
|
...(year ? {
|
||||||
datum: {
|
date: {
|
||||||
gte: new Date(`${year}-01-01`),
|
gte: new Date(`${year}-01-01`),
|
||||||
lt: new Date(`${year + 1}-01-01`),
|
lt: new Date(`${year + 1}-01-01`),
|
||||||
},
|
},
|
||||||
} : {}),
|
} : {}),
|
||||||
},
|
},
|
||||||
orderBy: { datum: "asc" },
|
orderBy: { date: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
einnahmen.map((e) => ({
|
einnahmen.map((e) => ({
|
||||||
...e,
|
...e,
|
||||||
betrag: Number(e.betrag),
|
amount: Number(e.amount),
|
||||||
steuersatz: Number(e.steuersatz),
|
date: e.date.toISOString(),
|
||||||
datum: e.datum.toISOString(),
|
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new einnahme for a given company.
|
* Creates a new einnahme (Buchung) for a given company.
|
||||||
*
|
*
|
||||||
* Requires a JSON object in the request body with the following shape:
|
* Requires a JSON object in the request body with the following shape:
|
||||||
* {
|
* {
|
||||||
* companyId: string,
|
* companyId: string,
|
||||||
* kategorie: EinnahmeKategorie,
|
* kategorie: string (BuchungKategorie name),
|
||||||
* betrag: number,
|
* betrag: number,
|
||||||
|
* steuersatz: number,
|
||||||
|
* zahlungsart: "KASSE" | "BANK",
|
||||||
* datum: string,
|
* datum: string,
|
||||||
* beschreibung: string,
|
* beschreibung: string,
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* Returns the created einnahme as a JSON object.
|
* Returns the created Buchung as a JSON object.
|
||||||
*
|
*
|
||||||
* If the request is unauthorized, returns a 401 response with an error message.
|
* If the request is unauthorized, returns a 401 response with an error message.
|
||||||
* If the request body is invalid, returns a 400 response with an error message containing the validation errors.
|
* If the request body is invalid, returns a 400 response with an error message containing the validation errors.
|
||||||
@@ -91,24 +93,26 @@ export async function action({ request }: { request: Request }) {
|
|||||||
});
|
});
|
||||||
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
|
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
|
||||||
|
|
||||||
const einnahme = await prisma.betriebseinnahme.create({
|
const einnahme = await prisma.buchung.create({
|
||||||
data: {
|
data: {
|
||||||
companyId: parsed.data.companyId,
|
companyId: parsed.data.companyId,
|
||||||
|
account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK",
|
||||||
|
type: "EINLAGE",
|
||||||
|
amount: parsed.data.betrag,
|
||||||
|
date: new Date(parsed.data.datum),
|
||||||
|
description: parsed.data.beschreibung,
|
||||||
kategorie: parsed.data.kategorie,
|
kategorie: parsed.data.kategorie,
|
||||||
betrag: parsed.data.betrag,
|
|
||||||
steuersatz: parsed.data.steuersatz,
|
steuersatz: parsed.data.steuersatz,
|
||||||
zahlungsart: parsed.data.zahlungsart,
|
zahlungsart: parsed.data.zahlungsart,
|
||||||
datum: new Date(parsed.data.datum),
|
isBusinessRecord: true,
|
||||||
beschreibung: parsed.data.beschreibung,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
...einnahme,
|
...einnahme,
|
||||||
betrag: Number(einnahme.betrag),
|
amount: Number(einnahme.amount),
|
||||||
steuersatz: Number(einnahme.steuersatz),
|
date: einnahme.date.toISOString(),
|
||||||
datum: einnahme.datum.toISOString(),
|
|
||||||
},
|
},
|
||||||
{ status: 201 }
|
{ status: 201 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export async function action({ request, params }: { request: Request; params: {
|
|||||||
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
|
|
||||||
const newStatus = parsed.data.status;
|
const newStatus = parsed.data.status;
|
||||||
|
const oldStatus = invoice.status;
|
||||||
|
|
||||||
let numberUpdate: string | null | undefined = undefined;
|
let numberUpdate: string | null | undefined = undefined;
|
||||||
if (newStatus === "DELETED") {
|
if (newStatus === "DELETED") {
|
||||||
@@ -111,10 +112,45 @@ export async function action({ request, params }: { request: Request; params: {
|
|||||||
numberUpdate = await generateInvoiceNumber(invoice.companyId);
|
numberUpdate = await generateInvoiceNumber(invoice.companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Buchung sync: Create when PAID, delete when unpaying
|
||||||
|
if (newStatus === "PAID" && oldStatus !== "PAID") {
|
||||||
|
// Create a Buchung for the invoice payment
|
||||||
|
const buchung = await prisma.buchung.create({
|
||||||
|
data: {
|
||||||
|
companyId: invoice.companyId,
|
||||||
|
date: invoice.issueDate,
|
||||||
|
account: "BANK",
|
||||||
|
type: "EINLAGE",
|
||||||
|
amount: invoice.grossTotal,
|
||||||
|
description: `Rechnung ${invoice.number}`,
|
||||||
|
kategorie: "Rechnungseinnahme",
|
||||||
|
isBusinessRecord: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update invoice with buchungId
|
||||||
|
const updated = await prisma.invoice.update({
|
||||||
|
where: { id: params.id },
|
||||||
|
data: {
|
||||||
|
status: newStatus,
|
||||||
|
buchungId: buchung.id,
|
||||||
|
deletedAt: null,
|
||||||
|
...(numberUpdate !== undefined && { number: numberUpdate }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return Response.json(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newStatus !== "PAID" && oldStatus === "PAID" && invoice.buchungId) {
|
||||||
|
// Delete the linked Buchung when unpaying
|
||||||
|
await prisma.buchung.delete({ where: { id: invoice.buchungId } });
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await prisma.invoice.update({
|
const updated = await prisma.invoice.update({
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
data: {
|
data: {
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
|
buchungId: newStatus === "PAID" ? invoice.buchungId : null,
|
||||||
deletedAt: newStatus === "DELETED" ? new Date() : null,
|
deletedAt: newStatus === "DELETED" ? new Date() : null,
|
||||||
...(numberUpdate !== undefined && { number: numberUpdate }),
|
...(numberUpdate !== undefined && { number: numberUpdate }),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,12 +52,14 @@ export async function loader({ request, params }: { request: Request; params: {
|
|||||||
if (!company) throw new Response("Not Found", { status: 404 });
|
if (!company) throw new Response("Not Found", { status: 404 });
|
||||||
|
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const ausgaben = await prisma.betriebsausgabe.findMany({
|
const ausgaben = await prisma.buchung.findMany({
|
||||||
where: {
|
where: {
|
||||||
companyId: params.id,
|
companyId: params.id,
|
||||||
datum: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) },
|
type: "ENTNAHME",
|
||||||
|
isBusinessRecord: true,
|
||||||
|
date: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) },
|
||||||
},
|
},
|
||||||
orderBy: { datum: "desc" },
|
orderBy: { date: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -66,12 +68,12 @@ export async function loader({ request, params }: { request: Request; params: {
|
|||||||
initialYear: year,
|
initialYear: year,
|
||||||
ausgaben: ausgaben.map((a) => ({
|
ausgaben: ausgaben.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
kategorie: a.kategorie as AusgabeKategorieKey,
|
kategorie: (a.kategorie || "SONSTIGER_BETRIEBSBEDARF") as AusgabeKategorieKey,
|
||||||
betrag: Number(a.betrag),
|
betrag: Number(a.amount),
|
||||||
steuersatz: Number(a.steuersatz),
|
steuersatz: a.steuersatz || 19,
|
||||||
zahlungsart: a.zahlungsart as "KASSE" | "BANK",
|
zahlungsart: (a.zahlungsart as "KASSE" | "BANK") || "BANK",
|
||||||
datum: a.datum.toISOString(),
|
datum: a.date.toISOString(),
|
||||||
beschreibung: a.beschreibung,
|
beschreibung: a.description,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,12 +52,14 @@ export async function loader({ request, params }: { request: Request; params: {
|
|||||||
if (!company) throw new Response("Not Found", { status: 404 });
|
if (!company) throw new Response("Not Found", { status: 404 });
|
||||||
|
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const einnahmen = await prisma.betriebseinnahme.findMany({
|
const einnahmen = await prisma.buchung.findMany({
|
||||||
where: {
|
where: {
|
||||||
companyId: params.id,
|
companyId: params.id,
|
||||||
datum: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) },
|
type: "EINLAGE",
|
||||||
|
isBusinessRecord: true,
|
||||||
|
date: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) },
|
||||||
},
|
},
|
||||||
orderBy: { datum: "desc" },
|
orderBy: { date: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -66,12 +68,12 @@ export async function loader({ request, params }: { request: Request; params: {
|
|||||||
initialYear: year,
|
initialYear: year,
|
||||||
einnahmen: einnahmen.map((e) => ({
|
einnahmen: einnahmen.map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
kategorie: e.kategorie as EinnahmeKategorieKey,
|
kategorie: (e.kategorie || "SONSTIGE_EINNAHMEN") as EinnahmeKategorieKey,
|
||||||
betrag: Number(e.betrag),
|
betrag: Number(e.amount),
|
||||||
steuersatz: Number(e.steuersatz),
|
steuersatz: e.steuersatz || 0,
|
||||||
zahlungsart: e.zahlungsart as "KASSE" | "BANK",
|
zahlungsart: (e.zahlungsart as "KASSE" | "BANK") || "BANK",
|
||||||
datum: e.datum.toISOString(),
|
datum: e.date.toISOString(),
|
||||||
beschreibung: e.beschreibung,
|
beschreibung: e.description,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+294
-110
@@ -16,6 +16,8 @@ type Transaction = {
|
|||||||
type: 'einlage' | 'entnahme';
|
type: 'einlage' | 'entnahme';
|
||||||
amount: number;
|
amount: number;
|
||||||
description: string;
|
description: string;
|
||||||
|
isBusinessRecord: boolean;
|
||||||
|
kategorie: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||||
@@ -29,6 +31,16 @@ export async function loader({ request, params }: { request: Request; params: {
|
|||||||
const buchungen = await prisma.buchung.findMany({
|
const buchungen = await prisma.buchung.findMany({
|
||||||
where: { companyId: company.id },
|
where: { companyId: company.id },
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
date: true,
|
||||||
|
account: true,
|
||||||
|
type: true,
|
||||||
|
amount: true,
|
||||||
|
description: true,
|
||||||
|
isBusinessRecord: true,
|
||||||
|
kategorie: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const transactions: Transaction[] = buchungen.map((b): Transaction => ({
|
const transactions: Transaction[] = buchungen.map((b): Transaction => ({
|
||||||
@@ -38,6 +50,8 @@ export async function loader({ request, params }: { request: Request; params: {
|
|||||||
type: b.type === 'EINLAGE' ? 'einlage' : 'entnahme',
|
type: b.type === 'EINLAGE' ? 'einlage' : 'entnahme',
|
||||||
amount: Number(b.amount),
|
amount: Number(b.amount),
|
||||||
description: b.description || '',
|
description: b.description || '',
|
||||||
|
isBusinessRecord: b.isBusinessRecord,
|
||||||
|
kategorie: b.kategorie || null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === 'einlage' ? t.amount : -t.amount), 0);
|
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === 'einlage' ? t.amount : -t.amount), 0);
|
||||||
@@ -59,22 +73,40 @@ export default function CompanyMoney() {
|
|||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
||||||
|
const [isUmbuchung, setIsUmbuchung] = useState(false);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
date: new Date().toISOString().split('T')[0],
|
date: new Date().toISOString().split('T')[0],
|
||||||
account: 'kasse' as 'kasse' | 'bank',
|
account: 'kasse' as 'kasse' | 'bank',
|
||||||
type: 'einlage' as 'einlage' | 'entnahme',
|
type: 'einlage' as 'einlage' | 'entnahme',
|
||||||
amount: '',
|
amount: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
toAccount: 'bank' as 'kasse' | 'bank',
|
||||||
});
|
});
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setEditingTransaction(null);
|
setEditingTransaction(null);
|
||||||
|
setIsUmbuchung(false);
|
||||||
setForm({
|
setForm({
|
||||||
date: new Date().toISOString().split('T')[0],
|
date: new Date().toISOString().split('T')[0],
|
||||||
account: 'kasse',
|
account: 'kasse',
|
||||||
type: 'einlage',
|
type: 'einlage',
|
||||||
amount: '',
|
amount: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
toAccount: 'bank',
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateUmbuchung() {
|
||||||
|
setEditingTransaction(null);
|
||||||
|
setIsUmbuchung(true);
|
||||||
|
setForm({
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
account: 'kasse',
|
||||||
|
type: 'umbuchung',
|
||||||
|
amount: '',
|
||||||
|
description: '',
|
||||||
|
toAccount: 'bank',
|
||||||
});
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}
|
}
|
||||||
@@ -93,27 +125,46 @@ export default function CompanyMoney() {
|
|||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const payload = {
|
const payload = isUmbuchung
|
||||||
date: form.date,
|
? {
|
||||||
account: form.account,
|
date: form.date,
|
||||||
type: form.type,
|
account: form.account,
|
||||||
amount: parseFloat(form.amount),
|
type: 'umbuchung',
|
||||||
description: form.description,
|
toAccount: form.toAccount,
|
||||||
};
|
amount: parseFloat(form.amount),
|
||||||
|
description: form.description,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
date: form.date,
|
||||||
|
account: form.account,
|
||||||
|
type: form.type,
|
||||||
|
amount: parseFloat(form.amount),
|
||||||
|
description: form.description,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingTransaction) {
|
if (editingTransaction) {
|
||||||
await fetch(`/api/companies/${companyId}/money?transactionId=${editingTransaction.id}`, {
|
const res = await fetch(`/api/companies/${companyId}/money?transactionId=${editingTransaction.id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
alert(error.error || "Fehler beim Speichern");
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await fetch(`/api/companies/${companyId}/money`, {
|
const res = await fetch(`/api/companies/${companyId}/money`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
alert(error.error || "Fehler beim Speichern");
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
revalidate();
|
revalidate();
|
||||||
@@ -158,6 +209,10 @@ export default function CompanyMoney() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<Button onClick={openCreateUmbuchung} variant="outline">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Umbuchung
|
||||||
|
</Button>
|
||||||
<Button onClick={openCreate}>
|
<Button onClick={openCreate}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Neue Transaktion
|
Neue Transaktion
|
||||||
@@ -187,7 +242,7 @@ export default function CompanyMoney() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabelle */}
|
{/* Split-View: Kasse und Bank nebeneinander */}
|
||||||
{sortedTransactions.length === 0 ? (
|
{sortedTransactions.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-16 text-center text-gray-400">
|
<CardContent className="py-16 text-center text-gray-400">
|
||||||
@@ -199,80 +254,181 @@ export default function CompanyMoney() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<div className="overflow-x-auto">
|
{/* Kasse Tabelle */}
|
||||||
<table className="w-full text-sm border-collapse">
|
<Card>
|
||||||
<thead>
|
<div className="px-4 py-3 border-b border-slate-200 bg-slate-50">
|
||||||
<tr className="border-b border-slate-200 bg-slate-50">
|
<h3 className="font-semibold text-slate-700">Kasse</h3>
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
</div>
|
||||||
Datum
|
<div className="overflow-x-auto">
|
||||||
</th>
|
<table className="w-full text-sm border-collapse">
|
||||||
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
<thead>
|
||||||
Konto
|
<tr className="border-b border-slate-200 bg-slate-50">
|
||||||
</th>
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
Datum
|
||||||
Typ
|
</th>
|
||||||
</th>
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
Typ
|
||||||
Beschreibung
|
</th>
|
||||||
</th>
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
Beschreibung
|
||||||
Betrag
|
</th>
|
||||||
</th>
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
<th className="px-3 py-2.5 w-16" />
|
Kategorie
|
||||||
</tr>
|
</th>
|
||||||
</thead>
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
<tbody className="divide-y divide-slate-100">
|
Betrag
|
||||||
{sortedTransactions.map((transaction) => (
|
</th>
|
||||||
<tr key={transaction.id} className="hover:bg-slate-50/60 group">
|
<th className="px-3 py-2.5 w-16" />
|
||||||
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
|
|
||||||
{transaction.date}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
|
|
||||||
{transaction.account === 'kasse' ? 'Kasse' : 'Bank'}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
|
|
||||||
<Badge variant={transaction.type === 'einlage' ? 'default' : 'destructive'}>
|
|
||||||
{transaction.type === 'einlage' ? 'Einlage' : 'Entnahme'}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 text-slate-700">
|
|
||||||
{transaction.description}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 text-right font-medium whitespace-nowrap">
|
|
||||||
<span className={transaction.type === 'einlage' ? 'text-green-600' : 'text-red-600'}>
|
|
||||||
{transaction.type === 'einlage' ? '+' : '-'}{formatCurrency(transaction.amount)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5">
|
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
onClick={() => openEdit(transaction)}
|
|
||||||
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
|
|
||||||
title="Bearbeiten"
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(transaction.id)}
|
|
||||||
disabled={deleting === transaction.id}
|
|
||||||
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
|
|
||||||
title="Löschen"
|
|
||||||
>
|
|
||||||
{deleting === transaction.id ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-slate-100">
|
||||||
</table>
|
{sortedTransactions
|
||||||
</div>
|
.filter((t) => t.account === 'kasse')
|
||||||
</Card>
|
.map((transaction) => (
|
||||||
|
<tr key={transaction.id} className="hover:bg-slate-50/60 group">
|
||||||
|
<td className="px-3 py-2.5 text-slate-600 whitespace-nowrap">
|
||||||
|
{transaction.date}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-slate-600 whitespace-nowrap">
|
||||||
|
<Badge variant={transaction.type === 'einlage' ? 'default' : 'destructive'}>
|
||||||
|
{transaction.type === 'einlage' ? 'Einlage' : 'Entnahme'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-slate-700">
|
||||||
|
{transaction.description}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-slate-600 text-xs">
|
||||||
|
{transaction.kategorie || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right font-medium whitespace-nowrap">
|
||||||
|
<span className={transaction.type === 'einlage' ? 'text-green-600' : 'text-red-600'}>
|
||||||
|
{transaction.type === 'einlage' ? '+' : '-'}{formatCurrency(transaction.amount)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
{transaction.isBusinessRecord ? (
|
||||||
|
<span className="text-xs text-gray-500 font-medium">
|
||||||
|
Automatisch
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(transaction)}
|
||||||
|
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(transaction.id)}
|
||||||
|
disabled={deleting === transaction.id}
|
||||||
|
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
{deleting === transaction.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Bank Tabelle */}
|
||||||
|
<Card>
|
||||||
|
<div className="px-4 py-3 border-b border-slate-200 bg-slate-50">
|
||||||
|
<h3 className="font-semibold text-slate-700">Bank</h3>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 bg-slate-50">
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
Datum
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
Typ
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
Beschreibung
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
Kategorie
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
Betrag
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2.5 w-16" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{sortedTransactions
|
||||||
|
.filter((t) => t.account === 'bank')
|
||||||
|
.map((transaction) => (
|
||||||
|
<tr key={transaction.id} className="hover:bg-slate-50/60 group">
|
||||||
|
<td className="px-3 py-2.5 text-slate-600 whitespace-nowrap">
|
||||||
|
{transaction.date}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-slate-600 whitespace-nowrap">
|
||||||
|
<Badge variant={transaction.type === 'einlage' ? 'default' : 'destructive'}>
|
||||||
|
{transaction.type === 'einlage' ? 'Einlage' : 'Entnahme'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-slate-700">
|
||||||
|
{transaction.description}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-slate-600 text-xs">
|
||||||
|
{transaction.kategorie || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right font-medium whitespace-nowrap">
|
||||||
|
<span className={transaction.type === 'einlage' ? 'text-green-600' : 'text-red-600'}>
|
||||||
|
{transaction.type === 'einlage' ? '+' : '-'}{formatCurrency(transaction.amount)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
{transaction.isBusinessRecord ? (
|
||||||
|
<span className="text-xs text-gray-500 font-medium">
|
||||||
|
Automatisch
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(transaction)}
|
||||||
|
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(transaction.id)}
|
||||||
|
disabled={deleting === transaction.id}
|
||||||
|
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
{deleting === transaction.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dialog: Anlegen / Bearbeiten */}
|
{/* Dialog: Anlegen / Bearbeiten */}
|
||||||
@@ -297,33 +453,61 @@ export default function CompanyMoney() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{isUmbuchung ? (
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<>
|
||||||
Konto <span className="text-red-500">*</span>
|
<div>
|
||||||
</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
<select
|
Von (Konto) <span className="text-red-500">*</span>
|
||||||
value={form.account}
|
</label>
|
||||||
onChange={(e) => setForm((f) => ({ ...f, account: e.target.value as 'kasse' | 'bank' }))}
|
<select
|
||||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
value={form.account}
|
||||||
>
|
onChange={(e) => setForm((f) => ({ ...f, account: e.target.value as 'kasse' | 'bank', toAccount: e.target.value === 'kasse' ? 'bank' : 'kasse' }))}
|
||||||
<option value="kasse">Kasse</option>
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
<option value="bank">Bank</option>
|
>
|
||||||
</select>
|
<option value="kasse">Kasse</option>
|
||||||
</div>
|
<option value="bank">Bank</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Nach (Konto)
|
||||||
|
</label>
|
||||||
|
<div className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm bg-gray-50">
|
||||||
|
{form.toAccount === 'kasse' ? 'Kasse' : 'Bank'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Konto <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.account}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, account: e.target.value as 'kasse' | 'bank' }))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="kasse">Kasse</option>
|
||||||
|
<option value="bank">Bank</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Typ <span className="text-red-500">*</span>
|
Typ <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={form.type}
|
value={form.type}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as 'einlage' | 'entnahme' }))}
|
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as 'einlage' | 'entnahme' }))}
|
||||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
>
|
>
|
||||||
<option value="einlage">Einlage</option>
|
<option value="einlage">Einnahme (Einlage)</option>
|
||||||
<option value="entnahme">Entnahme</option>
|
<option value="entnahme">Ausgabe (Entnahme)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
@@ -360,7 +544,7 @@ export default function CompanyMoney() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={saving || !formValid}>
|
<Button onClick={handleSave} disabled={saving || !formValid}>
|
||||||
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{editingTransaction ? "Speichern" : "Hinzufügen"}
|
{editingTransaction ? "Speichern" : isUmbuchung ? "Umbuchung durchführen" : "Hinzufügen"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
Generated
-1
@@ -1311,7 +1311,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
|
||||||
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
|
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.13"
|
"node": ">=16.13"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Add source linking fields to Buchung model
|
||||||
|
ALTER TABLE `buchungen` ADD COLUMN `betriebseinnahmeId` VARCHAR(191) NULL;
|
||||||
|
ALTER TABLE `buchungen` ADD COLUMN `betriebsausgabeId` VARCHAR(191) NULL;
|
||||||
|
ALTER TABLE `buchungen` ADD COLUMN `linkedBuchungId` VARCHAR(191) NULL;
|
||||||
|
|
||||||
|
-- Add unique constraints for 1:1 relations
|
||||||
|
ALTER TABLE `buchungen` ADD UNIQUE INDEX `buchungen_betriebseinnahmeId_key`(`betriebseinnahmeId`);
|
||||||
|
ALTER TABLE `buchungen` ADD UNIQUE INDEX `buchungen_betriebsausgabeId_key`(`betriebsausgabeId`);
|
||||||
|
|
||||||
|
-- Add foreign key constraints
|
||||||
|
ALTER TABLE `buchungen` ADD CONSTRAINT `buchungen_betriebseinnahmeId_fkey` FOREIGN KEY (`betriebseinnahmeId`) REFERENCES `betriebseinnahmen`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `buchungen` ADD CONSTRAINT `buchungen_betriebsausgabeId_fkey` FOREIGN KEY (`betriebsausgabeId`) REFERENCES `betriebsausgaben`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `buchungen` ADD CONSTRAINT `buchungen_linkedBuchungId_fkey` FOREIGN KEY (`linkedBuchungId`) REFERENCES `buchungen`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Add indexes for linked fields
|
||||||
|
ALTER TABLE `buchungen` ADD INDEX `buchungen_betriebsausgabeId_idx`(`betriebsausgabeId`);
|
||||||
|
ALTER TABLE `buchungen` ADD INDEX `buchungen_linkedBuchungId_idx`(`linkedBuchungId`);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Add buchungId field to invoices table
|
||||||
|
ALTER TABLE `invoices` ADD COLUMN `buchungId` VARCHAR(191) NULL;
|
||||||
|
|
||||||
|
-- Create unique index for the foreign key
|
||||||
|
ALTER TABLE `invoices` ADD UNIQUE INDEX `invoices_buchungId_key`(`buchungId`);
|
||||||
|
|
||||||
|
-- Add foreign key constraint
|
||||||
|
ALTER TABLE `invoices` ADD CONSTRAINT `invoices_buchungId_fkey`
|
||||||
|
FOREIGN KEY (`buchungId`) REFERENCES `buchungen`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- CreateTable for BuchungKategorie
|
||||||
|
CREATE TABLE `buchung_kategorien` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`companyId` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`typ` VARCHAR(191) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `buchung_kategorien_companyId_idx`(`companyId`),
|
||||||
|
UNIQUE INDEX `buchung_kategorien_companyId_name_typ_key`(`companyId`, `name`, `typ`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey for BuchungKategorie
|
||||||
|
ALTER TABLE `buchung_kategorien` ADD CONSTRAINT `buchung_kategorien_companyId_fkey` FOREIGN KEY (`companyId`) REFERENCES `companies`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AlterTable `buchungen` - add new columns
|
||||||
|
ALTER TABLE `buchungen` ADD COLUMN `kategorie` VARCHAR(191) NULL;
|
||||||
|
ALTER TABLE `buchungen` ADD COLUMN `steuersatz` INT NULL;
|
||||||
|
ALTER TABLE `buchungen` ADD COLUMN `zahlungsart` ENUM('KASSE', 'BANK') NULL;
|
||||||
|
ALTER TABLE `buchungen` ADD COLUMN `isBusinessRecord` BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Add index for isBusinessRecord
|
||||||
|
ALTER TABLE `buchungen` ADD INDEX `buchungen_isBusinessRecord_idx`(`isBusinessRecord`);
|
||||||
|
|
||||||
|
-- Migrate existing data from betriebseinnahmen/betriebsausgaben to Buchung
|
||||||
|
-- This is handled by the post-migration script
|
||||||
|
|
||||||
|
-- AlterTable `betriebseinnahmen` - change kategorie from Enum to String
|
||||||
|
ALTER TABLE `betriebseinnahmen` MODIFY `kategorie` VARCHAR(191) NOT NULL;
|
||||||
|
ALTER TABLE `betriebseinnahmen` MODIFY `steuersatz` INT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- AlterTable `betriebsausgaben` - change kategorie from Enum to String
|
||||||
|
ALTER TABLE `betriebsausgaben` MODIFY `kategorie` VARCHAR(191) NOT NULL;
|
||||||
|
ALTER TABLE `betriebsausgaben` MODIFY `steuersatz` INT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Drop old foreign key constraints from buchungen (if they exist from previous migration)
|
||||||
|
-- These will be re-added if needed, but for now we're consolidating
|
||||||
|
|
||||||
|
-- Note: The enum columns EinnahmeKategorie and AusgabeKategorie are automatically
|
||||||
|
-- dropped by Prisma when they're no longer referenced in the schema.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
-- Migration: Drop legacy betriebseinnahmen and betriebsausgaben tables
|
||||||
|
-- This consolidates all transaction data into the buchungen table
|
||||||
|
|
||||||
|
-- STEP 1: Copy existing betriebseinnahmen into buchungen
|
||||||
|
-- Only copy those that don't already have a linked Buchung (via betriebseinnahmeId)
|
||||||
|
INSERT INTO `buchungen` (id, companyId, date, account, type, amount, description,
|
||||||
|
kategorie, steuersatz, zahlungsart, isBusinessRecord, createdAt, updatedAt)
|
||||||
|
SELECT
|
||||||
|
CONCAT('migr-ein-', e.id),
|
||||||
|
e.companyId,
|
||||||
|
e.datum,
|
||||||
|
e.zahlungsart,
|
||||||
|
'EINLAGE',
|
||||||
|
e.betrag,
|
||||||
|
e.beschreibung,
|
||||||
|
e.kategorie,
|
||||||
|
e.steuersatz,
|
||||||
|
e.zahlungsart,
|
||||||
|
true,
|
||||||
|
e.createdAt,
|
||||||
|
e.updatedAt
|
||||||
|
FROM `betriebseinnahmen` e
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM `buchungen` b WHERE b.betriebseinnahmeId = e.id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- STEP 2: Copy existing betriebsausgaben into buchungen
|
||||||
|
INSERT INTO `buchungen` (id, companyId, date, account, type, amount, description,
|
||||||
|
kategorie, steuersatz, zahlungsart, isBusinessRecord, createdAt, updatedAt)
|
||||||
|
SELECT
|
||||||
|
CONCAT('migr-aus-', a.id),
|
||||||
|
a.companyId,
|
||||||
|
a.datum,
|
||||||
|
a.zahlungsart,
|
||||||
|
'ENTNAHME',
|
||||||
|
a.betrag,
|
||||||
|
a.beschreibung,
|
||||||
|
a.kategorie,
|
||||||
|
a.steuersatz,
|
||||||
|
a.zahlungsart,
|
||||||
|
true,
|
||||||
|
a.createdAt,
|
||||||
|
a.updatedAt
|
||||||
|
FROM `betriebsausgaben` a
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM `buchungen` b WHERE b.betriebsausgabeId = a.id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- STEP 3: Remove FK constraints before dropping columns/tables
|
||||||
|
ALTER TABLE `buchungen` DROP FOREIGN KEY `buchungen_betriebseinnahmeId_fkey`;
|
||||||
|
ALTER TABLE `buchungen` DROP FOREIGN KEY `buchungen_betriebsausgabeId_fkey`;
|
||||||
|
|
||||||
|
-- STEP 4: Remove old linking columns from buchungen
|
||||||
|
ALTER TABLE `buchungen` DROP INDEX `buchungen_betriebseinnahmeId_key`;
|
||||||
|
ALTER TABLE `buchungen` DROP INDEX `buchungen_betriebsausgabeId_key`;
|
||||||
|
ALTER TABLE `buchungen` DROP COLUMN `betriebseinnahmeId`;
|
||||||
|
ALTER TABLE `buchungen` DROP COLUMN `betriebsausgabeId`;
|
||||||
|
|
||||||
|
-- STEP 5: Drop old tables
|
||||||
|
DROP TABLE `betriebsausgaben`;
|
||||||
|
DROP TABLE `betriebseinnahmen`;
|
||||||
+83
-127
@@ -45,41 +45,53 @@ model AuditLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Company {
|
model Company {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
legalForm String?
|
legalForm String?
|
||||||
taxId String?
|
taxId String?
|
||||||
vatId String?
|
vatId String?
|
||||||
address String
|
address String
|
||||||
zip String
|
zip String
|
||||||
city String
|
city String
|
||||||
country String @default("DE")
|
country String @default("DE")
|
||||||
email String?
|
email String?
|
||||||
phone String?
|
phone String?
|
||||||
website String?
|
website String?
|
||||||
bankIban String?
|
bankIban String?
|
||||||
bankBic String?
|
bankBic String?
|
||||||
bankName String?
|
bankName String?
|
||||||
invoicePrefix String @default("RE")
|
invoicePrefix String @default("RE")
|
||||||
invoiceSequence Int @default(0)
|
invoiceSequence Int @default(0)
|
||||||
kleinunternehmer Boolean @default(false)
|
kleinunternehmer Boolean @default(false)
|
||||||
archived Boolean @default(false)
|
archived Boolean @default(false)
|
||||||
archivedAt DateTime?
|
archivedAt DateTime?
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
customers Customer[]
|
customers Customer[]
|
||||||
invoices Invoice[]
|
invoices Invoice[]
|
||||||
services Service[]
|
services Service[]
|
||||||
betriebsausgaben Betriebsausgabe[]
|
anlagegueter Anlagegut[]
|
||||||
betriebseinnahmen Betriebseinnahme[]
|
buchungen Buchung[]
|
||||||
anlagegueter Anlagegut[]
|
kategorien BuchungKategorie[]
|
||||||
buchungen Buchung[]
|
createdAt DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
updatedAt DateTime @updatedAt
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@map("companies")
|
@@map("companies")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model BuchungKategorie {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
companyId String
|
||||||
|
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||||
|
name String // e.g., "Fußpflege", "Miete", "Privateinlagen"
|
||||||
|
typ String // "EINNAHME" or "AUSGABE"
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([companyId, name, typ])
|
||||||
|
@@index([companyId])
|
||||||
|
@@map("buchung_kategorien")
|
||||||
|
}
|
||||||
|
|
||||||
enum TransactionAccount {
|
enum TransactionAccount {
|
||||||
KASSE
|
KASSE
|
||||||
BANK
|
BANK
|
||||||
@@ -91,19 +103,28 @@ enum TransactionType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Buchung {
|
model Buchung {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
companyId String
|
companyId String
|
||||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||||
date DateTime
|
date DateTime
|
||||||
account TransactionAccount
|
account TransactionAccount
|
||||||
type TransactionType
|
type TransactionType
|
||||||
amount Decimal @db.Decimal(10, 2)
|
amount Decimal @db.Decimal(10, 2)
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
createdAt DateTime @default(now())
|
kategorie String? // Name of BuchungKategorie (nullable for manual transactions)
|
||||||
updatedAt DateTime @updatedAt
|
steuersatz Int? // Tax rate: 0, 7, 19 (nullable for non-business records)
|
||||||
|
zahlungsart Zahlungsart? // KASSE or BANK
|
||||||
|
isBusinessRecord Boolean @default(false) // True if this is from Einnahme/Ausgabe
|
||||||
|
linkedBuchungId String?
|
||||||
|
linkedBuchung Buchung? @relation("BuchungLink", fields: [linkedBuchungId], references: [id], onDelete: SetNull)
|
||||||
|
linkedFrom Buchung[] @relation("BuchungLink")
|
||||||
|
invoice Invoice? // Back-relation: Invoice -> Buchung (via buchungId)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([companyId])
|
@@index([companyId])
|
||||||
@@index([date])
|
@@index([date])
|
||||||
|
@@index([isBusinessRecord])
|
||||||
@@map("buchungen")
|
@@map("buchungen")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,25 +163,27 @@ model Customer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Invoice {
|
model Invoice {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
number String?
|
number String?
|
||||||
companyId String
|
companyId String
|
||||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||||
customerId String
|
customerId String
|
||||||
customer Customer @relation(fields: [customerId], references: [id])
|
customer Customer @relation(fields: [customerId], references: [id])
|
||||||
issueDate DateTime
|
issueDate DateTime
|
||||||
deliveryDate DateTime?
|
deliveryDate DateTime?
|
||||||
dueDate DateTime
|
dueDate DateTime
|
||||||
status InvoiceStatus @default(DRAFT)
|
status InvoiceStatus @default(DRAFT)
|
||||||
kleinunternehmer Boolean @default(false)
|
kleinunternehmer Boolean @default(false)
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
items InvoiceItem[]
|
items InvoiceItem[]
|
||||||
netTotal Decimal @db.Decimal(10, 2)
|
netTotal Decimal @db.Decimal(10, 2)
|
||||||
taxTotal Decimal @db.Decimal(10, 2)
|
taxTotal Decimal @db.Decimal(10, 2)
|
||||||
grossTotal Decimal @db.Decimal(10, 2)
|
grossTotal Decimal @db.Decimal(10, 2)
|
||||||
createdAt DateTime @default(now())
|
buchungId String? @unique // Link to auto-created Buchung when PAID
|
||||||
updatedAt DateTime @updatedAt
|
buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull)
|
||||||
deletedAt DateTime?
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
@@unique([companyId, number])
|
@@unique([companyId, number])
|
||||||
@@map("invoices")
|
@@map("invoices")
|
||||||
@@ -179,36 +202,6 @@ enum Zahlungsart {
|
|||||||
BANK
|
BANK
|
||||||
}
|
}
|
||||||
|
|
||||||
enum EinnahmeKategorie {
|
|
||||||
FUSSPFLEGE
|
|
||||||
PRIVATEINLAGEN
|
|
||||||
DARLEHEN
|
|
||||||
STEUERERSTATTUNGEN
|
|
||||||
VERSICHERUNGSERSTATTUNGEN
|
|
||||||
ZINSERTRAEGE
|
|
||||||
VERMIETUNG_VERPACHTUNG
|
|
||||||
VERAEUSSERUNGSERLOES
|
|
||||||
EIGENVERBRAUCH
|
|
||||||
SONSTIGE_EINNAHMEN
|
|
||||||
}
|
|
||||||
|
|
||||||
model Betriebseinnahme {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
companyId String
|
|
||||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
|
||||||
kategorie EinnahmeKategorie
|
|
||||||
betrag Decimal @db.Decimal(10, 2)
|
|
||||||
steuersatz Decimal @db.Decimal(5, 2) @default(0)
|
|
||||||
zahlungsart Zahlungsart @default(BANK)
|
|
||||||
datum DateTime
|
|
||||||
beschreibung String? @db.Text
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@index([companyId])
|
|
||||||
@@index([datum])
|
|
||||||
@@map("betriebseinnahmen")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Anlagegut {
|
model Anlagegut {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -228,43 +221,6 @@ model Anlagegut {
|
|||||||
@@map("anlagegueter")
|
@@map("anlagegueter")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AusgabeKategorie {
|
|
||||||
WAREN_ROHSTOFFE
|
|
||||||
GERINGWERTIGE_WIRTSCHAFTSGUETER
|
|
||||||
ABSCHREIBUNGEN
|
|
||||||
MIETE
|
|
||||||
STROM_WASSER
|
|
||||||
TELEKOMMUNIKATION
|
|
||||||
FORTBILDUNG_MESSEN
|
|
||||||
BEITRAEGE
|
|
||||||
VERSICHERUNGEN
|
|
||||||
WERBEKOSTEN
|
|
||||||
ZINSEN
|
|
||||||
REISEKOSTEN
|
|
||||||
REPARATUREN_INSTANDHALTUNG
|
|
||||||
BUEROBEDARF
|
|
||||||
REPRAESENTATIONSKOSTEN
|
|
||||||
SONSTIGER_BETRIEBSBEDARF
|
|
||||||
NEBENKOSTEN_GELDVERKEHR
|
|
||||||
}
|
|
||||||
|
|
||||||
model Betriebsausgabe {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
companyId String
|
|
||||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
|
||||||
kategorie AusgabeKategorie
|
|
||||||
betrag Decimal @db.Decimal(10, 2)
|
|
||||||
steuersatz Decimal @db.Decimal(5, 2) @default(0)
|
|
||||||
zahlungsart Zahlungsart @default(BANK)
|
|
||||||
datum DateTime
|
|
||||||
beschreibung String? @db.Text
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@index([companyId])
|
|
||||||
@@index([datum])
|
|
||||||
@@map("betriebsausgaben")
|
|
||||||
}
|
|
||||||
|
|
||||||
model InvoiceItem {
|
model InvoiceItem {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|||||||
+53
-1
@@ -136,8 +136,60 @@ async function main() {
|
|||||||
data: { invoiceSequence: 1 },
|
data: { invoiceSequence: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Seed BuchungKategorien for demo company
|
||||||
|
const einnahmeKategorien = [
|
||||||
|
"Fußpflege",
|
||||||
|
"Privateinlagen",
|
||||||
|
"Darlehen",
|
||||||
|
"Steuererstattungen",
|
||||||
|
"Versicherungserstattungen",
|
||||||
|
"Zinsertrage",
|
||||||
|
"Vermietung/Verpachtung",
|
||||||
|
"Veräußerungserlös",
|
||||||
|
"Eigenverbrauch",
|
||||||
|
"Sonstige Einnahmen",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ausgabeKategorien = [
|
||||||
|
"Waren und Rohstoffe",
|
||||||
|
"Geringwertige Wirtschaftsgüter",
|
||||||
|
"Abschreibungen",
|
||||||
|
"Miete",
|
||||||
|
"Strom und Wasser",
|
||||||
|
"Telekommunikation",
|
||||||
|
"Fortbildung und Messen",
|
||||||
|
"Beiträge",
|
||||||
|
"Versicherungen",
|
||||||
|
"Werbekosten",
|
||||||
|
"Zinsen",
|
||||||
|
"Reisekosten",
|
||||||
|
"Reparaturen und Instandhaltung",
|
||||||
|
"Bürobedarf",
|
||||||
|
"Repräsentationskosten",
|
||||||
|
"Sonstiger Betriebsbedarf",
|
||||||
|
"Nebenkosten Geldverkehr",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of einnahmeKategorien) {
|
||||||
|
await prisma.buchungKategorie.upsert({
|
||||||
|
where: { companyId_name_typ: { companyId: company.id, name, typ: "EINNAHME" } },
|
||||||
|
update: {},
|
||||||
|
create: { companyId: company.id, name, typ: "EINNAHME" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`✓ Seeded ${einnahmeKategorien.length} Einnahme categories`);
|
||||||
|
|
||||||
|
for (const name of ausgabeKategorien) {
|
||||||
|
await prisma.buchungKategorie.upsert({
|
||||||
|
where: { companyId_name_typ: { companyId: company.id, name, typ: "AUSGABE" } },
|
||||||
|
update: {},
|
||||||
|
create: { companyId: company.id, name, typ: "AUSGABE" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`✓ Seeded ${ausgabeKategorien.length} Ausgabe categories`);
|
||||||
|
|
||||||
console.log("\n✅ Seed complete!");
|
console.log("\n✅ Seed complete!");
|
||||||
console.log("Login: anna@example.de / demo123");
|
console.log("Login: anna@example.de / annas_password");
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user