diff --git a/backend/prisma/migrations/20260806120000_company_profile/migration.sql b/backend/prisma/migrations/20260806120000_company_profile/migration.sql new file mode 100644 index 00000000..d9662aa9 --- /dev/null +++ b/backend/prisma/migrations/20260806120000_company_profile/migration.sql @@ -0,0 +1,23 @@ +-- Absender-/Firmenstammdaten (Einzel-Zeile) fuer Gutschrift-PDF + ZUGFeRD. +CREATE TABLE IF NOT EXISTS `CompanyProfile` ( + `id` INT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(191) NOT NULL DEFAULT '', + `street` VARCHAR(191) NOT NULL DEFAULT '', + `houseNumber` VARCHAR(191) NOT NULL DEFAULT '', + `postalCode` VARCHAR(191) NOT NULL DEFAULT '', + `city` VARCHAR(191) NOT NULL DEFAULT '', + `country` VARCHAR(191) NOT NULL DEFAULT 'DE', + `vatId` VARCHAR(191) NOT NULL DEFAULT '', + `taxNumber` VARCHAR(191) NOT NULL DEFAULT '', + `commercialRegister` VARCHAR(191) NOT NULL DEFAULT '', + `managingDirector` VARCHAR(191) NOT NULL DEFAULT '', + `email` VARCHAR(191) NOT NULL DEFAULT '', + `phone` VARCHAR(191) NOT NULL DEFAULT '', + `website` VARCHAR(191) NOT NULL DEFAULT '', + `iban` VARCHAR(191) NOT NULL DEFAULT '', + `bic` VARCHAR(191) NOT NULL DEFAULT '', + `bankName` VARCHAR(191) NOT NULL DEFAULT '', + `logoPath` VARCHAR(191) NULL, + `updatedAt` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c3a83fb6..a1b41428 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1021,6 +1021,30 @@ model CreditNote { @@index([contractId]) } +// Absender-/Firmenstammdaten (Einzel-Zeile). Fließen in Gutschrift-PDF und +// ZUGFeRD-XML (Verkäufer/Seller-Party) sowie in die Bankangaben ein. +model CompanyProfile { + id Int @id @default(autoincrement()) + name String @default("") + street String @default("") + houseNumber String @default("") + postalCode String @default("") + city String @default("") + country String @default("DE") // ISO-2, für ZUGFeRD countryID + vatId String @default("") // USt-IdNr (DE...) + taxNumber String @default("") // Steuernummer + commercialRegister String @default("") // z.B. HRB 12345, Amtsgericht + managingDirector String @default("") // Geschäftsführer/Inhaber + email String @default("") + phone String @default("") + website String @default("") + iban String @default("") + bic String @default("") + bankName String @default("") + logoPath String? + updatedAt DateTime @updatedAt +} + // Nummernkreis für Gutschriften (Einzel-Zeile, in den Einstellungen // verwaltbar). Nummer wird transaktional vergeben, damit keine Lücken/ // Doppelvergaben entstehen. diff --git a/backend/src/controllers/companyProfile.controller.ts b/backend/src/controllers/companyProfile.controller.ts new file mode 100644 index 00000000..b91cc47c --- /dev/null +++ b/backend/src/controllers/companyProfile.controller.ts @@ -0,0 +1,29 @@ +import { Response } from 'express'; +import { ApiResponse, AuthRequest } from '../types/index.js'; +import { logChange } from '../services/audit.service.js'; +import * as companyProfileService from '../services/companyProfile.service.js'; + +export async function getProfile(_req: AuthRequest, res: Response): Promise { + try { + const data = await companyProfileService.getOrCreateProfile(); + res.json({ success: true, data } as ApiResponse); + } catch (error) { + res.status(500).json({ success: false, error: 'Fehler beim Laden der Firmendaten' } as ApiResponse); + } +} + +export async function updateProfile(req: AuthRequest, res: Response): Promise { + try { + const updated = await companyProfileService.updateProfile(req.body ?? {}); + await logChange({ + req, + action: 'UPDATE', + resourceType: 'CompanyProfile', + resourceId: updated.id.toString(), + label: 'Firmenstammdaten (Absender) geändert', + }); + res.json({ success: true, data: updated } as ApiResponse); + } catch (error) { + res.status(500).json({ success: false, error: 'Fehler beim Speichern der Firmendaten' } as ApiResponse); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index a86e727b..e8a0adad 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -39,6 +39,7 @@ import meterRoutes from './routes/meter.routes.js'; import stressfreiEmailRoutes from './routes/stressfreiEmail.routes.js'; import contractRoutes from './routes/contract.routes.js'; import creditNoteRoutes from './routes/creditNote.routes.js'; +import companyProfileRoutes from './routes/companyProfile.routes.js'; import platformRoutes from './routes/platform.routes.js'; import cancellationPeriodRoutes from './routes/cancellation-period.routes.js'; import contractDurationRoutes from './routes/contract-duration.routes.js'; @@ -357,6 +358,7 @@ app.use('/api/meters', meterRoutes); app.use('/api/stressfrei-emails', stressfreiEmailRoutes); app.use('/api/contracts', contractRoutes); app.use('/api/credit-notes', creditNoteRoutes); +app.use('/api/company-profile', companyProfileRoutes); app.use('/api/platforms', platformRoutes); app.use('/api/cancellation-periods', cancellationPeriodRoutes); app.use('/api/contract-durations', contractDurationRoutes); diff --git a/backend/src/routes/companyProfile.routes.ts b/backend/src/routes/companyProfile.routes.ts new file mode 100644 index 00000000..8fa3ddce --- /dev/null +++ b/backend/src/routes/companyProfile.routes.ts @@ -0,0 +1,10 @@ +import { Router } from 'express'; +import * as companyProfileController from '../controllers/companyProfile.controller.js'; +import { authenticate, requirePermission } from '../middleware/auth.js'; + +const router = Router(); + +router.get('/', authenticate, requirePermission('settings:read'), companyProfileController.getProfile); +router.put('/', authenticate, requirePermission('settings:update'), companyProfileController.updateProfile); + +export default router; diff --git a/backend/src/services/companyProfile.service.ts b/backend/src/services/companyProfile.service.ts new file mode 100644 index 00000000..85910f24 --- /dev/null +++ b/backend/src/services/companyProfile.service.ts @@ -0,0 +1,28 @@ +// ==================== FIRMENSTAMMDATEN (ABSENDER) ==================== +// Einzel-Zeile. Fließt in Gutschrift-PDF + ZUGFeRD-XML (Verkäufer) ein. + +import prisma from '../lib/prisma.js'; +import { Prisma } from '@prisma/client'; + +const EDITABLE_STRING_FIELDS = [ + 'name', 'street', 'houseNumber', 'postalCode', 'city', 'country', + 'vatId', 'taxNumber', 'commercialRegister', 'managingDirector', + 'email', 'phone', 'website', 'iban', 'bic', 'bankName', +] as const; + +export async function getOrCreateProfile() { + const existing = await prisma.companyProfile.findFirst(); + if (existing) return existing; + return prisma.companyProfile.create({ data: {} }); +} + +export async function updateProfile(input: Record) { + const profile = await getOrCreateProfile(); + const data: Prisma.CompanyProfileUpdateInput = {}; + for (const field of EDITABLE_STRING_FIELDS) { + if (typeof input[field] === 'string') { + (data as Record)[field] = (input[field] as string).slice(0, 191).trim(); + } + } + return prisma.companyProfile.update({ where: { id: profile.id }, data }); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f85ac3a1..11beccdc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ import ContractDurationList from './pages/settings/ContractDurationList'; import ProviderList from './pages/settings/ProviderList'; import ContractCategoryList from './pages/settings/ContractCategoryList'; import CreditNoteNumberRange from './pages/settings/CreditNoteNumberRange'; +import CompanyProfileSettings from './pages/settings/CompanyProfileSettings'; import ViewSettings from './pages/settings/ViewSettings'; import PortalSettings from './pages/settings/PortalSettings'; import DeadlineSettings from './pages/settings/DeadlineSettings'; @@ -224,6 +225,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index dfbb6f1a..1a057f38 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -56,6 +56,13 @@ export default function Settings() { description: 'Präfix, Jahr und Zähler für die fortlaufende Nummerierung von Gutschriften.', show: hasPermission('settings:read'), }, + { + to: '/settings/company-profile', + icon: Building2, + title: 'Firmenstammdaten (Absender)', + description: 'Absenderdaten für Gutschrift-PDF und ZUGFeRD (Name, Anschrift, USt-IdNr, Bank).', + show: hasPermission('settings:read'), + }, ]; return ( diff --git a/frontend/src/pages/settings/CompanyProfileSettings.tsx b/frontend/src/pages/settings/CompanyProfileSettings.tsx new file mode 100644 index 00000000..05130a3f --- /dev/null +++ b/frontend/src/pages/settings/CompanyProfileSettings.tsx @@ -0,0 +1,120 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { ArrowLeft, Building2 } from 'lucide-react'; +import toast from 'react-hot-toast'; +import Button from '../../components/ui/Button'; +import Input from '../../components/ui/Input'; +import { companyProfileApi } from '../../services/api'; +import type { CompanyProfile } from '../../types'; +import { useAuth } from '../../context/AuthContext'; + +type Form = Omit; + +const EMPTY: Form = { + name: '', street: '', houseNumber: '', postalCode: '', city: '', country: 'DE', + vatId: '', taxNumber: '', commercialRegister: '', managingDirector: '', + email: '', phone: '', website: '', iban: '', bic: '', bankName: '', +}; + +export default function CompanyProfileSettings() { + const { hasPermission } = useAuth(); + const canEdit = hasPermission('settings:update'); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ['company-profile'], + queryFn: () => companyProfileApi.get(), + }); + + const [form, setForm] = useState
(EMPTY); + const set = (k: K, v: Form[K]) => setForm((f) => ({ ...f, [k]: v })); + + useEffect(() => { + if (data?.data) { + const { id, logoPath, ...rest } = data.data; + void id; void logoPath; + setForm({ ...EMPTY, ...rest }); + } + }, [data]); + + const saveMutation = useMutation({ + mutationFn: () => companyProfileApi.update(form), + onSuccess: () => { + toast.success('Firmendaten gespeichert'); + queryClient.invalidateQueries({ queryKey: ['company-profile'] }); + }, + onError: (err: Error) => toast.error(err.message || 'Speichern fehlgeschlagen'), + }); + + if (isLoading) return
Laden …
; + + const field = (label: string, key: keyof Form, placeholder?: string) => ( + set(key, e.target.value)} disabled={!canEdit} placeholder={placeholder} /> + ); + + return ( +
+ + Einstellungen + +

+ Firmenstammdaten (Absender) +

+

+ Diese Daten erscheinen als Absender auf Gutschrift-PDFs und in der ZUGFeRD-E-Rechnung. +

+ +
+
+

Firma & Anschrift

+ {field('Firmenname', 'name', 'z.B. Hacker-Net Telekommunikation')} +
+
{field('Straße', 'street')}
+ {field('Hausnummer', 'houseNumber')} +
+
+ {field('PLZ', 'postalCode')} +
{field('Ort', 'city')}
+ {field('Land (ISO-2)', 'country', 'DE')} +
+ {field('Geschäftsführer/Inhaber', 'managingDirector')} +
+ +
+

Steuer & Register

+
+ {field('USt-IdNr', 'vatId', 'DE123456789')} + {field('Steuernummer', 'taxNumber')} +
+ {field('Handelsregister', 'commercialRegister', 'z.B. HRB 12345, Amtsgericht Oldenburg')} +

+ Für ZUGFeRD an Firmenkunden mit USt ist die USt-IdNr erforderlich. +

+
+ +
+

Kontakt & Bank

+
+ {field('E-Mail', 'email')} + {field('Telefon', 'phone')} +
+ {field('Website', 'website')} +
+ {field('IBAN', 'iban')} + {field('BIC', 'bic')} + {field('Bank', 'bankName')} +
+
+ + {canEdit && ( +
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 64d639c0..9040d245 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Invoice, Role, PortalSettings, CustomerRepresentative, CustomerSummary, CustomerReferrals, CreditNote, CreditNoteDefaults, CreditNoteNumberRange, ContractHistoryEntry, AuditLog, AuditSensitivity, AuditRetentionPolicy, CustomerConsent, ConsentType, ConsentStatus, DataDeletionRequest, DeletionRequestStatus, GDPRDashboardStats, RepresentativeAuthorization } from '../types'; +import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Invoice, Role, PortalSettings, CustomerRepresentative, CustomerSummary, CustomerReferrals, CreditNote, CreditNoteDefaults, CreditNoteNumberRange, CompanyProfile, ContractHistoryEntry, AuditLog, AuditSensitivity, AuditRetentionPolicy, CustomerConsent, ConsentType, ConsentStatus, DataDeletionRequest, DeletionRequestStatus, GDPRDashboardStats, RepresentativeAuthorization } from '../types'; // ============================================================================ // In-Memory-Token-Store @@ -263,6 +263,18 @@ export const referralApi = { }, }; +// Firmenstammdaten (Absender für Gutschrift-PDF/ZUGFeRD) +export const companyProfileApi = { + get: async () => { + const res = await api.get>('/company-profile'); + return res.data; + }, + update: async (payload: Partial) => { + const res = await api.put>('/company-profile', payload); + return res.data; + }, +}; + // Gutschriften (Subventionen am Vertrag) export const creditNoteApi = { listByContract: async (contractId: number) => { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7f504111..a939c510 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -107,6 +107,27 @@ export interface CreditNoteNumberRange { preview?: string; } +export interface CompanyProfile { + id: number; + name: string; + street: string; + houseNumber: string; + postalCode: string; + city: string; + country: string; + vatId: string; + taxNumber: string; + commercialRegister: string; + managingDirector: string; + email: string; + phone: string; + website: string; + iban: string; + bic: string; + bankName: string; + logoPath?: string | null; +} + export interface RepresentativeAuthorization { id: number; customerId: number;