feat: Implement combined quote PDF with AGBs and recurring pricing, utilizing shared PDF UI components.
This commit is contained in:
Binary file not shown.
Binary file not shown.
BIN
din_test_v2.pdf
BIN
din_test_v2.pdf
Binary file not shown.
@@ -1,167 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { renderToFile } from '@react-pdf/renderer';
|
||||
import { EstimationPDF } from '../src/components/EstimationPDF';
|
||||
import {
|
||||
PRICING,
|
||||
initialState,
|
||||
PAGE_SAMPLES,
|
||||
FEATURE_OPTIONS,
|
||||
FUNCTION_OPTIONS,
|
||||
API_OPTIONS,
|
||||
DESIGN_OPTIONS,
|
||||
DEADLINE_LABELS,
|
||||
ASSET_OPTIONS,
|
||||
EMPLOYEE_OPTIONS,
|
||||
calculatePositions,
|
||||
FormState
|
||||
} from '../src/logic/pricing';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { parseArgs } from 'util';
|
||||
import * as readline from 'readline/promises';
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
async function ask(question: string, defaultValue?: string): Promise<string> {
|
||||
const answer = await rl.question(`${question}${defaultValue ? ` [${defaultValue}]` : ''}: `);
|
||||
return answer.trim() || defaultValue || '';
|
||||
}
|
||||
|
||||
async function selectOne(question: string, options: { id: string; label: string }[], defaultValue?: string): Promise<string> {
|
||||
console.log(`\n${question}`);
|
||||
options.forEach((opt, i) => console.log(`${i + 1}. ${opt.label}`));
|
||||
const answer = await ask('Select number');
|
||||
const index = parseInt(answer) - 1;
|
||||
return options[index]?.id || defaultValue || options[0].id;
|
||||
}
|
||||
|
||||
async function selectMany(question: string, options: { id: string; label: string }[]): Promise<string[]> {
|
||||
console.log(`\n${question} (comma separated numbers, e.g. 1,2,4)`);
|
||||
options.forEach((opt, i) => console.log(`${i + 1}. ${opt.label}`));
|
||||
const answer = await ask('Selection');
|
||||
if (!answer) return [];
|
||||
return answer.split(',')
|
||||
.map(n => parseInt(n.trim()) - 1)
|
||||
.filter(i => options[i])
|
||||
.map(i => options[i].id);
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
input: { type: 'string', short: 'i' },
|
||||
output: { type: 'string', short: 'o' },
|
||||
interactive: { type: 'boolean', short: 'I', default: false },
|
||||
name: { type: 'string' },
|
||||
company: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
'project-type': { type: 'string' },
|
||||
},
|
||||
});
|
||||
|
||||
let state: FormState = { ...initialState };
|
||||
|
||||
// Apply command line flags first
|
||||
if (values.name) state.name = values.name as string;
|
||||
if (values.company) state.companyName = values.company as string;
|
||||
if (values.email) state.email = values.email as string;
|
||||
if (values['project-type']) state.projectType = values['project-type'] as 'website' | 'web-app';
|
||||
|
||||
if (values.input) {
|
||||
const inputPath = path.resolve(process.cwd(), values.input);
|
||||
if (fs.existsSync(inputPath)) {
|
||||
const fileData = JSON.parse(fs.readFileSync(inputPath, 'utf-8'));
|
||||
state = { ...state, ...fileData };
|
||||
}
|
||||
}
|
||||
|
||||
if (values.interactive) {
|
||||
console.log('--- Mintel Quote Generator Wizard ---');
|
||||
|
||||
state.name = await ask('Client Name', state.name);
|
||||
state.companyName = await ask('Company Name', state.companyName);
|
||||
state.email = await ask('Client Email', state.email);
|
||||
|
||||
state.projectType = (await selectOne('Project Type', [
|
||||
{ id: 'website', label: 'Website' },
|
||||
{ id: 'web-app', label: 'Web App' }
|
||||
], state.projectType)) as 'website' | 'web-app';
|
||||
|
||||
if (state.projectType === 'website') {
|
||||
state.websiteTopic = await ask('Website Topic', state.websiteTopic);
|
||||
state.selectedPages = await selectMany('Pages', PAGE_SAMPLES);
|
||||
state.features = await selectMany('Features', FEATURE_OPTIONS);
|
||||
state.functions = await selectMany('Functions', FUNCTION_OPTIONS);
|
||||
state.apiSystems = await selectMany('APIs', API_OPTIONS);
|
||||
|
||||
const cms = await ask('CMS Setup? (y/n)', state.cmsSetup ? 'y' : 'n');
|
||||
state.cmsSetup = cms.toLowerCase() === 'y';
|
||||
|
||||
const langs = await ask('Languages (comma separated)', state.languagesList.join(', '));
|
||||
state.languagesList = langs.split(',').map(l => l.trim()).filter(Boolean);
|
||||
} else {
|
||||
state.targetAudience = await selectOne('Target Audience', [
|
||||
{ id: 'internal', label: 'Internal Tool' },
|
||||
{ id: 'customers', label: 'Customer Portal' }
|
||||
], state.targetAudience);
|
||||
|
||||
state.platformType = await selectOne('Platform', [
|
||||
{ id: 'web-only', label: 'Web Only' },
|
||||
{ id: 'cross-platform', label: 'Cross Platform' }
|
||||
], state.platformType);
|
||||
|
||||
state.dataSensitivity = await selectOne('Data Sensitivity', [
|
||||
{ id: 'standard', label: 'Standard' },
|
||||
{ id: 'high', label: 'High / Sensitive' }
|
||||
], state.dataSensitivity);
|
||||
}
|
||||
|
||||
state.employeeCount = await selectOne('Company Size', EMPLOYEE_OPTIONS, state.employeeCount);
|
||||
state.designVibe = await selectOne('Design Vibe', DESIGN_OPTIONS, state.designVibe);
|
||||
state.deadline = await selectOne('Timeline', Object.entries(DEADLINE_LABELS).map(([id, label]) => ({ id, label })), state.deadline);
|
||||
state.assets = await selectMany('Available Assets', ASSET_OPTIONS);
|
||||
|
||||
state.message = await ask('Additional Message / Remarks', state.message);
|
||||
}
|
||||
|
||||
rl.close();
|
||||
|
||||
const totalPagesCount = state.selectedPages.length + state.otherPages.length + (state.otherPagesCount || 0);
|
||||
const positions = calculatePositions(state, PRICING);
|
||||
const totalPrice = positions.reduce((sum, p) => sum + p.price, 0);
|
||||
const monthlyPrice = PRICING.HOSTING_MONTHLY + (state.storageExpansion * PRICING.STORAGE_EXPANSION_MONTHLY);
|
||||
|
||||
const outputPath = values.output
|
||||
? path.resolve(process.cwd(), values.output)
|
||||
: path.resolve(process.cwd(), `quote_${state.companyName.replace(/\s+/g, '_') || 'client'}_${new Date().toISOString().split('T')[0]}.pdf`);
|
||||
|
||||
console.log(`\nGenerating PDF for ${state.companyName || state.name || 'Unknown Client'}...`);
|
||||
|
||||
const headerIcon = path.resolve(process.cwd(), 'src/assets/logo/Icon White Transparent.png');
|
||||
const footerLogo = path.resolve(process.cwd(), 'src/assets/logo/Logo Black Transparent.png');
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
await renderToFile(
|
||||
React.createElement(EstimationPDF, {
|
||||
state,
|
||||
totalPrice,
|
||||
monthlyPrice,
|
||||
totalPagesCount,
|
||||
pricing: PRICING,
|
||||
headerIcon,
|
||||
footerLogo,
|
||||
}),
|
||||
outputPath
|
||||
);
|
||||
console.log(`Successfully generated: ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
generate();
|
||||
154
src/components/AgbsPDF.tsx
Normal file
154
src/components/AgbsPDF.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Page as PDFPage,
|
||||
Text as PDFText,
|
||||
View as PDFView,
|
||||
StyleSheet as PDFStyleSheet,
|
||||
} from '@react-pdf/renderer';
|
||||
import { pdfStyles, Header, Footer, FoldingMarks, DocumentTitle } from './pdf/SharedUI';
|
||||
|
||||
const localStyles = PDFStyleSheet.create({
|
||||
sectionContainer: {
|
||||
marginTop: 0,
|
||||
},
|
||||
agbSection: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
labelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
marginBottom: 6,
|
||||
},
|
||||
monoNumber: {
|
||||
fontSize: 7,
|
||||
fontWeight: 'bold',
|
||||
color: '#94a3b8',
|
||||
letterSpacing: 2,
|
||||
width: 25,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 9,
|
||||
fontWeight: 'bold',
|
||||
color: '#000000',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
officialText: {
|
||||
fontSize: 8,
|
||||
lineHeight: 1.5,
|
||||
color: '#334155',
|
||||
textAlign: 'justify',
|
||||
paddingLeft: 25,
|
||||
}
|
||||
});
|
||||
|
||||
const AGBSection = ({ index, title, children }: { index: string; title: string; children: React.ReactNode }) => (
|
||||
<PDFView style={localStyles.agbSection} wrap={false}>
|
||||
<PDFView style={localStyles.labelRow}>
|
||||
<PDFText style={localStyles.monoNumber}>{index}</PDFText>
|
||||
<PDFText style={localStyles.sectionTitle}>{title}</PDFText>
|
||||
</PDFView>
|
||||
<PDFText style={localStyles.officialText}>{children}</PDFText>
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
interface AgbsPDFProps {
|
||||
state: any;
|
||||
headerIcon?: string;
|
||||
footerLogo?: string;
|
||||
}
|
||||
|
||||
export const AgbsPDF = ({ state, headerIcon, footerLogo }: AgbsPDFProps) => {
|
||||
const date = new Date().toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const companyData = {
|
||||
name: "Marc Mintel",
|
||||
address1: "Georg-Meistermann-Straße 7",
|
||||
address2: "54586 Schüller",
|
||||
ustId: "DE367588065"
|
||||
};
|
||||
|
||||
const bankData = {
|
||||
name: "N26",
|
||||
bic: "NTSBDEB1XXX",
|
||||
iban: "DE50 1001 1001 2620 4328 65"
|
||||
};
|
||||
|
||||
return (
|
||||
<PDFPage size="A4" style={pdfStyles.page}>
|
||||
<FoldingMarks />
|
||||
<Header icon={headerIcon} showAddress={false} />
|
||||
|
||||
<DocumentTitle
|
||||
title="Allgemeine Geschäftsbedingungen"
|
||||
subLines={[
|
||||
`Stand: ${date}`
|
||||
]}
|
||||
/>
|
||||
|
||||
<PDFView style={localStyles.sectionContainer}>
|
||||
<AGBSection
|
||||
index="01"
|
||||
title="Geltungsbereich"
|
||||
>
|
||||
Diese Allgemeinen Geschäftsbedingungen gelten für alle Verträge zwischen Marc Mintel (nachfolgend „Auftragnehmer“) und dem Auftraggeber. Abweichende Bedingungen des Auftraggebers werden nicht Vertragsbestandteil.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection
|
||||
index="02"
|
||||
title="Vertragsgegenstand"
|
||||
>
|
||||
Dienstleistungen in Webentwicklung, technischer Umsetzung und Hosting. Der Auftragnehmer schuldet eine fachgerechte technische Ausführung, jedoch keinen wirtschaftlichen Erfolg.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection
|
||||
index="03"
|
||||
title="Mitwirkungspflichten"
|
||||
>
|
||||
Der Auftraggeber stellt alle erforderlichen Inhalte und Zugänge rechtzeitig bereit. Verzögerungen durch fehlende Mitwirkung gehen zu Lasten der Projektlaufzeit.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection
|
||||
index="04"
|
||||
title="Abnahme"
|
||||
>
|
||||
Die Abnahme erfolgt durch produktive Nutzung oder Ablauf von 7 Tagen nach Projektabschluss. Subjektives Nichtgefallen stellt keinen technischen Mangel dar.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection
|
||||
index="05"
|
||||
title="Haftung"
|
||||
>
|
||||
Haftung besteht nur bei Vorsatz oder grober Fahrlässigkeit. Die Haftung für indirekte Schäden oder entgangenen Gewinn wird ausgeschlossen.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection
|
||||
index="06"
|
||||
title="Hosting & Wartung"
|
||||
>
|
||||
Wartung sichert den Betrieb des Ist-Zustands. Erweiterungen oder Funktionsänderungen sind separat zu beauftragen.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection
|
||||
index="07"
|
||||
title="Zahlung & Verzug"
|
||||
>
|
||||
Alle Preise netto. Fälligkeit innerhalb von 7 Tagen. Bei erheblichem Verzug ist der Auftragnehmer berechtigt, die Leistung einzustellen.
|
||||
</AGBSection>
|
||||
</PDFView>
|
||||
|
||||
<Footer
|
||||
logo={footerLogo}
|
||||
companyData={companyData}
|
||||
bankData={bankData}
|
||||
showDetails={false}
|
||||
/>
|
||||
</PDFPage>
|
||||
);
|
||||
};
|
||||
29
src/components/CombinedQuotePDF.tsx
Normal file
29
src/components/CombinedQuotePDF.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Document as PDFDocument } from '@react-pdf/renderer';
|
||||
import { EstimationPDF } from './EstimationPDF';
|
||||
import { AgbsPDF } from './AgbsPDF';
|
||||
|
||||
interface CombinedProps {
|
||||
estimationProps: any;
|
||||
showAgbs?: boolean;
|
||||
}
|
||||
|
||||
export const CombinedQuotePDF = ({ estimationProps, showAgbs = true }: CombinedProps) => {
|
||||
return (
|
||||
<PDFDocument title={`Mintel - ${estimationProps.state.companyName || estimationProps.state.name}`}>
|
||||
{/* Estimation Sections */}
|
||||
<EstimationPDF {...estimationProps} />
|
||||
|
||||
{/* AGB Section */}
|
||||
{showAgbs && (
|
||||
<AgbsPDF
|
||||
state={estimationProps.state}
|
||||
headerIcon={estimationProps.headerIcon}
|
||||
footerLogo={estimationProps.footerLogo}
|
||||
/>
|
||||
)}
|
||||
</PDFDocument>
|
||||
);
|
||||
};
|
||||
@@ -17,87 +17,10 @@ import {
|
||||
calculatePositions
|
||||
} from '../logic/pricing';
|
||||
|
||||
import { pdfStyles, Header, Footer, FoldingMarks, DocumentTitle } from './pdf/SharedUI';
|
||||
|
||||
const styles = PDFStyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 45, // DIN 5008
|
||||
paddingLeft: 70, // ~25mm
|
||||
paddingRight: 57, // ~20mm
|
||||
paddingBottom: 48,
|
||||
backgroundColor: '#ffffff',
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: 10,
|
||||
color: '#000000',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 40,
|
||||
minHeight: 120,
|
||||
},
|
||||
addressBlock: {
|
||||
width: '55%',
|
||||
marginTop: 45, // DIN 5008 positioning for window
|
||||
},
|
||||
senderLine: {
|
||||
fontSize: 7,
|
||||
textDecoration: 'underline',
|
||||
color: '#666666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
recipientAddress: {
|
||||
fontSize: 10,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
brandLogoContainer: {
|
||||
width: '40%',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
brandIconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
backgroundColor: '#000000',
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
brandIconText: {
|
||||
color: '#ffffff',
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
quoteInfo: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
quoteTitle: {
|
||||
fontSize: 14, // Slightly smaller to prevent wrapping
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
color: '#000000',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
textAlign: 'right',
|
||||
},
|
||||
quoteDate: {
|
||||
fontSize: 9,
|
||||
color: '#666666',
|
||||
marginTop: 2,
|
||||
textAlign: 'right',
|
||||
},
|
||||
|
||||
section: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 8,
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
color: '#999999',
|
||||
marginBottom: 8,
|
||||
},
|
||||
|
||||
...pdfStyles,
|
||||
table: {
|
||||
marginTop: 16,
|
||||
minHeight: 300,
|
||||
@@ -134,36 +57,56 @@ const styles = PDFStyleSheet.create({
|
||||
priceText: { fontSize: 10, fontWeight: 'bold', color: '#000000' },
|
||||
|
||||
summaryContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 32,
|
||||
},
|
||||
summaryCard: {
|
||||
width: '45%',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: '#000000',
|
||||
marginTop: 24,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#000000',
|
||||
paddingTop: 12,
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
justifyContent: 'flex-end',
|
||||
paddingVertical: 4,
|
||||
alignItems: 'baseline',
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: 7,
|
||||
color: '#64748b',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
fontWeight: 'bold',
|
||||
marginRight: 12,
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: 9,
|
||||
fontWeight: 'bold',
|
||||
color: '#0f172a',
|
||||
textAlign: 'right',
|
||||
width: 100,
|
||||
},
|
||||
summaryLabel: { fontSize: 8, color: '#666666' },
|
||||
summaryValue: { fontSize: 9, fontWeight: 'bold', color: '#000000' },
|
||||
|
||||
totalRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
justifyContent: 'flex-end',
|
||||
paddingTop: 12,
|
||||
marginTop: 8,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#eeeeee',
|
||||
borderTopWidth: 2,
|
||||
borderTopColor: '#000000',
|
||||
alignItems: 'baseline',
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: 8,
|
||||
fontWeight: 'bold',
|
||||
color: '#000000',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
color: '#000000',
|
||||
textAlign: 'right',
|
||||
width: 110,
|
||||
},
|
||||
totalLabel: { fontSize: 10, fontWeight: 'bold', color: '#000000' },
|
||||
totalValue: { fontSize: 14, fontWeight: 'bold', color: '#000000' },
|
||||
|
||||
hostingBox: {
|
||||
marginTop: 24,
|
||||
@@ -206,52 +149,6 @@ const styles = PDFStyleSheet.create({
|
||||
textTransform: 'uppercase',
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 32,
|
||||
left: 70,
|
||||
right: 57,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f1f5f9',
|
||||
paddingTop: 16,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
footerColumn: {
|
||||
flex: 1,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
footerLogo: {
|
||||
height: 20,
|
||||
width: 'auto',
|
||||
objectFit: 'contain',
|
||||
marginBottom: 8,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 7,
|
||||
color: '#94a3b8',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
footerLabel: {
|
||||
fontWeight: 'bold',
|
||||
color: '#64748b',
|
||||
},
|
||||
pageNumber: {
|
||||
fontSize: 7,
|
||||
color: '#cbd5e1',
|
||||
fontWeight: 'bold',
|
||||
marginTop: 8,
|
||||
textAlign: 'right',
|
||||
},
|
||||
foldingMark: {
|
||||
position: 'absolute',
|
||||
left: 20,
|
||||
width: 10,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: '#cbd5e1',
|
||||
}
|
||||
});
|
||||
|
||||
interface PDFProps {
|
||||
@@ -274,75 +171,40 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
||||
|
||||
const positions = calculatePositions(state, pricing);
|
||||
|
||||
const FoldingMarks = () => (
|
||||
<>
|
||||
<PDFView style={[styles.foldingMark, { top: 297.6 }]} fixed />
|
||||
<PDFView style={[styles.foldingMark, { top: 420.9, width: 15 }]} fixed />
|
||||
<PDFView style={[styles.foldingMark, { top: 595.3 }]} fixed />
|
||||
</>
|
||||
);
|
||||
const companyData = {
|
||||
name: "Marc Mintel",
|
||||
address1: "Georg-Meistermann-Straße 7",
|
||||
address2: "54586 Schüller",
|
||||
ustId: "DE367588065"
|
||||
};
|
||||
|
||||
const Footer = () => (
|
||||
<PDFView style={styles.footer} fixed>
|
||||
<PDFView style={styles.footerColumn}>
|
||||
{footerLogo ? (
|
||||
<PDFImage src={footerLogo} style={styles.footerLogo} />
|
||||
) : (
|
||||
<PDFText style={{ fontSize: 12, fontWeight: 'bold', marginBottom: 8 }}>marc mintel</PDFText>
|
||||
)}
|
||||
</PDFView>
|
||||
|
||||
<PDFView style={styles.footerColumn}>
|
||||
<PDFText style={styles.footerText}>
|
||||
<PDFText style={styles.footerLabel}>Marc Mintel</PDFText>{"\n"}
|
||||
Georg-Meistermann-Straße 7{"\n"}
|
||||
54586 Schüller{"\n"}
|
||||
UST: DE367588065
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
|
||||
<PDFView style={[styles.footerColumn, { alignItems: 'flex-end' }]}>
|
||||
<PDFText style={[styles.footerText, { textAlign: 'right' }]}>
|
||||
<PDFText style={styles.footerLabel}>N26</PDFText>{"\n"}
|
||||
NTSBDEB1XXX{"\n"}
|
||||
DE50100110012620432865
|
||||
</PDFText>
|
||||
<PDFText style={styles.pageNumber} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} fixed />
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
);
|
||||
const bankData = {
|
||||
name: "N26",
|
||||
bic: "NTSBDEB1XXX",
|
||||
iban: "DE50 1001 1001 2620 4328 65"
|
||||
};
|
||||
|
||||
return (
|
||||
<PDFDocument title={`Kostenschätzung - ${state.companyName || state.name}`}>
|
||||
<>
|
||||
<PDFPage size="A4" style={styles.page}>
|
||||
<FoldingMarks />
|
||||
<PDFView style={styles.header}>
|
||||
<PDFView style={styles.addressBlock}>
|
||||
<PDFText style={styles.senderLine}>Marc Mintel | Georg-Meistermann-Straße 7 | 54586 Schüller</PDFText>
|
||||
<PDFView style={styles.recipientAddress}>
|
||||
<PDFText style={{ fontWeight: 'bold' }}>{state.companyName || state.name}</PDFText>
|
||||
{state.companyName && <PDFText>{state.name}</PDFText>}
|
||||
<PDFText>{state.email}</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
<Header
|
||||
sender="Marc Mintel | Georg-Meistermann-Straße 7 | 54586 Schüller"
|
||||
recipient={{
|
||||
title: state.companyName || state.name,
|
||||
subtitle: state.companyName ? state.name : undefined,
|
||||
email: state.email
|
||||
}}
|
||||
icon={headerIcon}
|
||||
/>
|
||||
|
||||
<PDFView style={styles.brandLogoContainer}>
|
||||
<PDFView style={styles.brandIconContainer}>
|
||||
{headerIcon ? (
|
||||
<PDFImage src={headerIcon} style={{ width: 24, height: 24 }} />
|
||||
) : (
|
||||
<PDFText style={styles.brandIconText}>M</PDFText>
|
||||
)}
|
||||
</PDFView>
|
||||
<PDFView style={styles.quoteInfo}>
|
||||
<PDFText style={styles.quoteTitle}>Kostenschätzung</PDFText>
|
||||
<PDFText style={styles.quoteDate}>Datum: {date}</PDFText>
|
||||
<PDFText style={[styles.quoteDate, { fontWeight: 'bold', color: '#000000' }]}>
|
||||
Projekt: {state.projectType === 'website' ? 'Website' : 'Web App'}
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
<DocumentTitle
|
||||
title="Kostenschätzung"
|
||||
subLines={[
|
||||
`Datum: ${date}`,
|
||||
`Projekt: ${state.projectType === 'website' ? 'Website' : 'Web App'}`
|
||||
]}
|
||||
/>
|
||||
|
||||
<PDFView style={styles.table}>
|
||||
<PDFView style={styles.tableHeader}>
|
||||
@@ -368,51 +230,27 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
||||
</PDFView>
|
||||
|
||||
<PDFView style={styles.summaryContainer}>
|
||||
<PDFView style={styles.summaryCard}>
|
||||
<PDFView style={styles.summaryRow}>
|
||||
<PDFText style={styles.summaryLabel}>Zwischensumme (Netto)</PDFText>
|
||||
<PDFText style={styles.summaryValue}>{totalPrice.toLocaleString('de-DE')} €</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.totalRow}>
|
||||
<PDFText style={styles.totalLabel}>Gesamtsumme</PDFText>
|
||||
<PDFText style={styles.totalValue}>{totalPrice.toLocaleString('de-DE')} €</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.summaryRow}>
|
||||
<PDFText style={styles.summaryLabel}>Zwischensumme (Netto)</PDFText>
|
||||
<PDFText style={styles.summaryValue}>{totalPrice.toLocaleString('de-DE')} €</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.summaryRow}>
|
||||
<PDFText style={styles.summaryLabel}>zzgl. 19% MwSt.</PDFText>
|
||||
<PDFText style={styles.summaryValue}>{(totalPrice * 0.19).toLocaleString('de-DE')} €</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.totalRow}>
|
||||
<PDFText style={styles.totalLabel}>Gesamtsumme (Brutto)</PDFText>
|
||||
<PDFText style={styles.totalValue}>{(totalPrice * 1.19).toLocaleString('de-DE')} €</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
|
||||
{state.projectType === 'website' && (
|
||||
<PDFView style={styles.hostingBox}>
|
||||
<PDFText style={{ color: '#666666', fontSize: 8, fontWeight: 'bold', textTransform: 'uppercase' }}>Betrieb & Hosting</PDFText>
|
||||
<PDFText style={{ fontSize: 10, fontWeight: 'bold', color: '#000000' }}>{monthlyPrice.toLocaleString('de-DE')} € / Monat</PDFText>
|
||||
</PDFView>
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
<Footer logo={footerLogo} companyData={companyData} bankData={bankData} />
|
||||
</PDFPage>
|
||||
|
||||
<PDFPage size="A4" style={styles.page}>
|
||||
<FoldingMarks />
|
||||
<PDFView style={styles.header}>
|
||||
<PDFView style={styles.addressBlock}>
|
||||
<PDFText style={styles.senderLine}>Marc Mintel | Georg-Meistermann-Straße 7 | 54586 Schüller</PDFText>
|
||||
<PDFView style={styles.recipientAddress}>
|
||||
<PDFText style={{ fontWeight: 'bold' }}>{state.companyName || state.name}</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
|
||||
<PDFView style={styles.brandLogoContainer}>
|
||||
<PDFView style={styles.brandIconContainer}>
|
||||
{headerIcon ? (
|
||||
<PDFImage src={headerIcon} style={{ width: 24, height: 24 }} />
|
||||
) : (
|
||||
<PDFText style={styles.brandIconText}>M</PDFText>
|
||||
)}
|
||||
</PDFView>
|
||||
<PDFView style={styles.quoteInfo}>
|
||||
<PDFText style={styles.quoteTitle}>Projektdetails</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
<Header icon={headerIcon} showAddress={false} />
|
||||
<DocumentTitle title="Projektdetails" />
|
||||
|
||||
<PDFView style={styles.section}>
|
||||
<PDFText style={styles.sectionTitle}>Konfiguration & Wünsche</PDFText>
|
||||
@@ -484,8 +322,8 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
||||
</PDFView>
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
<Footer logo={footerLogo} companyData={companyData} bankData={bankData} />
|
||||
</PDFPage>
|
||||
</PDFDocument>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
231
src/components/pdf/SharedUI.tsx
Normal file
231
src/components/pdf/SharedUI.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Text as PDFText,
|
||||
View as PDFView,
|
||||
StyleSheet as PDFStyleSheet,
|
||||
Image as PDFImage
|
||||
} from '@react-pdf/renderer';
|
||||
|
||||
export const pdfStyles = PDFStyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 45, // DIN 5008
|
||||
paddingLeft: 70, // ~25mm
|
||||
paddingRight: 57, // ~20mm
|
||||
paddingBottom: 48,
|
||||
backgroundColor: '#ffffff',
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: 10,
|
||||
color: '#000000',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 20,
|
||||
minHeight: 120,
|
||||
},
|
||||
addressBlock: {
|
||||
width: '55%',
|
||||
marginTop: 45, // DIN 5008 positioning for window
|
||||
},
|
||||
senderLine: {
|
||||
fontSize: 7,
|
||||
textDecoration: 'underline',
|
||||
color: '#666666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
recipientAddress: {
|
||||
fontSize: 10,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
brandLogoContainer: {
|
||||
width: '40%',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
brandIconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
backgroundColor: '#000000',
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
brandIconText: {
|
||||
color: '#ffffff',
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
titleInfo: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
mainTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
color: '#000000',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
subTitle: {
|
||||
fontSize: 9,
|
||||
color: '#666666',
|
||||
marginTop: 2,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 8,
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
color: '#999999',
|
||||
marginBottom: 8,
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 32,
|
||||
left: 70,
|
||||
right: 57,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f1f5f9',
|
||||
paddingTop: 16,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
footerColumn: {
|
||||
flex: 1,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
footerLogo: {
|
||||
height: 20,
|
||||
width: 'auto',
|
||||
objectFit: 'contain',
|
||||
marginBottom: 8,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 7,
|
||||
color: '#94a3b8',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
footerLabel: {
|
||||
fontWeight: 'bold',
|
||||
color: '#64748b',
|
||||
},
|
||||
pageNumber: {
|
||||
fontSize: 7,
|
||||
color: '#cbd5e1',
|
||||
fontWeight: 'bold',
|
||||
marginTop: 8,
|
||||
textAlign: 'right',
|
||||
},
|
||||
foldingMark: {
|
||||
position: 'absolute',
|
||||
left: 20,
|
||||
width: 10,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: '#cbd5e1',
|
||||
}
|
||||
});
|
||||
|
||||
export const FoldingMarks = () => (
|
||||
<>
|
||||
<PDFView style={[pdfStyles.foldingMark, { top: 297.6 }]} fixed />
|
||||
<PDFView style={[pdfStyles.foldingMark, { top: 420.9, width: 15 }]} fixed />
|
||||
<PDFView style={[pdfStyles.foldingMark, { top: 595.3 }]} fixed />
|
||||
</>
|
||||
);
|
||||
|
||||
export const Footer = ({ logo, companyData, bankData, showDetails = true }: { logo?: string; companyData: any; bankData: any; showDetails?: boolean }) => (
|
||||
<PDFView style={pdfStyles.footer} fixed>
|
||||
<PDFView style={pdfStyles.footerColumn}>
|
||||
{logo ? (
|
||||
<PDFImage src={logo} style={pdfStyles.footerLogo} />
|
||||
) : (
|
||||
<PDFText style={{ fontSize: 12, fontWeight: 'bold', marginBottom: 8 }}>marc mintel</PDFText>
|
||||
)}
|
||||
</PDFView>
|
||||
|
||||
{showDetails && (
|
||||
<>
|
||||
<PDFView style={pdfStyles.footerColumn}>
|
||||
<PDFText style={pdfStyles.footerText}>
|
||||
<PDFText style={pdfStyles.footerLabel}>{companyData.name}</PDFText>{"\n"}
|
||||
{companyData.address1}{"\n"}
|
||||
{companyData.address2}{"\n"}
|
||||
UST: {companyData.ustId}
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
|
||||
<PDFView style={[pdfStyles.footerColumn, { alignItems: 'flex-end' }]}>
|
||||
<PDFText style={[pdfStyles.footerText, { textAlign: 'right' }]}>
|
||||
<PDFText style={pdfStyles.footerLabel}>{bankData.name}</PDFText>{"\n"}
|
||||
{bankData.bic}{"\n"}
|
||||
{bankData.iban}
|
||||
</PDFText>
|
||||
<PDFText style={pdfStyles.pageNumber} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} fixed />
|
||||
</PDFView>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!showDetails && (
|
||||
<PDFView style={[pdfStyles.footerColumn, { alignItems: 'flex-end' }]}>
|
||||
<PDFText style={pdfStyles.pageNumber} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} fixed />
|
||||
</PDFView>
|
||||
)}
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const Header = ({
|
||||
sender,
|
||||
recipient,
|
||||
icon,
|
||||
showAddress = true
|
||||
}: {
|
||||
sender?: string;
|
||||
recipient?: { title: string; subtitle?: string; email?: string };
|
||||
icon?: string;
|
||||
showAddress?: boolean;
|
||||
}) => (
|
||||
<PDFView style={pdfStyles.header}>
|
||||
<PDFView style={pdfStyles.addressBlock}>
|
||||
{showAddress && sender && (
|
||||
<>
|
||||
<PDFText style={pdfStyles.senderLine}>{sender}</PDFText>
|
||||
{recipient && (
|
||||
<PDFView style={pdfStyles.recipientAddress}>
|
||||
<PDFText style={{ fontWeight: 'bold' }}>{recipient.title}</PDFText>
|
||||
{recipient.subtitle && <PDFText>{recipient.subtitle}</PDFText>}
|
||||
{recipient.email && <PDFText>{recipient.email}</PDFText>}
|
||||
</PDFView>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PDFView>
|
||||
|
||||
<PDFView style={pdfStyles.brandLogoContainer}>
|
||||
<PDFView style={pdfStyles.brandIconContainer}>
|
||||
{icon ? (
|
||||
<PDFImage src={icon} style={{ width: 24, height: 24 }} />
|
||||
) : (
|
||||
<PDFText style={pdfStyles.brandIconText}>M</PDFText>
|
||||
)}
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const DocumentTitle = ({ title, subLines }: { title: string; subLines?: string[] }) => (
|
||||
<PDFView style={pdfStyles.titleInfo}>
|
||||
<PDFText style={pdfStyles.mainTitle}>{title}</PDFText>
|
||||
{subLines?.map((line, i) => (
|
||||
<PDFText key={i} style={[pdfStyles.subTitle, i === 1 ? { fontWeight: 'bold', color: '#000000' } : {}]}>
|
||||
{line}
|
||||
</PDFText>
|
||||
))}
|
||||
</PDFView>
|
||||
);
|
||||
@@ -114,6 +114,15 @@ export function calculatePositions(state: FormState, pricing: any): Position[] {
|
||||
price: Math.round(factorPrice)
|
||||
});
|
||||
}
|
||||
|
||||
const monthlyRate = pricing.HOSTING_MONTHLY + (state.storageExpansion * pricing.STORAGE_EXPANSION_MONTHLY);
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: 'Betriebs- und Pflegeleistung (12 Monate)',
|
||||
desc: `Bereitstellung der Infrastruktur, technische Instandhaltung, Sicherheits-Updates und Backup-Management gemäß AGB Punkt 7a. Inklusive ${state.storageExpansion > 0 ? state.storageExpansion + ' GB Speicher-Erweiterung' : 'Basis-Infrastruktur'}.`,
|
||||
qty: 1,
|
||||
price: monthlyRate * 12
|
||||
});
|
||||
} else {
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
|
||||
@@ -64,4 +64,5 @@ export interface Position {
|
||||
desc: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
isRecurring?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user