diff --git a/messages/de.json b/messages/de.json index 639f8e4..596b5c5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2147,5 +2147,130 @@ "powFailed": "Sicherheitsüberprüfung fehlgeschlagen. Bitte aktualisieren und erneut versuchen.", "general": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut." } + }, + "proposals": { + "builder": { + "title": "Angebots-Editor", + "editTitle": "Angebot bearbeiten", + "breadcrumb": "Angebote", + "newProposal": "Neues Angebot", + "projectLabel": "Projekt", + "clientLabel": "Kunde", + "proposalTitleLabel": "Angebotstitel", + "proposalTitlePlaceholder": "z. B. Hochzeitsfotografie-Angebot — Emma & Liam", + "currencyLabel": "Währung", + "pricingModeLabel": "Preismodus", + "pricingModeGross": "Brutto (inkl. USt.)", + "pricingModeNet": "Netto (zzgl. USt.)", + "kleinunternehmerBadge": "§ 19 UStG (Kleinunternehmer)", + "status": { + "draft": "Entwurf", + "sent": "Versendet", + "accepted": "Angenommen", + "declined": "Abgelehnt", + "expired": "Abgelaufen", + "superseded": "Ersetzt" + }, + "taxRates": { + "standard": "19% (Regelsteuersatz)", + "reduced": "7% (Ermäßigt)", + "exempt": "0% (Steuerbefreit)" + }, + "packageSection": { + "title": "1. Basispaket", + "subtitle": "Wählen Sie ein Basispaket aus Ihrem Studiokatalog oder starten Sie mit individuellen Leistungen.", + "selectPlaceholder": "Studiopaket auswählen...", + "noPackages": "Keine Pakete gefunden", + "filterAll": "Alle Projekttypen", + "includedDeliverablesTitle": "Enthaltene Leistungen", + "deliverablesSubtitle": "In diesem Paket enthaltene Leistungen. Sie können diese für dieses Angebot anpassen.", + "addDeliverable": "Leistung hinzufügen", + "deliverableTitlePlaceholder": "Leistungsbezeichnung", + "deliverableQty": "Menge", + "deliverableQtyAria": "{title} Anzahl", + "deliverableTitleAria": "Leistungstitel {number}", + "removeDeliverable": "Leistung entfernen", + "customBasePackage": "Individuelles Paket (ohne Katalogverknüpfung)", + "basePriceLabel": "Grundpreis", + "taxRateLabel": "USt-Satz" + }, + "addonSection": { + "title": "2. Zusatzoptionen & Individuelle Positionen", + "subtitle": "Fügen Sie modulare Zusatzleistungen aus Ihrem Katalog oder individuelle Positionen hinzu.", + "cataloguePlaceholder": "Zusatzleistung aus Katalog auswählen...", + "addCatalogueAddon": "Zusatzleistung hinzufügen", + "addCustomItem": "Individuelle Position hinzufügen", + "noAddonsAttached": "Bisher keine Zusatzoptionen oder individuellen Positionen angehängt.", + "table": { + "item": "Position", + "type": "Typ", + "price": "Einzelpreis", + "quantity": "Menge", + "maxQuantity": "Max. Menge", + "taxRate": "USt-Satz", + "optional": "Optional für Kunden", + "actions": "Aktionen" + }, + "types": { + "base_package": "Basispaket", + "addon": "Katalog-Zusatzoption", + "custom": "Individuelle Position" + }, + "optionalBadge": "Vom Kunden wählbar", + "mandatoryBadge": "Verbindlich", + "moveUp": "Position nach oben verschieben", + "moveDown": "Position nach unten verschieben", + "removeItem": "Position entfernen", + "namePlaceholder": "Positionsbezeichnung", + "descriptionPlaceholder": "Optionale Beschreibung für den Kunden..." + }, + "financialSection": { + "title": "3. Finanzübersicht & Versand", + "subtitle": "Echtzeit-Berechnung in Minor Units gemäß Ihrer Studio-Steuerkonfiguration.", + "subtotal": "Zwischensumme", + "netSubtotal": "Nettosumme", + "grossSubtotal": "Gesamtbetrag (brutto)", + "tax": "Umsatzsteuer", + "total": "Gesamtbetrag", + "taxBreakdown": "USt-Aufschlüsselung", + "taxAtRate": "USt. zu {rate}%", + "kleinunternehmerNotice": "Hinweis nach § 19 UStG:", + "saveDraft": "Entwurf speichern", + "saving": "Entwurf wird gespeichert...", + "sendProposal": "Angebot an Kunden senden", + "saveSuccess": "Angebotsentwurf erfolgreich gespeichert.", + "statusBanner": "Status: {status}" + }, + "sendModal": { + "title": "Angebot an Kunden senden", + "description": "Erstellen Sie einen sicheren, cookielosen Token-Link und senden Sie die Benachrichtigungs-E-Mail an den Kunden.", + "recipientEmailLabel": "E-Mail-Adresse des Empfängers", + "recipientEmailPlaceholder": "kunde@beispiel.de", + "validUntilLabel": "Gültig bis", + "validUntilHelp": "Angebote sind standardmäßig 14 Tage gültig. Nach Ablauf ist die Online-Annahme gesperrt.", + "submitButton": "Angebot senden & E-Mail einreihen", + "sending": "Wird gesendet...", + "cancelButton": "Abbrechen" + }, + "linkCopied": { + "title": "Angebot erfolgreich versendet!", + "description": "Das Angebot wurde als versendet markiert und die E-Mail zur Zustellung eingereiht.", + "urlLabel": "Kunden-Angebotslink", + "copyButton": "Link kopieren", + "copied": "In die Zwischenablage kopiert!", + "close": "Fertig" + }, + "validation": { + "titleRequired": "Angebotstitel ist erforderlich", + "titleTooLong": "Angebotstitel darf maximal 200 Zeichen lang sein", + "negativePrice": "Preis darf nicht negativ sein", + "invalidPrice": "Bitte geben Sie einen gültigen Preis ein", + "nameRequired": "Positionsbezeichnung ist erforderlich", + "dateInPast": "Gültigkeitsdatum muss in der Zukunft liegen", + "invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein", + "quantityMin": "Menge muss mindestens 0 sein", + "maxQuantityMin": "Maximale Menge muss mindestens 1 sein" + } + } } } diff --git a/messages/en.json b/messages/en.json index f9b2902..180020f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2147,5 +2147,130 @@ "powFailed": "Security check failed. Please refresh and try again.", "general": "Something went wrong. Please try again." } + }, + "proposals": { + "builder": { + "title": "Proposal Composer", + "editTitle": "Edit Proposal", + "breadcrumb": "Proposals", + "newProposal": "New Proposal", + "projectLabel": "Project", + "clientLabel": "Client", + "proposalTitleLabel": "Proposal Title", + "proposalTitlePlaceholder": "e.g. Wedding Photography Proposal — Emma & Liam", + "currencyLabel": "Currency", + "pricingModeLabel": "Tax Mode", + "pricingModeGross": "Gross (VAT included)", + "pricingModeNet": "Net (VAT added)", + "kleinunternehmerBadge": "§ 19 UStG (Small Business)", + "status": { + "draft": "Draft", + "sent": "Sent", + "accepted": "Accepted", + "declined": "Declined", + "expired": "Expired", + "superseded": "Superseded" + }, + "taxRates": { + "standard": "19% Standard", + "reduced": "7% Reduced", + "exempt": "0% Tax Exempt" + }, + "packageSection": { + "title": "1. Base Service Package", + "subtitle": "Select a base package from your studio catalogue or start with custom deliverables.", + "selectPlaceholder": "Choose a studio package...", + "noPackages": "No packages found", + "filterAll": "All Project Types", + "includedDeliverablesTitle": "Included Bundled Deliverables", + "deliverablesSubtitle": "Deliverables included in this package. You can customize them for this proposal.", + "addDeliverable": "Add Deliverable", + "deliverableTitlePlaceholder": "Deliverable title", + "deliverableQty": "Qty", + "deliverableQtyAria": "{title} quantity", + "deliverableTitleAria": "Deliverable title {number}", + "removeDeliverable": "Remove deliverable", + "customBasePackage": "Custom Package (No catalogue link)", + "basePriceLabel": "Base Price", + "taxRateLabel": "VAT Rate" + }, + "addonSection": { + "title": "2. Add-on Options & Custom Items", + "subtitle": "Attach modular add-on products from your catalogue or add one-off custom line items.", + "cataloguePlaceholder": "Select a catalogue add-on...", + "addCatalogueAddon": "Add Catalogue Add-on", + "addCustomItem": "Add Custom Item", + "noAddonsAttached": "No add-on options or custom line items attached yet.", + "table": { + "item": "Item", + "type": "Type", + "price": "Unit Price", + "quantity": "Quantity", + "maxQuantity": "Max Qty", + "taxRate": "VAT Rate", + "optional": "Optional for Client", + "actions": "Actions" + }, + "types": { + "base_package": "Base Package", + "addon": "Catalogue Add-on", + "custom": "Custom Item" + }, + "optionalBadge": "Client-Optional", + "mandatoryBadge": "Mandatory", + "moveUp": "Move line item up", + "moveDown": "Move line item down", + "removeItem": "Remove line item", + "namePlaceholder": "Item name", + "descriptionPlaceholder": "Optional description for client..." + }, + "financialSection": { + "title": "3. Commercial Summary & Dispatch", + "subtitle": "Real-time calculation in integer minor units according to your studio tax configuration.", + "subtotal": "Subtotal", + "netSubtotal": "Net Subtotal", + "grossSubtotal": "Gross Total", + "tax": "VAT", + "total": "Total Amount", + "taxBreakdown": "VAT Breakdown", + "taxAtRate": "VAT at {rate}%", + "kleinunternehmerNotice": "Small Business Notice (§ 19 UStG):", + "saveDraft": "Save Draft", + "saving": "Saving Draft...", + "sendProposal": "Send Proposal to Client", + "saveSuccess": "Proposal draft saved successfully.", + "statusBanner": "Status: {status}" + }, + "sendModal": { + "title": "Send Proposal to Client", + "description": "Generate a secure, cookieless token link and send the proposal notification email to the client.", + "recipientEmailLabel": "Recipient Email Address", + "recipientEmailPlaceholder": "client@example.com", + "validUntilLabel": "Validity Expiration Date", + "validUntilHelp": "Proposals default to 14 days of validity. After this date, interactive acceptance is locked.", + "submitButton": "Send Proposal & Queue Email", + "sending": "Sending...", + "cancelButton": "Cancel" + }, + "linkCopied": { + "title": "Proposal Sent Successfully!", + "description": "The proposal was marked as sent and the email notification was queued for delivery.", + "urlLabel": "Client Proposal Link", + "copyButton": "Copy Link", + "copied": "Copied to clipboard!", + "close": "Done" + }, + "validation": { + "titleRequired": "Proposal title is required", + "titleTooLong": "Proposal title cannot exceed 200 characters", + "negativePrice": "Price cannot be negative", + "invalidPrice": "Please enter a valid price", + "nameRequired": "Item name is required", + "dateInPast": "Expiration date must be in the future", + "invalidEmail": "Please enter a valid email address", + "quantityMin": "Quantity must be at least 0", + "maxQuantityMin": "Max quantity must be at least 1" + } + } } } diff --git a/src/app/[locale]/projects/[id]/proposals/[proposalId]/page.tsx b/src/app/[locale]/projects/[id]/proposals/[proposalId]/page.tsx new file mode 100644 index 0000000..4ebf488 --- /dev/null +++ b/src/app/[locale]/projects/[id]/proposals/[proposalId]/page.tsx @@ -0,0 +1,92 @@ +import type { Metadata } from 'next'; +import { redirect, notFound } from 'next/navigation'; +import { z } from 'zod'; +import { requireOwner } from '@/server/auth/guards'; +import { getProjectDetail } from '@/server/projects/service'; +import { getProposalById } from '@/server/proposals/repo'; +import { listPackages } from '@/server/packages/repo'; +import { listCatalogueItems } from '@/server/catalogue/repo'; +import { getStudioTaxSettings } from '@/server/studio/tax-settings'; +import { ProposalBuilderView } from '@/components/proposals/proposal-builder-view'; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +interface ProposalEditPageProps { + params: Promise<{ locale: string; id: string; proposalId: string }>; +} + +const uuidSchema = z.string().uuid(); + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string; id: string; proposalId: string }>; +}): Promise { + const { proposalId } = await params; + const uuidParsed = uuidSchema.safeParse(proposalId); + if (!uuidParsed.success) { + return { title: 'Proposal · Ownlight' }; + } + + try { + const proposal = await getProposalById(proposalId); + if (!proposal) { + return { title: 'Proposal Not Found · Ownlight' }; + } + return { + title: `${proposal.title} · Ownlight`, + }; + } catch { + return { title: 'Proposal · Ownlight' }; + } +} + +export default async function ProposalEditPage({ params }: ProposalEditPageProps) { + const { locale, id, proposalId } = await params; + + try { + await requireOwner(); + } catch { + redirect(`/${locale}/sign-in?callbackUrl=/${locale}/projects/${id}/proposals/${proposalId}`); + return null; + } + + const idParsed = uuidSchema.safeParse(id); + const proposalIdParsed = uuidSchema.safeParse(proposalId); + if (!idParsed.success || !proposalIdParsed.success) { + notFound(); + return null; + } + + const [projectDetail, proposal, packages, catalogueItems, studioSettings] = await Promise.all([ + getProjectDetail(id), + getProposalById(proposalId), + listPackages({ isActive: true }), + listCatalogueItems({ isActive: true }), + getStudioTaxSettings(), + ]); + + if (!projectDetail || !proposal) { + notFound(); + return null; + } + + // Ensure proposal belongs to this project + if (proposal.projectId !== id) { + notFound(); + return null; + } + + return ( + + ); +} diff --git a/src/app/[locale]/projects/[id]/proposals/new/page.tsx b/src/app/[locale]/projects/[id]/proposals/new/page.tsx new file mode 100644 index 0000000..9c92531 --- /dev/null +++ b/src/app/[locale]/projects/[id]/proposals/new/page.tsx @@ -0,0 +1,83 @@ +import type { Metadata } from 'next'; +import { redirect, notFound } from 'next/navigation'; +import { z } from 'zod'; +import { requireOwner } from '@/server/auth/guards'; +import { getProjectDetail } from '@/server/projects/service'; +import { listPackages } from '@/server/packages/repo'; +import { listCatalogueItems } from '@/server/catalogue/repo'; +import { getStudioTaxSettings } from '@/server/studio/tax-settings'; +import { ProposalBuilderView } from '@/components/proposals/proposal-builder-view'; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +interface ProposalNewPageProps { + params: Promise<{ locale: string; id: string }>; +} + +const uuidSchema = z.string().uuid(); + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string; id: string }>; +}): Promise { + const { id } = await params; + const uuidParsed = uuidSchema.safeParse(id); + if (!uuidParsed.success) { + return { title: 'New Proposal · Ownlight' }; + } + + try { + const detail = await getProjectDetail(id); + if (!detail) { + return { title: 'Project Not Found · Ownlight' }; + } + return { + title: `New Proposal · ${detail.project.title} · Ownlight`, + }; + } catch { + return { title: 'New Proposal · Ownlight' }; + } +} + +export default async function ProposalNewPage({ params }: ProposalNewPageProps) { + const { locale, id } = await params; + + try { + await requireOwner(); + } catch { + redirect(`/${locale}/sign-in?callbackUrl=/${locale}/projects/${id}/proposals/new`); + return null; + } + + const uuidParsed = uuidSchema.safeParse(id); + if (!uuidParsed.success) { + notFound(); + return null; + } + + const [projectDetail, packages, catalogueItems, studioSettings] = await Promise.all([ + getProjectDetail(id), + listPackages({ isActive: true }), + listCatalogueItems({ isActive: true }), + getStudioTaxSettings(), + ]); + + if (!projectDetail) { + notFound(); + return null; + } + + return ( + + ); +} diff --git a/src/components/proposals/addon-options-section.tsx b/src/components/proposals/addon-options-section.tsx new file mode 100644 index 0000000..a588535 --- /dev/null +++ b/src/components/proposals/addon-options-section.tsx @@ -0,0 +1,357 @@ +'use client'; + +import * as React from 'react'; +import { useTranslations } from 'next-intl'; +import { Layers, Plus, ArrowUp, ArrowDown, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { formatCurrency } from '@/lib/formatters'; +import type { CatalogueItem } from '@/server/db/schema/packages'; +import { + type ProposalLineItemState, + type ProposalBuilderErrors, + createAddonRowHandlers, + createAddonSectionController, +} from './proposal-builder-helpers'; + +export interface AddonOptionsSectionProps { + catalogueItems: CatalogueItem[]; + addonItems: ProposalLineItemState[]; + currency: string; + locale: string; + errors: ProposalBuilderErrors; + onAddCatalogueItem: (item: CatalogueItem) => void; + onAddCustomItem: () => void; + onUpdateItemField: (index: number, field: keyof ProposalLineItemState, value: unknown) => void; + onMoveItem: (from: number, to: number) => void; + onRemoveItem: (index: number) => void; +} + +export function AddonOptionsSection({ + catalogueItems, + addonItems, + currency, + locale, + errors, + onAddCatalogueItem, + onAddCustomItem, + onUpdateItemField, + onMoveItem, + onRemoveItem, +}: AddonOptionsSectionProps) { + const t = useTranslations('proposals.builder'); + const [selectedCatalogueId, setSelectedCatalogueId] = React.useState(''); + + const controller = createAddonSectionController( + catalogueItems, + onAddCatalogueItem, + setSelectedCatalogueId, + ); + + return ( +
+
+

+

+

{t('addonSection.subtitle')}

+
+ + {/* Item Addition Toolbar */} +
+
+ + + +
+ +
+ +
+
+ + {/* Attached Line Items Table / List */} +
+ {addonItems.length === 0 ? ( +
+

{t('addonSection.noAddonsAttached')}

+
+ ) : ( + addonItems.map((item, index) => { + const itemErrors = errors.items; + // eslint-disable-next-line security/detect-object-injection -- index is bounded numeric array index from map + const itemError = itemErrors ? itemErrors[index] : undefined; + const rowHandlers = createAddonRowHandlers( + index, + onUpdateItemField, + onMoveItem, + onRemoveItem, + ); + + return ( +
+ {/* Header row: Name, Type Badge, Optional Badge, Reorder & Remove Actions */} +
+
+ + {item.itemType === 'addon' + ? t('addonSection.types.addon') + : t('addonSection.types.custom')} + + + {item.isOptional + ? t('addonSection.optionalBadge') + : t('addonSection.mandatoryBadge')} + +
+ + {/* Actions: Move Up, Move Down, Delete */} +
+ + + +
+
+ + {/* Form Inputs Grid */} +
+ {/* Name field */} +
+ + + {itemError?.name && ( + + )} +
+ + {/* Price input */} +
+ + + {itemError?.unitPrice && ( + + )} +
+ + {/* Quantity input */} +
+ + +
+ + {/* Max quantity input */} +
+ + +
+ + {/* Tax Rate select */} +
+ + +
+ + {/* Optional Toggle Checkbox */} +
+ + +
+
+
+ ); + }) + )} +
+
+ ); +} diff --git a/src/components/proposals/financial-summary-section.tsx b/src/components/proposals/financial-summary-section.tsx new file mode 100644 index 0000000..f72d9b2 --- /dev/null +++ b/src/components/proposals/financial-summary-section.tsx @@ -0,0 +1,164 @@ +'use client'; + +import * as React from 'react'; +import { useTranslations } from 'next-intl'; +import { Calculator, Send, Save, AlertCircle, CheckCircle2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { formatCurrency } from '@/lib/formatters'; +import type { ProposalBuilderState, ProposalBuilderErrors } from './proposal-builder-helpers'; +import { calculateProposalStateTotals } from './proposal-builder-helpers'; + +export interface FinancialSummarySectionProps { + state: ProposalBuilderState; + errors: ProposalBuilderErrors; + locale: string; + onSaveDraft: () => void | Promise; + onOpenSendModal: () => void | Promise; +} + +export function FinancialSummarySection({ + state, + errors, + locale, + onSaveDraft, + onOpenSendModal, +}: FinancialSummarySectionProps) { + const t = useTranslations('proposals.builder'); + const totals = calculateProposalStateTotals(state); + const isValid = !errors.title && !errors.basePackage && !errors.items && !errors.general; + + return ( +
+
+

+

+

{t('financialSection.subtitle')}

+
+ + {/* Notifications */} + {state.saveSuccess && ( +
+ + {t('financialSection.saveSuccess')} +
+ )} + + {state.generalError && ( +
+ + {state.generalError} +
+ )} + + {/* Summary Card */} +
+ {/* Subtotal */} +
+ + {state.pricesIncludeTax + ? t('financialSection.subtotal') + : t('financialSection.netSubtotal')} + + + {formatCurrency(totals.subtotalMinor, state.currency, locale)} + +
+ + {/* Kleinunternehmer Notice or VAT Breakdown */} + {state.isKleinunternehmer ? ( +
+ + {t('financialSection.kleinunternehmerNotice')} + + {state.taxNotice ?? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.'} +
+ ) : ( + totals.breakdown.map((rate) => ( +
+ {t('financialSection.taxAtRate', { rate: rate.taxRatePercentFormatted })} + {formatCurrency(rate.taxMinor, state.currency, locale)} +
+ )) + )} + + {/* Total VAT */} + {!state.isKleinunternehmer && ( +
+ {t('financialSection.tax')} + + {formatCurrency(totals.taxMinor, state.currency, locale)} + +
+ )} + + {/* Total Amount */} +
+ {t('financialSection.total')} + + {formatCurrency(totals.totalMinor, state.currency, locale)} + +
+
+ + {/* Buttons */} +
+ + + +
+
+ ); +} diff --git a/src/components/proposals/package-selector-section.tsx b/src/components/proposals/package-selector-section.tsx new file mode 100644 index 0000000..c1c58b6 --- /dev/null +++ b/src/components/proposals/package-selector-section.tsx @@ -0,0 +1,314 @@ +'use client'; + +import * as React from 'react'; +import { useTranslations } from 'next-intl'; +import { Package, Plus, Trash2, CheckCircle2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { formatCurrency } from '@/lib/formatters'; +import type { PackageWithIncludedItems } from '@/server/packages/repo'; +import { + type ProposalLineItemState, + type DeliverableItemState, + type ProposalBuilderErrors, + createDeliverableRowHandlers, + createPackageSectionController, +} from './proposal-builder-helpers'; + +export interface PackageSelectorSectionProps { + packages: PackageWithIncludedItems[]; + selectedPackageId: string | null; + basePackageItem: ProposalLineItemState | null; + deliverables: DeliverableItemState[]; + currency: string; + locale: string; + errors: ProposalBuilderErrors; + projectTypeKey?: string | null; + onSelectPackage: (pkg: PackageWithIncludedItems | null) => void; + onUpdateBasePrice: (priceMajor: string) => void; + onUpdateTaxRate: (taxRateBasisPoints: number) => void; + onAddDeliverable: (title: string, quantity?: number) => void; + onUpdateDeliverableTitle: (index: number, title: string) => void; + onUpdateDeliverableQty: (index: number, quantity: number) => void; + onRemoveDeliverable: (index: number) => void; +} + +export function PackageSelectorSection({ + packages, + selectedPackageId, + basePackageItem, + deliverables, + currency, + locale, + errors, + projectTypeKey, + onSelectPackage, + onUpdateBasePrice, + onUpdateTaxRate, + onAddDeliverable, + onUpdateDeliverableTitle, + onUpdateDeliverableQty, + onRemoveDeliverable, +}: PackageSelectorSectionProps) { + const t = useTranslations('proposals.builder'); + const [newDeliverableTitle, setNewDeliverableTitle] = React.useState(''); + const [newDeliverableQty, setNewDeliverableQty] = React.useState('1'); + + // Filter packages: if projectTypeKey is given, prioritize or show matching packages + const filteredPackages = React.useMemo(() => { + if (!projectTypeKey) return packages; + return packages.filter((p) => !p.projectTypeKey || p.projectTypeKey === projectTypeKey); + }, [packages, projectTypeKey]); + + const displayPackages = filteredPackages.length > 0 ? filteredPackages : packages; + + const controller = createPackageSectionController( + onSelectPackage, + onUpdateBasePrice, + onUpdateTaxRate, + onAddDeliverable, + packages, + setNewDeliverableTitle, + setNewDeliverableQty, + ); + + return ( +
+
+

+

+

{t('packageSection.subtitle')}

+
+ + {/* Package Selector */} +
+
+
+ + +
+ + {basePackageItem && ( +
+
+ +
+ +
+ {errors.basePackage?.unitPrice && ( + + )} +
+ +
+ + +
+
+ )} +
+ + {/* Selected Package Details Banner */} + {basePackageItem && ( +
+
+ + + {basePackageItem.name} + +
+ + {formatCurrency(basePackageItem.unitPriceMinor, currency, locale)} + +
+ )} + + {/* Deliverables Sub-section */} +
+
+

+ {t('packageSection.includedDeliverablesTitle')} +

+ + {t('packageSection.deliverablesSubtitle')} + +
+ + {/* List of deliverables */} +
+ {deliverables.length === 0 ? ( +

+ {t('packageSection.noPackages')} +

+ ) : ( + deliverables.map((item, index) => { + const rowHandlers = createDeliverableRowHandlers( + index, + onUpdateDeliverableTitle, + onUpdateDeliverableQty, + onRemoveDeliverable, + ); + return ( +
+ + + +
+ ); + }) + )} +
+ + {/* Add custom deliverable form */} +
+
+ + +
+ +
+
+
+
+ ); +} diff --git a/src/components/proposals/proposal-builder-helpers.ts b/src/components/proposals/proposal-builder-helpers.ts new file mode 100644 index 0000000..edbeaa2 --- /dev/null +++ b/src/components/proposals/proposal-builder-helpers.ts @@ -0,0 +1,1154 @@ +import type { Dispatch } from 'react'; +import { convertPriceMajorToMinor, convertPriceMinorToMajorString } from '@/lib/currency'; + +export function generateClientRowId(): string { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + + const ts = BigInt(Date.now()); + bytes[0] = Number((ts >> 40n) & 0xffn); + bytes[1] = Number((ts >> 32n) & 0xffn); + bytes[2] = Number((ts >> 24n) & 0xffn); + bytes[3] = Number((ts >> 16n) & 0xffn); + bytes[4] = Number((ts >> 8n) & 0xffn); + bytes[5] = Number(ts & 0xffn); + + // Version 7: 0b0111xxxx + bytes[6] = 0x70 | ((bytes[6] ?? 0) & 0x0f); + // Variant: 0b10xxxxxx + bytes[8] = 0x80 | ((bytes[8] ?? 0) & 0x3f); + + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} +import { calculateProposalTotals, type TaxCalculationResult } from '@/server/proposals/calculator'; +import type { ProposalWithLineItems } from '@/server/proposals/repo'; +import type { PackageWithIncludedItems } from '@/server/packages/repo'; +import type { CatalogueItem } from '@/server/db/schema/packages'; +import type { SaveDraftProposalInput, SendProposalInput } from '@/server/proposals/schema'; +import type { ProposalActionResult } from '@/server/proposals/actions'; +import type { SendProposalResult } from '@/server/proposals/service'; + +export interface DeliverableItemState { + id: string; + title: string; + quantity: number; +} + +export interface ProposalLineItemState { + id?: string; + packageId?: string | null; + catalogueItemId?: string | null; + itemType: 'base_package' | 'addon' | 'custom'; + name: string; + description?: string | null; + unitPriceMinor: number; + unitPriceMajor: string; + quantity: number; + minQuantity: number; + maxQuantity: number; + taxRateBasisPoints: number; + isOptional: boolean; + isSelected: boolean; + position: number; +} + +export interface ProposalBuilderState { + id?: string; + projectId: string; + clientId: string; + title: string; + currency: string; + pricesIncludeTax: boolean; + isKleinunternehmer: boolean; + taxNotice?: string | null; + validUntil?: string | null; + internalNotes?: string | null; + status: 'draft' | 'sent' | 'accepted' | 'declined' | 'expired' | 'superseded'; + + // Base Package details + selectedPackageId: string | null; + basePackageItem: ProposalLineItemState | null; + deliverables: DeliverableItemState[]; + + // Attached Line Items (Addons & Custom) + addonItems: ProposalLineItemState[]; + + // Send modal state + sendModalOpen: boolean; + sendModalDate: string; // YYYY-MM-DD + sendModalEmail: string; + + // Sent link / banner state + sentResult: SendProposalResult | null; + linkCopied: boolean; + + // Status flags + isSaving: boolean; + isSending: boolean; + generalError?: string | null; + saveSuccess: boolean; +} + +export interface ProposalBuilderErrors { + title?: string; + basePackage?: { unitPrice?: string }; + items?: Record; + sendDate?: string; + sendEmail?: string; + general?: string; +} + +export type ProposalBuilderAction = + | { type: 'SET_TITLE'; title: string } + | { type: 'SELECT_PACKAGE'; pkg: PackageWithIncludedItems | null } + | { type: 'UPDATE_BASE_PACKAGE_PRICE'; majorPrice: string } + | { type: 'UPDATE_BASE_PACKAGE_TAX_RATE'; taxRateBasisPoints: number } + | { type: 'ADD_DELIVERABLE'; title: string; quantity?: number } + | { type: 'UPDATE_DELIVERABLE_TITLE'; index: number; title: string } + | { type: 'UPDATE_DELIVERABLE_QTY'; index: number; quantity: number } + | { type: 'REMOVE_DELIVERABLE'; index: number } + | { type: 'ADD_CATALOGUE_ADDON'; item: CatalogueItem } + | { type: 'ADD_CUSTOM_ITEM' } + | { + type: 'UPDATE_ITEM_FIELD'; + index: number; + field: keyof ProposalLineItemState; + value: unknown; + } + | { type: 'MOVE_ITEM'; from: number; to: number } + | { type: 'REMOVE_ITEM'; index: number } + | { type: 'SET_SEND_MODAL_OPEN'; open: boolean } + | { type: 'SET_SEND_MODAL_DATE'; date: string } + | { type: 'SET_SEND_MODAL_EMAIL'; email: string } + | { type: 'SET_SAVING'; saving: boolean } + | { type: 'SET_SENDING'; sending: boolean } + | { type: 'SET_SENT_RESULT'; result: SendProposalResult | null } + | { type: 'SET_LINK_COPIED'; copied: boolean } + | { type: 'SET_SAVE_SUCCESS'; success: boolean } + | { type: 'SET_GENERAL_ERROR'; error?: string | null } + | { type: 'SET_SAVED_PROPOSAL'; id: string; status: ProposalBuilderState['status'] }; + +export function getDefaultExpirationDateString(now = new Date()): string { + const future = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); + return future.toISOString().split('T')[0] ?? ''; +} + +export function formatDeliverablesDescription( + baseDescription: string | null | undefined, + deliverables: DeliverableItemState[], +): string { + const parts: string[] = []; + if (baseDescription?.trim()) { + parts.push(baseDescription.trim()); + } + if (deliverables.length > 0) { + parts.push( + 'Included Deliverables:\n' + + deliverables + .map((d) => `• ${d.quantity > 1 ? `${String(d.quantity)}x ` : ''}${d.title}`) + .join('\n'), + ); + } + return parts.join('\n\n'); +} + +export function parseDeliverablesFromDescription( + description: string | null | undefined, +): DeliverableItemState[] { + if (!description) return []; + const lines = description.split('\n'); + const deliverables: DeliverableItemState[] = []; + let readingDeliverables = false; + const qtyRegex = /^(\d+)x\s+(.+)$/; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (line.toLowerCase().includes('included deliverables:')) { + readingDeliverables = true; + continue; + } + if (readingDeliverables) { + if (line.startsWith('•') || line.startsWith('-') || line.startsWith('*')) { + const itemText = line.replace(/^[•\-*]\s*/, '').trim(); + const qtyMatch = qtyRegex.exec(itemText); + if (qtyMatch?.[1] && qtyMatch[2]) { + deliverables.push({ + id: generateClientRowId(), + title: qtyMatch[2].trim(), + quantity: parseInt(qtyMatch[1], 10) || 1, + }); + } else { + deliverables.push({ + id: generateClientRowId(), + title: itemText, + quantity: 1, + }); + } + } + } + } + + return deliverables; +} + +export interface InitialStateParams { + project: { + id: string; + title: string; + projectTypeKey?: string | null; + currency?: string; + }; + client: { + id: string; + displayName: string; + email?: string | null; + phone?: string | null; + }; + studioSettings: { + currency: string; + isKleinunternehmer: boolean; + pricesIncludeTax: boolean; + defaultTaxRateBasisPoints: number; + kleinunternehmerNotice?: string | null; + }; + packages: PackageWithIncludedItems[]; + proposal?: ProposalWithLineItems | null; +} + +export function createInitialProposalState({ + project, + client, + studioSettings, + packages, + proposal, +}: InitialStateParams): ProposalBuilderState { + const currency = proposal?.currency ?? project.currency ?? studioSettings.currency; + const pricesIncludeTax = proposal?.pricesIncludeTax ?? studioSettings.pricesIncludeTax; + const isKleinunternehmer = proposal?.isKleinunternehmer ?? studioSettings.isKleinunternehmer; + const taxNotice = proposal?.taxNotice ?? studioSettings.kleinunternehmerNotice ?? null; + + if (proposal) { + const baseItem = proposal.lineItems.find((i) => i.itemType === 'base_package'); + const addonRows = proposal.lineItems.filter( + (i) => i.itemType === 'addon' || i.itemType === 'custom', + ); + + let deliverables: DeliverableItemState[] = []; + if (baseItem?.packageId) { + const matchedPackage = packages.find((p) => p.id === baseItem.packageId); + if (matchedPackage && matchedPackage.includedItems.length > 0) { + deliverables = matchedPackage.includedItems.map((inc) => ({ + id: inc.id, + title: inc.title, + quantity: inc.quantity, + })); + } + } + if (deliverables.length === 0 && baseItem?.description) { + deliverables = parseDeliverablesFromDescription(baseItem.description); + } + + const basePackageItem: ProposalLineItemState | null = baseItem + ? { + id: baseItem.id, + packageId: baseItem.packageId, + catalogueItemId: null, + itemType: 'base_package', + name: baseItem.name, + description: baseItem.description, + unitPriceMinor: baseItem.unitPriceMinor, + unitPriceMajor: convertPriceMinorToMajorString(baseItem.unitPriceMinor, currency), + quantity: baseItem.quantity, + minQuantity: baseItem.minQuantity, + maxQuantity: baseItem.maxQuantity, + taxRateBasisPoints: baseItem.taxRateBasisPoints, + isOptional: baseItem.isOptional, + isSelected: baseItem.isSelected, + position: baseItem.position, + } + : null; + + const addonItems: ProposalLineItemState[] = addonRows.map((item) => ({ + id: item.id, + packageId: null, + catalogueItemId: item.catalogueItemId, + itemType: item.itemType === 'custom' ? 'custom' : 'addon', + name: item.name, + description: item.description, + unitPriceMinor: item.unitPriceMinor, + unitPriceMajor: convertPriceMinorToMajorString(item.unitPriceMinor, currency), + quantity: item.quantity, + minQuantity: item.minQuantity, + maxQuantity: item.maxQuantity, + taxRateBasisPoints: item.taxRateBasisPoints, + isOptional: item.isOptional, + isSelected: item.isSelected, + position: item.position, + })); + + const validUntilStr = proposal.validUntil + ? (new Date(proposal.validUntil).toISOString().split('T')[0] ?? '') + : getDefaultExpirationDateString(); + + return { + id: proposal.id, + projectId: proposal.projectId, + clientId: proposal.clientId, + title: proposal.title, + currency, + pricesIncludeTax, + isKleinunternehmer, + taxNotice, + validUntil: proposal.validUntil ? new Date(proposal.validUntil).toISOString() : null, + internalNotes: proposal.internalNotes, + status: proposal.status, + selectedPackageId: baseItem?.packageId ?? null, + basePackageItem, + deliverables, + addonItems, + sendModalOpen: false, + sendModalDate: validUntilStr, + sendModalEmail: client.email ?? '', + sentResult: null, + linkCopied: false, + isSaving: false, + isSending: false, + saveSuccess: false, + }; + } + + // Creating new proposal + return { + projectId: project.id, + clientId: client.id, + title: `${project.title} — Proposal`, + currency, + pricesIncludeTax, + isKleinunternehmer, + taxNotice, + validUntil: null, + internalNotes: null, + status: 'draft', + selectedPackageId: null, + basePackageItem: null, + deliverables: [], + addonItems: [], + sendModalOpen: false, + sendModalDate: getDefaultExpirationDateString(), + sendModalEmail: client.email ?? '', + sentResult: null, + linkCopied: false, + isSaving: false, + isSending: false, + saveSuccess: false, + }; +} + +export function calculateProposalStateTotals(state: ProposalBuilderState): TaxCalculationResult { + const allItems = [...(state.basePackageItem ? [state.basePackageItem] : []), ...state.addonItems]; + + return calculateProposalTotals( + allItems.map((item) => ({ + unitPriceMinor: item.unitPriceMinor, + quantity: item.quantity, + taxRateBasisPoints: item.taxRateBasisPoints, + isSelected: item.isSelected, + })), + { + pricesIncludeTax: state.pricesIncludeTax, + isKleinunternehmer: state.isKleinunternehmer, + }, + ); +} + +export function validateProposalForm( + state: ProposalBuilderState, + t: (key: string) => string, +): { isValid: boolean; errors: ProposalBuilderErrors } { + const errors: ProposalBuilderErrors = {}; + + const trimmedTitle = state.title.trim(); + if (!trimmedTitle) { + errors.title = t('validation.titleRequired'); + } else if (trimmedTitle.length > 200) { + errors.title = t('validation.titleTooLong'); + } + + const itemErrors: Record = {}; + + // Validate base package item if present + if (state.basePackageItem) { + if (state.basePackageItem.unitPriceMinor < 0) { + errors.basePackage = { unitPrice: t('validation.negativePrice') }; + } + } + + // Validate addon / custom items + state.addonItems.forEach((item, index) => { + const errorEntry: { name?: string; unitPrice?: string } = {}; + if (!item.name.trim()) { + errorEntry.name = t('validation.nameRequired'); + } + if (item.unitPriceMinor < 0) { + errorEntry.unitPrice = t('validation.negativePrice'); + } + if (errorEntry.name || errorEntry.unitPrice) { + // eslint-disable-next-line security/detect-object-injection -- index is bounded numeric array index from forEach + itemErrors[index] = errorEntry; + } + }); + + if (Object.keys(itemErrors).length > 0) { + errors.items = itemErrors; + } + + const isValid = !errors.title && !errors.basePackage && !errors.items && !errors.general; + return { isValid, errors }; +} + +export function validateSendModal( + state: ProposalBuilderState, + t: (key: string) => string, +): { isValid: boolean; errors: ProposalBuilderErrors } { + const errors: ProposalBuilderErrors = {}; + + if (!state.sendModalDate) { + errors.sendDate = t('validation.dateInPast'); + } else { + const targetDate = new Date(`${state.sendModalDate}T23:59:59.999Z`); + if (targetDate.getTime() <= Date.now()) { + errors.sendDate = t('validation.dateInPast'); + } + } + + if (state.sendModalEmail.trim()) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(state.sendModalEmail.trim())) { + errors.sendEmail = t('validation.invalidEmail'); + } + } + + const isValid = !errors.sendDate && !errors.sendEmail; + return { isValid, errors }; +} + +export function proposalBuilderReducer( + state: ProposalBuilderState, + action: ProposalBuilderAction, +): ProposalBuilderState { + switch (action.type) { + case 'SET_TITLE': { + return { ...state, title: action.title, saveSuccess: false }; + } + + case 'SELECT_PACKAGE': { + const pkg = action.pkg; + if (!pkg) { + return { + ...state, + selectedPackageId: null, + basePackageItem: null, + deliverables: [], + saveSuccess: false, + }; + } + + const deliverables: DeliverableItemState[] = pkg.includedItems.map((inc) => ({ + id: inc.id, + title: inc.title, + quantity: inc.quantity, + })); + + const basePackageItem: ProposalLineItemState = { + id: state.basePackageItem?.id, + packageId: pkg.id, + catalogueItemId: null, + itemType: 'base_package', + name: pkg.name, + description: pkg.description, + unitPriceMinor: pkg.basePriceMinor, + unitPriceMajor: convertPriceMinorToMajorString(pkg.basePriceMinor, state.currency), + quantity: 1, + minQuantity: 1, + maxQuantity: 1, + taxRateBasisPoints: pkg.taxRateBasisPoints, + isOptional: false, + isSelected: true, + position: 0, + }; + + // Set proposal title if generic + let updatedTitle = state.title; + if ( + !updatedTitle.trim() || + updatedTitle.endsWith('— Proposal') || + updatedTitle === 'Proposal' + ) { + const prefix = state.title.includes('—') ? state.title.split('—')[0]?.trim() : state.title; + updatedTitle = `${prefix ?? 'Project'} — ${pkg.name}`; + } + + return { + ...state, + selectedPackageId: pkg.id, + basePackageItem, + deliverables, + title: updatedTitle, + saveSuccess: false, + }; + } + + case 'UPDATE_BASE_PACKAGE_PRICE': { + if (!state.basePackageItem) return state; + const minor = convertPriceMajorToMinor(action.majorPrice, state.currency); + const isNegative = action.majorPrice.trim().startsWith('-'); + const finalMinor = isNegative ? -1 : (minor ?? 0); + + return { + ...state, + basePackageItem: { + ...state.basePackageItem, + unitPriceMajor: action.majorPrice, + unitPriceMinor: finalMinor, + }, + saveSuccess: false, + }; + } + + case 'UPDATE_BASE_PACKAGE_TAX_RATE': { + if (!state.basePackageItem) return state; + return { + ...state, + basePackageItem: { + ...state.basePackageItem, + taxRateBasisPoints: action.taxRateBasisPoints, + }, + saveSuccess: false, + }; + } + + case 'ADD_DELIVERABLE': { + const newDeliverable: DeliverableItemState = { + id: generateClientRowId(), + title: action.title, + quantity: action.quantity ?? 1, + }; + return { + ...state, + deliverables: [...state.deliverables, newDeliverable], + saveSuccess: false, + }; + } + + case 'UPDATE_DELIVERABLE_TITLE': { + const deliverables = state.deliverables.map((d, i) => + i === action.index ? { ...d, title: action.title } : d, + ); + return { ...state, deliverables, saveSuccess: false }; + } + + case 'UPDATE_DELIVERABLE_QTY': { + const deliverables = state.deliverables.map((d, i) => + i === action.index ? { ...d, quantity: action.quantity } : d, + ); + return { ...state, deliverables, saveSuccess: false }; + } + + case 'REMOVE_DELIVERABLE': { + const deliverables = state.deliverables.filter((_, i) => i !== action.index); + return { ...state, deliverables, saveSuccess: false }; + } + + case 'ADD_CATALOGUE_ADDON': { + const item = action.item; + const newItem: ProposalLineItemState = { + id: generateClientRowId(), + packageId: null, + catalogueItemId: item.id, + itemType: 'addon', + name: item.name, + description: item.description, + unitPriceMinor: item.unitPriceMinor, + unitPriceMajor: convertPriceMinorToMajorString(item.unitPriceMinor, state.currency), + quantity: 1, + minQuantity: 0, + maxQuantity: 1, + taxRateBasisPoints: item.taxRateBasisPoints, + isOptional: false, + isSelected: true, + position: state.addonItems.length, + }; + return { + ...state, + addonItems: [...state.addonItems, newItem], + saveSuccess: false, + }; + } + + case 'ADD_CUSTOM_ITEM': { + const defaultTax = state.basePackageItem?.taxRateBasisPoints ?? 1900; + const newItem: ProposalLineItemState = { + id: generateClientRowId(), + packageId: null, + catalogueItemId: null, + itemType: 'custom', + name: '', + description: '', + unitPriceMinor: 0, + unitPriceMajor: '0.00', + quantity: 1, + minQuantity: 0, + maxQuantity: 1, + taxRateBasisPoints: defaultTax, + isOptional: false, + isSelected: true, + position: state.addonItems.length, + }; + return { + ...state, + addonItems: [...state.addonItems, newItem], + saveSuccess: false, + }; + } + + case 'UPDATE_ITEM_FIELD': { + const { index, field, value } = action; + const items = state.addonItems.map((item, i) => { + if (i !== index) return item; + if (field === 'unitPriceMajor') { + const strVal = String(value); + const minor = convertPriceMajorToMinor(strVal, state.currency); + const isNegative = strVal.trim().startsWith('-'); + const finalMinor = isNegative ? -1 : (minor ?? 0); + return { + ...item, + unitPriceMajor: strVal, + unitPriceMinor: finalMinor, + }; + } + return { ...item, [field]: value }; + }); + return { ...state, addonItems: items, saveSuccess: false }; + } + + case 'MOVE_ITEM': { + const { from, to } = action; + if (from < 0 || from >= state.addonItems.length || to < 0 || to >= state.addonItems.length) { + return state; + } + const items = [...state.addonItems]; + const [moved] = items.splice(from, 1); + if (moved) { + items.splice(to, 0, moved); + } + return { + ...state, + addonItems: items.map((it, idx) => ({ ...it, position: idx })), + saveSuccess: false, + }; + } + + case 'REMOVE_ITEM': { + const items = state.addonItems.filter((_, i) => i !== action.index); + return { + ...state, + addonItems: items.map((it, idx) => ({ ...it, position: idx })), + saveSuccess: false, + }; + } + + case 'SET_SEND_MODAL_OPEN': { + return { ...state, sendModalOpen: action.open }; + } + + case 'SET_SEND_MODAL_DATE': { + return { ...state, sendModalDate: action.date }; + } + + case 'SET_SEND_MODAL_EMAIL': { + return { ...state, sendModalEmail: action.email }; + } + + case 'SET_SAVING': { + return { ...state, isSaving: action.saving }; + } + + case 'SET_SENDING': { + return { ...state, isSending: action.sending }; + } + + case 'SET_SENT_RESULT': { + return { + ...state, + sentResult: action.result, + status: action.result ? 'sent' : state.status, + }; + } + + case 'SET_LINK_COPIED': { + return { ...state, linkCopied: action.copied }; + } + + case 'SET_SAVE_SUCCESS': { + return { ...state, saveSuccess: action.success }; + } + + case 'SET_GENERAL_ERROR': { + return { ...state, generalError: action.error ?? null }; + } + + case 'SET_SAVED_PROPOSAL': { + return { ...state, id: action.id, status: action.status, saveSuccess: true }; + } + + default: + return state; + } +} + +export function buildSaveDraftPayload(state: ProposalBuilderState): SaveDraftProposalInput { + const allItems: SaveDraftProposalInput['lineItems'] = []; + + if (state.basePackageItem) { + const descWithDeliverables = formatDeliverablesDescription( + state.basePackageItem.description, + state.deliverables, + ); + allItems.push({ + id: state.basePackageItem.id, + packageId: state.basePackageItem.packageId ?? null, + catalogueItemId: null, + itemType: 'base_package', + name: state.basePackageItem.name.trim(), + description: descWithDeliverables || null, + unitPriceMinor: Math.max(0, state.basePackageItem.unitPriceMinor), + quantity: 1, + minQuantity: 1, + maxQuantity: 1, + taxRateBasisPoints: state.basePackageItem.taxRateBasisPoints, + isOptional: false, + isSelected: true, + position: 0, + }); + } + + state.addonItems.forEach((item, index) => { + allItems.push({ + id: item.id, + packageId: null, + catalogueItemId: item.catalogueItemId ?? null, + itemType: item.itemType, + name: item.name.trim(), + description: item.description?.trim() ? item.description.trim() : null, + unitPriceMinor: Math.max(0, item.unitPriceMinor), + quantity: item.quantity, + minQuantity: item.minQuantity, + maxQuantity: item.maxQuantity, + taxRateBasisPoints: item.taxRateBasisPoints, + isOptional: item.isOptional, + isSelected: item.isSelected, + position: state.basePackageItem ? index + 1 : index, + }); + }); + + return { + id: state.id, + projectId: state.projectId, + clientId: state.clientId, + title: state.title.trim(), + currency: state.currency, + pricesIncludeTax: state.pricesIncludeTax, + isKleinunternehmer: state.isKleinunternehmer, + taxNotice: state.taxNotice ?? null, + validUntil: state.validUntil ? new Date(state.validUntil) : null, + internalNotes: state.internalNotes ?? null, + lineItems: allItems, + }; +} + +export function buildSendPayload(state: ProposalBuilderState): SendProposalInput { + if (!state.id) { + throw new Error('Cannot send proposal without saved ID'); + } + + const validUntil = state.sendModalDate + ? new Date(`${state.sendModalDate}T23:59:59.999Z`) + : undefined; + + return { + proposalId: state.id, + validUntil, + recipientEmail: state.sendModalEmail.trim() || undefined, + }; +} + +export interface ProposalBuilderViewActionHandlersParams { + state: ProposalBuilderState; + getState?: () => ProposalBuilderState; + dispatch: Dispatch; + t: (key: string) => string; + router: { push: (url: string) => void }; + locale?: string; + saveDraftAction: (input: unknown) => Promise>; + sendAction: (input: unknown) => Promise>; +} + +export function createProposalBuilderActionHandlers({ + state: initialState, + getState, + dispatch, + t, + router, + saveDraftAction, + sendAction, +}: ProposalBuilderViewActionHandlersParams) { + const getCurrentState = () => (getState ? getState() : initialState); + + const onSaveDraft = async () => { + const state = getCurrentState(); + const { isValid, errors } = validateProposalForm(state, t); + if (!isValid) { + dispatch({ + type: 'SET_GENERAL_ERROR', + error: errors.title ?? Object.values(errors.items ?? {})[0]?.unitPrice, + }); + return; + } + + dispatch({ type: 'SET_SAVING', saving: true }); + dispatch({ type: 'SET_GENERAL_ERROR', error: null }); + + try { + const payload = buildSaveDraftPayload(state); + const result = await saveDraftAction(payload); + + if (result.success) { + dispatch({ + type: 'SET_SAVED_PROPOSAL', + id: result.data.id, + status: result.data.status, + }); + if (!state.id) { + // Redirect to edit route + router.push(`/projects/${state.projectId}/proposals/${result.data.id}`); + } + } else { + dispatch({ type: 'SET_GENERAL_ERROR', error: result.error }); + } + } catch (err) { + dispatch({ + type: 'SET_GENERAL_ERROR', + error: err instanceof Error ? err.message : 'Error saving draft proposal', + }); + } finally { + dispatch({ type: 'SET_SAVING', saving: false }); + } + }; + + const onOpenSendModal = async () => { + const state = getCurrentState(); + // If proposal has not been saved yet, save it first + if (!state.id) { + const { isValid, errors } = validateProposalForm(state, t); + if (!isValid) { + dispatch({ + type: 'SET_GENERAL_ERROR', + error: errors.title ?? Object.values(errors.items ?? {})[0]?.unitPrice, + }); + return; + } + dispatch({ type: 'SET_SAVING', saving: true }); + try { + const payload = buildSaveDraftPayload(state); + const result = await saveDraftAction(payload); + if (result.success) { + dispatch({ + type: 'SET_SAVED_PROPOSAL', + id: result.data.id, + status: result.data.status, + }); + dispatch({ type: 'SET_SEND_MODAL_OPEN', open: true }); + } else { + dispatch({ type: 'SET_GENERAL_ERROR', error: result.error }); + } + } finally { + dispatch({ type: 'SET_SAVING', saving: false }); + } + return; + } + + dispatch({ type: 'SET_SEND_MODAL_OPEN', open: true }); + }; + + const onSubmitSendProposal = async () => { + const state = getCurrentState(); + const { isValid, errors } = validateSendModal(state, t); + if (!isValid) { + dispatch({ + type: 'SET_GENERAL_ERROR', + error: errors.sendDate ?? errors.sendEmail, + }); + return; + } + + dispatch({ type: 'SET_SENDING', sending: true }); + dispatch({ type: 'SET_GENERAL_ERROR', error: null }); + + try { + const payload = buildSendPayload(state); + const result = await sendAction(payload); + + if (result.success) { + dispatch({ type: 'SET_SENT_RESULT', result: result.data }); + dispatch({ type: 'SET_SEND_MODAL_OPEN', open: false }); + } else { + dispatch({ type: 'SET_GENERAL_ERROR', error: result.error }); + } + } catch (err) { + dispatch({ + type: 'SET_GENERAL_ERROR', + error: err instanceof Error ? err.message : 'Error sending proposal', + }); + } finally { + dispatch({ type: 'SET_SENDING', sending: false }); + } + }; + + const onCopyLink = async () => { + const state = getCurrentState(); + if (!state.sentResult?.tokenUrl) return; + try { + await navigator.clipboard.writeText(state.sentResult.tokenUrl); + } catch { + // Ignore clipboard permission issues in headless environments + } + dispatch({ type: 'SET_LINK_COPIED', copied: true }); + }; + + return { + onSaveDraft, + onOpenSendModal, + onSubmitSendProposal, + onCopyLink, + }; +} + +export function createProposalBuilderViewHandlers(dispatch: Dispatch) { + return { + onTitleChange: (title: string) => { + dispatch({ type: 'SET_TITLE', title }); + }, + onSelectPackage: (pkg: PackageWithIncludedItems | null) => { + dispatch({ type: 'SELECT_PACKAGE', pkg }); + }, + onUpdateBasePrice: (majorPrice: string) => { + dispatch({ type: 'UPDATE_BASE_PACKAGE_PRICE', majorPrice }); + }, + onUpdateTaxRate: (taxRateBasisPoints: number) => { + dispatch({ type: 'UPDATE_BASE_PACKAGE_TAX_RATE', taxRateBasisPoints }); + }, + onAddDeliverable: (title: string, quantity?: number) => { + dispatch({ type: 'ADD_DELIVERABLE', title, quantity }); + }, + onUpdateDeliverableTitle: (index: number, title: string) => { + dispatch({ type: 'UPDATE_DELIVERABLE_TITLE', index, title }); + }, + onUpdateDeliverableQty: (index: number, quantity: number) => { + dispatch({ type: 'UPDATE_DELIVERABLE_QTY', index, quantity }); + }, + onRemoveDeliverable: (index: number) => { + dispatch({ type: 'REMOVE_DELIVERABLE', index }); + }, + onAddCatalogueItem: (item: CatalogueItem) => { + dispatch({ type: 'ADD_CATALOGUE_ADDON', item }); + }, + onAddCustomItem: () => { + dispatch({ type: 'ADD_CUSTOM_ITEM' }); + }, + onUpdateItemField: (index: number, field: keyof ProposalLineItemState, value: unknown) => { + dispatch({ type: 'UPDATE_ITEM_FIELD', index, field, value }); + }, + onMoveItem: (from: number, to: number) => { + dispatch({ type: 'MOVE_ITEM', from, to }); + }, + onRemoveItem: (index: number) => { + dispatch({ type: 'REMOVE_ITEM', index }); + }, + onCloseSendModal: () => { + dispatch({ type: 'SET_SEND_MODAL_OPEN', open: false }); + dispatch({ type: 'SET_SENT_RESULT', result: null }); + }, + onSendDateChange: (date: string) => { + dispatch({ type: 'SET_SEND_MODAL_DATE', date }); + }, + onSendEmailChange: (email: string) => { + dispatch({ type: 'SET_SEND_MODAL_EMAIL', email }); + }, + handleTitleInputChange: (e: React.ChangeEvent) => { + dispatch({ type: 'SET_TITLE', title: e.target.value }); + }, + }; +} + +export function createDeliverableRowHandlers( + index: number, + onUpdateTitle: (index: number, title: string) => void, + onUpdateQty: (index: number, qty: number) => void, + onRemove: (index: number) => void, +) { + return { + handleQtyChange: (e: React.ChangeEvent) => { + onUpdateQty(index, parseInt(e.target.value, 10) || 1); + }, + handleTitleChange: (e: React.ChangeEvent) => { + onUpdateTitle(index, e.target.value); + }, + handleRemove: () => { + onRemove(index); + }, + }; +} + +export function createAddonRowHandlers( + index: number, + onUpdateField: (index: number, field: keyof ProposalLineItemState, value: unknown) => void, + onMoveItem: (from: number, to: number) => void, + onRemoveItem: (index: number) => void, +) { + return { + handleNameChange: (e: React.ChangeEvent) => { + onUpdateField(index, 'name', e.target.value); + }, + handlePriceChange: (e: React.ChangeEvent) => { + onUpdateField(index, 'unitPriceMajor', e.target.value); + }, + handleQuantityChange: (e: React.ChangeEvent) => { + onUpdateField(index, 'quantity', parseInt(e.target.value, 10) || 0); + }, + handleMaxQuantityChange: (e: React.ChangeEvent) => { + onUpdateField(index, 'maxQuantity', parseInt(e.target.value, 10) || 1); + }, + handleTaxRateChange: (e: React.ChangeEvent) => { + onUpdateField(index, 'taxRateBasisPoints', parseInt(e.target.value, 10)); + }, + handleOptionalChange: (e: React.ChangeEvent) => { + onUpdateField(index, 'isOptional', e.target.checked); + }, + handleMoveUp: () => { + onMoveItem(index, index - 1); + }, + handleMoveDown: () => { + onMoveItem(index, index + 1); + }, + handleRemove: () => { + onRemoveItem(index); + }, + }; +} + +export function createPackageSectionController( + onSelectPackage: (pkg: PackageWithIncludedItems | null) => void, + onUpdateBasePrice: (majorPrice: string) => void, + onUpdateTaxRate: (taxRateBasisPoints: number) => void, + onAddDeliverable: (title: string, quantity?: number) => void, + packages: PackageWithIncludedItems[], + setNewDeliverableTitle?: (val: string) => void, + setNewDeliverableQty?: (val: string) => void, +) { + return { + handlePackageSelectChange: (e: React.ChangeEvent | string) => { + const pkgId = typeof e === 'string' ? e : e.target.value; + if (!pkgId) { + onSelectPackage(null); + return; + } + const found = packages.find((p) => p.id === pkgId) ?? null; + onSelectPackage(found); + }, + handleBasePriceChange: (e: React.ChangeEvent) => { + onUpdateBasePrice(e.target.value); + }, + handleTaxRateChange: (e: React.ChangeEvent) => { + onUpdateTaxRate(parseInt(e.target.value, 10)); + }, + handleAddDeliverable: (title: string, qtyStr: string) => { + if (!title.trim()) return false; + const qty = parseInt(qtyStr, 10) || 1; + onAddDeliverable(title.trim(), qty); + return true; + }, + handleQtyInputChange: (e: React.ChangeEvent) => { + setNewDeliverableQty?.(e.target.value); + }, + handleTitleInputChange: (e: React.ChangeEvent) => { + setNewDeliverableTitle?.(e.target.value); + }, + handleAddDeliverableSubmit: (title: string, qtyStr: string) => (e: React.SyntheticEvent) => { + e.preventDefault(); + if (!title.trim()) return false; + const qty = parseInt(qtyStr, 10) || 1; + onAddDeliverable(title.trim(), qty); + setNewDeliverableTitle?.(''); + setNewDeliverableQty?.('1'); + return true; + }, + }; +} + +export function createAddonSectionController( + catalogueItems: CatalogueItem[], + onAddCatalogueItem: (item: CatalogueItem) => void, + setSelectedCatalogueId?: (id: string) => void, +) { + return { + findAndAddCatalogueItem: (catalogueId: string) => { + if (!catalogueId) return false; + const item = catalogueItems.find((i) => i.id === catalogueId); + if (item) { + onAddCatalogueItem(item); + setSelectedCatalogueId?.(''); + return true; + } + return false; + }, + handleCatalogueSelectChange: (e: React.ChangeEvent) => { + setSelectedCatalogueId?.(e.target.value); + }, + handleAddCatalogueClick: (selectedCatalogueId: string) => () => { + if (!selectedCatalogueId) return false; + const item = catalogueItems.find((i) => i.id === selectedCatalogueId); + if (item) { + onAddCatalogueItem(item); + setSelectedCatalogueId?.(''); + return true; + } + return false; + }, + }; +} + +export function createSendModalController( + onClose: () => void, + onDateChange?: (date: string) => void, + onEmailChange?: (email: string) => void, + triggerRef?: React.RefObject, +) { + return { + handleOpenChange: (open: boolean) => { + if (!open) { + onClose(); + } + }, + handleCloseAutoFocus: (e: Event) => { + e.preventDefault(); + triggerRef?.current?.focus(); + }, + handleDateChange: (e: React.ChangeEvent) => { + onDateChange?.(e.target.value); + }, + handleEmailChange: (e: React.ChangeEvent) => { + onEmailChange?.(e.target.value); + }, + }; +} diff --git a/src/components/proposals/proposal-builder-view.tsx b/src/components/proposals/proposal-builder-view.tsx new file mode 100644 index 0000000..1aeed41 --- /dev/null +++ b/src/components/proposals/proposal-builder-view.tsx @@ -0,0 +1,272 @@ +'use client'; + +import * as React from 'react'; +import { useTranslations } from 'next-intl'; +import { Link, useRouter } from '@/i18n/navigation'; +import { ChevronRight, FileText, User } from 'lucide-react'; +import { saveDraftProposalAction, sendProposalAction } from '@/server/proposals/actions'; +import type { ProposalWithLineItems } from '@/server/proposals/repo'; +import type { PackageWithIncludedItems } from '@/server/packages/repo'; +import type { CatalogueItem } from '@/server/db/schema/packages'; +import type { StudioSettings } from '@/server/db/schema/studio-settings'; +import { + createInitialProposalState, + proposalBuilderReducer, + validateProposalForm, + createProposalBuilderActionHandlers, + createProposalBuilderViewHandlers, +} from './proposal-builder-helpers'; +import { PackageSelectorSection } from './package-selector-section'; +import { AddonOptionsSection } from './addon-options-section'; +import { FinancialSummarySection } from './financial-summary-section'; +import { SendProposalModal } from './send-proposal-modal'; + +export interface ProposalBuilderViewProps { + project: { + id: string; + title: string; + projectTypeKey?: string | null; + currency?: string; + }; + client: { + id: string; + displayName: string; + email?: string | null; + phone?: string | null; + }; + packages: PackageWithIncludedItems[]; + catalogueItems: CatalogueItem[]; + studioSettings: StudioSettings; + proposal?: ProposalWithLineItems | null; + locale?: string; +} + +export function ProposalBuilderView({ + project, + client, + packages, + catalogueItems, + studioSettings, + proposal, + locale = 'en', +}: ProposalBuilderViewProps) { + const t = useTranslations('proposals.builder'); + const router = useRouter(); + const sendButtonRef = React.useRef(null); + + const [state, dispatch] = React.useReducer( + proposalBuilderReducer, + { + project, + client, + studioSettings: { + currency: studioSettings.currency, + isKleinunternehmer: studioSettings.is_kleinunternehmer, + pricesIncludeTax: studioSettings.prices_include_tax, + defaultTaxRateBasisPoints: studioSettings.default_tax_rate_basis_points, + kleinunternehmerNotice: studioSettings.kleinunternehmer_notice, + }, + packages, + proposal, + }, + createInitialProposalState, + ); + + const { errors } = validateProposalForm(state, t); + + const actionHandlers = createProposalBuilderActionHandlers({ + state, + dispatch, + t, + router, + locale, + saveDraftAction: saveDraftProposalAction, + sendAction: sendProposalAction, + }); + + const viewHandlers = createProposalBuilderViewHandlers(dispatch); + + return ( +
+ {/* Breadcrumb Navigation */} + + + {/* Top Header Card: Title, Status, and Client Info */} +
+
+
+
+
+
+

+ {state.id ? t('editTitle') : t('title')} +

+

+ {t('projectLabel')}:{' '} + {project.title} +

+
+
+ + {/* Status Badge */} +
+ + {t(`status.${state.status}`)} + + {state.isKleinunternehmer && ( + + {t('kleinunternehmerBadge')} + + )} +
+
+ + {/* Client & Title Input Row */} +
+ {/* Proposal Title Input */} +
+ + + {errors.title && ( + + )} +
+ + {/* Client Recipient Summary Card */} +
+
+
+

+ {client.displayName} +

+ {client.email && ( +

+ {client.email} +

+ )} +
+
+
+ + {/* 1. Base Service Package Section */} + + + {/* 2. Add-on Options & Custom Items Section */} + + + {/* 3. Commercial Summary & Dispatch Section */} + + + {/* Single Modal: Send Proposal */} + +
+ ); +} diff --git a/src/components/proposals/send-proposal-modal.tsx b/src/components/proposals/send-proposal-modal.tsx new file mode 100644 index 0000000..4767797 --- /dev/null +++ b/src/components/proposals/send-proposal-modal.tsx @@ -0,0 +1,231 @@ +'use client'; + +import * as React from 'react'; +import { useTranslations } from 'next-intl'; +import { Send, Check, Copy, CheckCircle2 } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { + type ProposalBuilderState, + type ProposalBuilderErrors, + validateSendModal, + createSendModalController, +} from './proposal-builder-helpers'; + +export interface SendProposalModalProps { + state: ProposalBuilderState; + errors: ProposalBuilderErrors; + triggerRef?: React.RefObject; + onClose: () => void; + onDateChange: (date: string) => void; + onEmailChange: (email: string) => void; + onSubmit: () => void | Promise; + onCopyLink: () => void | Promise; +} + +export function SendProposalModal({ + state, + triggerRef, + onClose, + onDateChange, + onEmailChange, + onSubmit, + onCopyLink, +}: SendProposalModalProps) { + const t = useTranslations('proposals.builder'); + const modalErrors = validateSendModal(state, t).errors; + const isFormValid = !modalErrors.sendDate && !modalErrors.sendEmail; + const controller = createSendModalController(onClose, onDateChange, onEmailChange, triggerRef); + + return ( + + + {state.sentResult ? ( + // Success State with Copy Link +
+ +
+
+ + {t('linkCopied.description')} + +
+ +
+ +
+ + +
+
+ + + + +
+ ) : ( + // Form State +
+ + + {t('sendModal.title')} + + + {t('sendModal.description')} + + + +
+ {/* Recipient Email */} +
+ + + {modalErrors.sendEmail && ( + + )} +
+ + {/* Validity Expiration Date */} +
+ + + {modalErrors.sendDate ? ( + + ) : ( +

+ {t('sendModal.validUntilHelp')} +

+ )} +
+
+ + + + + +
+ )} +
+
+ ); +} diff --git a/tests/e2e/proposal-builder.spec.ts b/tests/e2e/proposal-builder.spec.ts new file mode 100644 index 0000000..114621f --- /dev/null +++ b/tests/e2e/proposal-builder.spec.ts @@ -0,0 +1,459 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import fs from 'node:fs'; +import path from 'node:path'; +import { E2E_OWNER_EMAIL, E2E_OWNER_PASSWORD } from '../helpers/e2e-global-setup'; +import { E2E_PROJECT_INQUIRY_1_ID } from '../helpers/e2e-projects-helper'; +import { + E2E_PACKAGE_WEDDING_ID, + E2E_CATALOGUE_ITEM_1_ID, + E2E_DRAFT_PROPOSAL_ID, +} from '../helpers/e2e-proposals-helper'; + +const VALID_SETUP_TOKEN = + process.env.SETUP_TOKEN ?? 'c4d7e8f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9'; + +function getLatestRunDir(): string { + if (process.env.RUN_DIR) return process.env.RUN_DIR; + const runsRoot = path.resolve(import.meta.dirname, '../../../../control/ownlight/runs/TASK-0061'); + if (fs.existsSync(runsRoot)) { + const entries = fs + .readdirSync(runsRoot) + .filter((d) => d.startsWith('iter-')) + .sort(); + const last = entries[entries.length - 1]; + if (last) { + return last; + } + } + return 'iter-01'; +} + +const RUN_DIR_PARAM = getLatestRunDir(); +const UX_RUN_DIR = path.resolve( + import.meta.dirname, + `../../../../control/ownlight/runs/TASK-0061/${RUN_DIR_PARAM}/ux`, +); + +async function ensureOwnerBootstrappedAndSignedIn(page: import('@playwright/test').Page) { + await page.goto('/en/setup'); + const setupForm = page.locator('[data-testid="setup-form"]'); + if (await setupForm.isVisible({ timeout: 1500 }).catch(() => false)) { + await page.fill('[data-testid="setup-token-input"]', VALID_SETUP_TOKEN); + await page.fill('[data-testid="name-input"]', 'Studio Owner'); + await page.fill('[data-testid="email-input"]', E2E_OWNER_EMAIL); + await page.fill('[data-testid="password-input"]', E2E_OWNER_PASSWORD); + await page.click('[data-testid="setup-submit-btn"]'); + await expect(page).not.toHaveURL(/\/setup/); + return; + } + + await page.goto('/en/sign-in'); + const emailInput = page.locator('[data-testid="email-input"]'); + if (await emailInput.isVisible({ timeout: 1500 }).catch(() => false)) { + await emailInput.fill(E2E_OWNER_EMAIL); + await page.fill('[data-testid="password-input"]', E2E_OWNER_PASSWORD); + await page.click('[data-testid="signin-submit-btn"]'); + await expect(page).not.toHaveURL(/\/sign-in$/); + } +} + +test.describe('TASK-0061: Proposal Builder Project Workspace UI E2E', () => { + test.beforeAll(() => { + fs.mkdirSync(UX_RUN_DIR, { recursive: true }); + }); + + test.beforeEach(async ({ page }) => { + await ensureOwnerBootstrappedAndSignedIn(page); + }); + + test('AC-5: Unauthenticated visitor to proposal builder routes is redirected to sign-in', async ({ + browser, + }) => { + const unauthContext = await browser.newContext(); + const unauthPage = await unauthContext.newPage(); + + await unauthPage.goto(`/en/projects/${E2E_PROJECT_INQUIRY_1_ID}/proposals/new`); + await expect(unauthPage).toHaveURL(new RegExp(`.*\\/sign-in\\?callbackUrl=.*proposals%2Fnew`)); + + const bodyText = await unauthPage.content(); + expect(bodyText).not.toContain('data-testid="proposal-builder-view"'); + expect(bodyText).not.toContain('data-testid="package-selector-section"'); + + await unauthContext.close(); + }); + + test('AC-1 & AC-2 & AC-3: Full proposal composer flow with package, add-on, custom item, and draft save', async ({ + page, + }) => { + test.setTimeout(90000); + + // 1. Navigate to proposal builder composer + await page.goto(`/en/projects/${E2E_PROJECT_INQUIRY_1_ID}/proposals/new`); + await expect(page.locator('[data-testid="proposal-builder-view"]')).toBeVisible(); + + // Verify breadcrumbs and client review card + await expect(page.locator('[data-testid="breadcrumb-projects"]')).toBeVisible(); + await expect(page.locator('[data-testid="breadcrumb-project-detail"]')).toBeVisible(); + await expect(page.locator('[data-testid="client-review-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="proposal-client-name"]')).toBeVisible(); + await expect(page.locator('[data-testid="proposal-status-badge"]')).toContainText('Draft'); + + // 2. Select package from catalogue + const packageSelect = page.locator('[data-testid="package-select"]'); + await expect(packageSelect).toBeVisible(); + await packageSelect.selectOption(E2E_PACKAGE_WEDDING_ID); + + // Verify base price populated (€2,800.00 -> 2800.00) + const basePriceInput = page.locator('[data-testid="base-package-price-input"]'); + await expect(basePriceInput).toHaveValue('2800.00'); + + // Verify deliverables populated + await expect(page.locator('[data-testid="deliverable-item-0"]')).toBeVisible(); + await expect(page.locator('[data-testid="deliverable-title-input-0"]')).toHaveValue( + '8 Hours Continuous Coverage', + ); + await expect(page.locator('[data-testid="deliverable-item-1"]')).toBeVisible(); + await expect(page.locator('[data-testid="deliverable-title-input-1"]')).toHaveValue( + '400+ High-Resolution Edited Images', + ); + + // Edit deliverable 0 title and deliverable 1 quantity + await page.fill('[data-testid="deliverable-title-input-0"]', '9 Hours Continuous Coverage'); + await page.fill('[data-testid="deliverable-qty-input-1"]', '500'); + + // Add a third custom deliverable + await page.fill('[data-testid="new-deliverable-qty-input"]', '1'); + await page.fill( + '[data-testid="new-deliverable-title-input"]', + 'Sneak Peek Gallery within 48 Hours', + ); + await page.locator('[data-testid="add-deliverable-btn"]').click(); + await expect(page.locator('[data-testid="deliverable-item-2"]')).toBeVisible(); + + // 3. Add Catalogue Add-on Item (AC-2) + const addonSelect = page.locator('[data-testid="catalogue-addon-select"]'); + await addonSelect.selectOption(E2E_CATALOGUE_ITEM_1_ID); + await page.locator('[data-testid="add-catalogue-addon-btn"]').click(); + + // Verify add-on item added + await expect(page.locator('[data-testid="addon-row-0"]')).toBeVisible(); + await expect(page.locator('[data-testid="addon-name-0"]')).toHaveValue( + 'Extra Photography Hour', + ); + await expect(page.locator('[data-testid="addon-price-0"]')).toHaveValue('250.00'); + + // Mark as optional and set max quantity = 2 + const optionalToggle = page.locator('[data-testid="addon-optional-toggle-0"]'); + await optionalToggle.check(); + await expect(optionalToggle).toBeChecked(); + + const maxQtyInput = page.locator('[data-testid="addon-max-quantity-0"]'); + await maxQtyInput.fill('2'); + await expect(maxQtyInput).toHaveValue('2'); + + // 4. Add Custom Inline Line Item + await page.locator('[data-testid="add-custom-item-btn"]').click(); + await expect(page.locator('[data-testid="addon-row-1"]')).toBeVisible(); + await page.fill('[data-testid="addon-name-1"]', 'Second Photographer Assistant'); + await page.fill('[data-testid="addon-price-1"]', '400.00'); + + // 5. Verify reactive financial summary calculation + // Base 2800.00 + Addon 250.00 + Custom 400.00 = 3450.00 + await expect(page.locator('[data-testid="financial-summary-total"]')).toContainText('3,450.00'); + + // 6. Save Draft Proposal (AC-3) + await page.locator('[data-testid="save-draft-btn"]').click(); + + // Verify redirected to edit route /projects/[id]/proposals/[proposalId] + await expect(page).toHaveURL( + new RegExp(`.*/projects/${E2E_PROJECT_INQUIRY_1_ID}/proposals/[0-9a-f-]{36}`), + ); + + // Verify persisted state after redirect + await expect(page.locator('[data-testid="proposal-builder-view"]')).toBeVisible(); + await expect(page.locator('[data-testid="proposal-status-badge"]')).toContainText('Draft'); + await expect(page.locator('[data-testid="financial-summary-total"]')).toContainText('3,450.00'); + }); + + test('AC-4: Send Proposal Modal flow with 14-day default validity and copy link notification', async ({ + page, + }) => { + test.setTimeout(90000); + + // Open existing draft proposal + await page.goto(`/en/projects/${E2E_PROJECT_INQUIRY_1_ID}/proposals/${E2E_DRAFT_PROPOSAL_ID}`); + await expect(page.locator('[data-testid="proposal-builder-view"]')).toBeVisible(); + + // Click "Send Proposal to Client" + const openSendBtn = page.locator('[data-testid="open-send-proposal-btn"]'); + await expect(openSendBtn).toBeVisible(); + await openSendBtn.click(); + + // Verify modal is open + const modal = page.locator('[data-testid="send-proposal-modal"]'); + await expect(modal).toBeVisible(); + await page.waitForTimeout(300); + + // Verify recipient email is populated + const emailInput = page.locator('[data-testid="send-recipient-email-input"]'); + await expect(emailInput).not.toHaveValue(''); + + // Verify expiration date is populated with a date ~14 days in future + const expDateInput = page.locator('[data-testid="send-valid-until-input"]'); + const expValue = await expDateInput.inputValue(); + expect(expValue).toMatch(/^\d{4}-\d{2}-\d{2}$/); + + // Submit send proposal + await page.locator('[data-testid="submit-send-proposal-btn"]').click(); + + // Verify "Copy Link" notification card appears with shareable token URL inside modal + const copyLinkNotification = page.locator('[data-testid="copy-link-notification"]'); + await expect(copyLinkNotification).toBeVisible(); + + const tokenUrlElement = page.locator('[data-testid="proposal-token-url"]'); + await expect(tokenUrlElement).toBeVisible(); + const tokenUrlText = await tokenUrlElement.inputValue(); + expect(tokenUrlText).toContain('/p/'); + + // Click "Copy Link" button + const copyBtn = page.locator('[data-testid="copy-link-button"]'); + await expect(copyBtn).toBeVisible(); + await copyBtn.click(); + await expect(copyBtn).toContainText('Copied'); + }); + + test('AC-6: Validation error associations on invalid title, negative prices, and past dates', async ({ + page, + }) => { + test.setTimeout(90000); + + await page.goto(`/en/projects/${E2E_PROJECT_INQUIRY_1_ID}/proposals/new`); + await expect(page.locator('[data-testid="proposal-builder-view"]')).toBeVisible(); + + // 1. Empty title validation + const titleInput = page.locator('[data-testid="proposal-title-input"]'); + await titleInput.fill(''); + await expect(page.locator('[data-testid="save-draft-btn"]')).toBeDisabled(); + + const titleError = page.locator('[data-testid="proposal-title-error"]'); + await expect(titleError).toBeVisible(); + await expect(titleError).toHaveAttribute('role', 'alert'); + await expect(titleInput).toHaveAttribute('aria-invalid', 'true'); + await expect(titleInput).toHaveAttribute('aria-describedby', 'proposal-title-error'); + + // Restore valid title + await titleInput.fill('Valid Proposal Title'); + await expect(titleError).not.toBeVisible(); + + // 2. Negative price validation (select package first) + await page.locator('[data-testid="package-select"]').selectOption(E2E_PACKAGE_WEDDING_ID); + const basePriceInput = page.locator('[data-testid="base-package-price-input"]'); + await basePriceInput.fill('-100.00'); + await expect(page.locator('[data-testid="save-draft-btn"]')).toBeDisabled(); + + const priceError = page.locator('[data-testid="base-package-price-error"]'); + await expect(priceError).toBeVisible(); + await expect(priceError).toHaveAttribute('role', 'alert'); + await expect(basePriceInput).toHaveAttribute('aria-invalid', 'true'); + await expect(basePriceInput).toHaveAttribute('aria-describedby', 'base-package-price-error'); + + // 3. Send modal past date validation + await page.goto(`/en/projects/${E2E_PROJECT_INQUIRY_1_ID}/proposals/${E2E_DRAFT_PROPOSAL_ID}`); + await expect(page.locator('[data-testid="proposal-builder-view"]')).toBeVisible(); + + await page.locator('[data-testid="open-send-proposal-btn"]').click(); + const modal = page.locator('[data-testid="send-proposal-modal"]'); + await expect(modal).toBeVisible(); + await page.waitForTimeout(300); + + const expDateInput = page.locator('[data-testid="send-valid-until-input"]'); + await expDateInput.fill('2020-01-01'); + + const dateError = page.locator('[data-testid="send-date-error"]'); + await expect(dateError).toBeVisible(); + await expect(dateError).toHaveAttribute('role', 'alert'); + await expect(expDateInput).toHaveAttribute('aria-invalid', 'true'); + await expect(page.locator('[data-testid="submit-send-proposal-btn"]')).toBeDisabled(); + + // Close modal + await page.locator('[data-testid="cancel-send-proposal-btn"]').click(); + await expect(modal).not.toBeVisible(); + }); + + test('AC-7: German locale renders complete proposal builder UI with zero untranslated strings', async ({ + page, + }) => { + test.setTimeout(90000); + + await page.goto(`/de/projects/${E2E_PROJECT_INQUIRY_1_ID}/proposals/new`); + await expect(page.locator('[data-testid="proposal-builder-view"]')).toBeVisible(); + + const content = await page.content(); + // Verify no untranslated keys exist + expect(content).not.toContain('proposals.builder.'); + expect(content).not.toContain('common.'); + + // Verify key German strings + await expect(page.locator('[data-testid="save-draft-btn"]')).toContainText('Entwurf speichern'); + await expect(page.locator('[data-testid="open-send-proposal-btn"]')).toContainText( + 'Angebot an Kunden senden', + ); + await expect(page.locator('[data-testid="proposal-status-badge"]')).toContainText('Entwurf'); + await expect(page.locator('[data-testid="package-selector-section"] h2')).toContainText( + '1. Basispaket', + ); + await expect(page.locator('[data-testid="addon-options-section"] h2')).toContainText( + '2. Zusatzoptionen', + ); + await expect(page.locator('[data-testid="financial-summary-section"] h2')).toContainText( + '3. Finanzübersicht', + ); + }); + + test('AC-8: WCAG 2.2 AA accessibility scan with Axe across light/dark themes and viewports', async ({ + page, + }) => { + test.setTimeout(90000); + + // 1. Desktop Light (1280x800) + await page.setViewportSize({ width: 1280, height: 800 }); + await page.emulateMedia({ colorScheme: 'light' }); + await page.goto(`/en/projects/${E2E_PROJECT_INQUIRY_1_ID}/proposals/${E2E_DRAFT_PROPOSAL_ID}`); + await expect(page.locator('[data-testid="proposal-builder-view"]')).toBeVisible(); + + const axeDesktopLight = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) + .analyze(); + expect(axeDesktopLight.violations).toEqual([]); + + await page.screenshot({ + path: path.join(UX_RUN_DIR, 'proposal-builder-1280px-light.png'), + }); + + // 2. Desktop Dark (1280x800) + await page.emulateMedia({ colorScheme: 'dark' }); + await page.waitForTimeout(200); + + const axeDesktopDark = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) + .analyze(); + expect(axeDesktopDark.violations).toEqual([]); + + await page.screenshot({ + path: path.join(UX_RUN_DIR, 'proposal-builder-1280px-dark.png'), + }); + + // 3. Open Send Proposal Modal Axe Scan (Light mode) + await page.emulateMedia({ colorScheme: 'light' }); + await page.locator('[data-testid="open-send-proposal-btn"]').click(); + await page.waitForSelector('[data-testid="send-proposal-modal"]', { state: 'visible' }); + await page.waitForTimeout(300); + + const axeModalLight = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) + .analyze(); + expect(axeModalLight.violations).toEqual([]); + + await page.screenshot({ + path: path.join(UX_RUN_DIR, 'send-proposal-modal-1280px-light.png'), + }); + + await page.locator('[data-testid="cancel-send-proposal-btn"]').click(); + await expect(page.locator('[data-testid="send-proposal-modal"]')).not.toBeVisible(); + + // 4. Mobile Light (390x844) + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`/en/projects/${E2E_PROJECT_INQUIRY_1_ID}/proposals/${E2E_DRAFT_PROPOSAL_ID}`); + await expect(page.locator('[data-testid="proposal-builder-view"]')).toBeVisible(); + + const axeMobileLight = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) + .analyze(); + expect(axeMobileLight.violations).toEqual([]); + + await page.screenshot({ + path: path.join(UX_RUN_DIR, 'proposal-builder-390px-mobile-light.png'), + }); + + // 5. Mobile Dark (390x844) + await page.emulateMedia({ colorScheme: 'dark' }); + await page.waitForTimeout(200); + + const axeMobileDark = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) + .analyze(); + expect(axeMobileDark.violations).toEqual([]); + + await page.screenshot({ + path: path.join(UX_RUN_DIR, 'proposal-builder-390px-mobile-dark.png'), + }); + }); + + test('I1-U02 regression: 375px mobile viewport has no horizontal overflow or clipped buttons in package and addon sections', async ({ + page, + }) => { + await ensureOwnerBootstrappedAndSignedIn(page); + + // Test both English and German locales on 375px viewport + for (const locale of ['en', 'de']) { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto(`/${locale}/projects/${E2E_PROJECT_INQUIRY_1_ID}/proposals/new`); + await page.waitForSelector('[data-testid="proposal-builder-view"]', { + state: 'visible', + }); + + // 1. Check package selector add deliverable form does not overflow card width + const deliverableForm = page.locator('[data-testid="add-deliverable-form"]'); + await expect(deliverableForm).toBeVisible(); + const formBox = await deliverableForm.boundingBox(); + expect(formBox).not.toBeNull(); + if (formBox) { + expect(formBox.x + formBox.width).toBeLessThanOrEqual(375); + } + + const addDeliverableBtn = page.locator('[data-testid="add-deliverable-btn"]'); + const addDeliverableBox = await addDeliverableBtn.boundingBox(); + expect(addDeliverableBox).not.toBeNull(); + if (addDeliverableBox) { + expect(addDeliverableBox.x + addDeliverableBox.width).toBeLessThanOrEqual(375); + } + + // 2. Check catalogue addon toolbar does not overflow card width + const catalogueSelect = page.locator('[data-testid="catalogue-addon-select"]'); + await expect(catalogueSelect).toBeVisible(); + await catalogueSelect.selectOption({ index: 1 }); + + const addCatalogueBtn = page.locator('[data-testid="add-catalogue-addon-btn"]'); + await expect(addCatalogueBtn).toBeEnabled(); + const addCatalogueBox = await addCatalogueBtn.boundingBox(); + expect(addCatalogueBox).not.toBeNull(); + if (addCatalogueBox) { + expect(addCatalogueBox.x + addCatalogueBox.width).toBeLessThanOrEqual(375); + } + + // 3. Check document does not horizontally scroll + const isOverflowing = await page.evaluate(() => { + const doc = document.documentElement; + return doc.scrollWidth > doc.clientWidth; + }); + expect(isOverflowing).toBe(false); + + // 4. Verify I1-U01 tax rates localized options in German + if (locale === 'de') { + const packageSelect = page.locator('[data-testid="package-select"]'); + await packageSelect.selectOption({ index: 1 }); + const taxRateSelect = page.locator('[data-testid="base-package-tax-rate-select"]'); + await expect(taxRateSelect).toBeVisible(); + const taxOptionsText = await taxRateSelect.innerText(); + expect(taxOptionsText).toContain('19% (Regelsteuersatz)'); + expect(taxOptionsText).not.toContain('19% Standard'); + + // Capture screenshot of 375px layout in German + await page.screenshot({ + path: path.join(UX_RUN_DIR, 'proposal-builder-375px-mobile-de-fixed.png'), + }); + } + } + }); +}); diff --git a/tests/helpers/e2e-global-setup.ts b/tests/helpers/e2e-global-setup.ts index 6440ef7..8d1183b 100644 --- a/tests/helpers/e2e-global-setup.ts +++ b/tests/helpers/e2e-global-setup.ts @@ -7,6 +7,7 @@ import { seedAllE2eInquiryForms } from './e2e-inquiry-helper'; import { seedAllE2eLeads } from './e2e-leads-helper'; import { seedAllE2eProjects } from './e2e-projects-helper'; import { seedAllE2eCalendar } from './e2e-calendar-helper'; +import { seedAllE2eProposals } from './e2e-proposals-helper'; export const E2E_OWNER_EMAIL = 'owner@example.com'; export const E2E_OWNER_PASSWORD = 'ValidOwnerPassword123!'; @@ -50,6 +51,9 @@ export default async function globalSetup() { // Seed calendar defaults (booking types & availability schedules) for booking E2E tests (TASK-0044) await seedAllE2eCalendar(pglite); + // Seed proposal packages, catalogue add-ons, and draft proposals (TASK-0061) + await seedAllE2eProposals(pglite); + await pglite.close(); } diff --git a/tests/helpers/e2e-proposals-helper.ts b/tests/helpers/e2e-proposals-helper.ts new file mode 100644 index 0000000..eff1118 --- /dev/null +++ b/tests/helpers/e2e-proposals-helper.ts @@ -0,0 +1,115 @@ +import { type PGlite } from '@electric-sql/pglite'; +import { E2E_PROJECT_INQUIRY_1_ID } from './e2e-projects-helper'; + +export const E2E_PACKAGE_WEDDING_ID = '01920000-0000-7000-8000-000000000101'; +export const E2E_CATALOGUE_ITEM_1_ID = '01920000-0000-7000-8000-000000000111'; +export const E2E_CATALOGUE_ITEM_2_ID = '01920000-0000-7000-8000-000000000112'; +export const E2E_DRAFT_PROPOSAL_ID = '01920000-0000-7000-8000-000000000201'; + +export async function seedAllE2eProposals(pglite: PGlite): Promise { + const now = new Date().toISOString(); + + // 1. Seed base package + const existingPkg = await pglite.query<{ id: string }>(`SELECT id FROM packages WHERE id = $1`, [ + E2E_PACKAGE_WEDDING_ID, + ]); + if (existingPkg.rows.length === 0) { + await pglite.query( + `INSERT INTO packages ( + id, name, slug, description, project_type_key, base_price_minor, currency, tax_rate_basis_points, position, is_active, created_at, updated_at + ) VALUES ( + $1, 'Intimate Wedding Story', 'intimate-wedding-story', 'Comprehensive wedding photography coverage.', + 'wedding', 280000, 'EUR', 1900, 0, true, $2, $2 + )`, + [E2E_PACKAGE_WEDDING_ID, now], + ); + + await pglite.query( + `INSERT INTO package_included_items ( + id, package_id, title, description, quantity, position, created_at, updated_at + ) VALUES + ('01920000-0000-7000-8000-000000000102', $1, '8 Hours Continuous Coverage', 'From preparations to cake cutting', 1, 0, $2, $2), + ('01920000-0000-7000-8000-000000000103', $1, '400+ High-Resolution Edited Images', 'Delivered in digital gallery', 400, 1, $2, $2)`, + [E2E_PACKAGE_WEDDING_ID, now], + ); + } + + // 2. Seed catalogue add-ons + const existingCat1 = await pglite.query<{ id: string }>( + `SELECT id FROM catalogue_items WHERE id = $1`, + [E2E_CATALOGUE_ITEM_1_ID], + ); + if (existingCat1.rows.length === 0) { + await pglite.query( + `INSERT INTO catalogue_items ( + id, name, slug, description, category, unit_price_minor, currency, tax_rate_basis_points, position, is_active, created_at, updated_at + ) VALUES ( + $1, 'Extra Photography Hour', 'extra-photography-hour', 'Additional hour of coverage on the event day', + 'service', 25000, 'EUR', 1900, 0, true, $2, $2 + )`, + [E2E_CATALOGUE_ITEM_1_ID, now], + ); + } + + const existingCat2 = await pglite.query<{ id: string }>( + `SELECT id FROM catalogue_items WHERE id = $1`, + [E2E_CATALOGUE_ITEM_2_ID], + ); + if (existingCat2.rows.length === 0) { + await pglite.query( + `INSERT INTO catalogue_items ( + id, name, slug, description, category, unit_price_minor, currency, tax_rate_basis_points, position, is_active, created_at, updated_at + ) VALUES ( + $1, 'Fine Art Linen Album 30x30', 'fine-art-linen-album-30x30', 'Handcrafted 30-page album with archival paper', + 'product', 45000, 'EUR', 1900, 1, true, $2, $2 + )`, + [E2E_CATALOGUE_ITEM_2_ID, now], + ); + } + + // 3. Seed sample draft proposal on E2E_PROJECT_INQUIRY_1_ID + const projectRes = await pglite.query<{ client_id: string }>( + `SELECT client_id FROM projects WHERE id = $1`, + [E2E_PROJECT_INQUIRY_1_ID], + ); + if (projectRes.rows.length > 0 && projectRes.rows[0]) { + const clientId = projectRes.rows[0].client_id; + const existingProposal = await pglite.query<{ id: string }>( + `SELECT id FROM proposals WHERE id = $1`, + [E2E_DRAFT_PROPOSAL_ID], + ); + if (existingProposal.rows.length === 0) { + await pglite.query( + `INSERT INTO proposals ( + id, project_id, client_id, title, status, version, currency, + subtotal_minor, tax_minor, total_minor, prices_include_tax, is_kleinunternehmer, + tax_notice, token_hash, created_at, updated_at + ) VALUES ( + $1, $2, $3, 'Emma & Liam Wedding Photography Proposal', 'draft', 1, 'EUR', + 305000, 48700, 305000, true, false, + NULL, '0192000000007000800000000000020101920000000070008000000000000201', $4, $4 + )`, + [E2E_DRAFT_PROPOSAL_ID, E2E_PROJECT_INQUIRY_1_ID, clientId, now], + ); + + await pglite.query( + `INSERT INTO proposal_line_items ( + id, proposal_id, package_id, catalogue_item_id, item_type, name, description, + unit_price_minor, quantity, min_quantity, max_quantity, tax_rate_basis_points, + is_optional, is_selected, position, created_at, updated_at + ) VALUES + ( + '01920000-0000-7000-8000-000000000202', $1, $2, NULL, 'base_package', + 'Intimate Wedding Story', 'Included Deliverables:\n• 8 Hours Continuous Coverage\n• 400+ High-Resolution Edited Images', + 280000, 1, 1, 1, 1900, false, true, 0, $3, $3 + ), + ( + '01920000-0000-7000-8000-000000000203', $1, NULL, $4, 'addon', + 'Extra Photography Hour', 'Additional hour of coverage on the event day', + 25000, 1, 0, 2, 1900, true, true, 1, $3, $3 + )`, + [E2E_DRAFT_PROPOSAL_ID, E2E_PACKAGE_WEDDING_ID, now, E2E_CATALOGUE_ITEM_1_ID], + ); + } + } +} diff --git a/tests/unit/proposal-builder-form.test.ts b/tests/unit/proposal-builder-form.test.ts new file mode 100644 index 0000000..f4d6fba --- /dev/null +++ b/tests/unit/proposal-builder-form.test.ts @@ -0,0 +1,1483 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + createInitialProposalState, + proposalBuilderReducer, + calculateProposalStateTotals, + validateProposalForm, + validateSendModal, + buildSaveDraftPayload, + buildSendPayload, + getDefaultExpirationDateString, + formatDeliverablesDescription, + parseDeliverablesFromDescription, + type ProposalBuilderState, + type ProposalBuilderAction, +} from '@/components/proposals/proposal-builder-helpers'; +import type { SaveDraftProposalInput } from '@/server/proposals/schema'; +import type { PackageWithIncludedItems } from '@/server/packages/repo'; +import type { CatalogueItem } from '@/server/db/schema/packages'; + +const mockPackage: PackageWithIncludedItems = { + id: '01920000-0000-7000-8000-000000000101', + name: 'Full Day Wedding Coverage', + slug: 'wedding-full-day', + description: 'Comprehensive 8-hour wedding photography', + projectTypeKey: 'wedding', + basePriceMinor: 280000, + currency: 'EUR', + taxRateBasisPoints: 1900, + position: 1, + isActive: true, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + includedItems: [ + { + id: '01920000-0000-7000-8000-000000000111', + packageId: '01920000-0000-7000-8000-000000000101', + title: '8 Hours Continuous Coverage', + description: 'Prep to first dance', + quantity: 1, + position: 1, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }, + { + id: '01920000-0000-7000-8000-000000000112', + packageId: '01920000-0000-7000-8000-000000000101', + title: '400+ Edited High-Resolution Images', + description: 'Full resolution download', + quantity: 400, + position: 2, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }, + ], +}; + +const mockCatalogueItem: CatalogueItem = { + id: '01920000-0000-7000-8000-000000000301', + name: 'Extra Hour Coverage', + slug: 'extra-hour-coverage', + description: 'Additional hour on location', + category: 'service', + unitPriceMinor: 25000, + currency: 'EUR', + taxRateBasisPoints: 1900, + position: 1, + isActive: true, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +}; + +const mockStudioSettings = { + currency: 'EUR', + isKleinunternehmer: false, + pricesIncludeTax: true, + defaultTaxRateBasisPoints: 1900, + kleinunternehmerNotice: null, +}; + +const mockProject = { + id: '01920000-0000-7000-8000-000000000071', + title: 'Emma & Liam Wedding', + projectTypeKey: 'wedding', + currency: 'EUR', +}; + +const mockClient = { + id: '01920000-0000-7000-8000-000000000081', + displayName: 'Emma Watson', + email: 'emma@example.com', + phone: '+49 30 123456', +}; + +// Translator mock +const mockT = (key: string) => key; + +describe('TASK-0061: Proposal Builder Form & Helpers Unit Tests', () => { + it('AC-1: selecting a base package from package selector populates title, base price, and included deliverables', () => { + let state = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: mockStudioSettings, + packages: [mockPackage], + }); + + expect(state.selectedPackageId).toBeNull(); + expect(state.basePackageItem).toBeNull(); + expect(state.deliverables).toHaveLength(0); + + // Select base package + state = proposalBuilderReducer(state, { + type: 'SELECT_PACKAGE', + pkg: mockPackage, + }); + + expect(state.selectedPackageId).toBe(mockPackage.id); + expect(state.title).toBe('Emma & Liam Wedding — Full Day Wedding Coverage'); + expect(state.basePackageItem).not.toBeNull(); + expect(state.basePackageItem?.name).toBe('Full Day Wedding Coverage'); + expect(state.basePackageItem?.unitPriceMinor).toBe(280000); + expect(state.basePackageItem?.unitPriceMajor).toBe('2800.00'); + expect(state.basePackageItem?.taxRateBasisPoints).toBe(1900); + expect(state.basePackageItem?.isOptional).toBe(false); + expect(state.basePackageItem?.isSelected).toBe(true); + + // Bundled deliverables populated + expect(state.deliverables).toHaveLength(2); + expect(state.deliverables[0]?.title).toBe('8 Hours Continuous Coverage'); + expect(state.deliverables[1]?.title).toBe('400+ Edited High-Resolution Images'); + + // Deliverables can be added or removed for this proposal + state = proposalBuilderReducer(state, { + type: 'ADD_DELIVERABLE', + title: 'Drone Footage (Custom Deliverable)', + quantity: 1, + }); + expect(state.deliverables).toHaveLength(3); + expect(state.deliverables[2]?.title).toBe('Drone Footage (Custom Deliverable)'); + + state = proposalBuilderReducer(state, { + type: 'REMOVE_DELIVERABLE', + index: 1, + }); + expect(state.deliverables).toHaveLength(2); + expect(state.deliverables[1]?.title).toBe('Drone Footage (Custom Deliverable)'); + }); + + it('AC-2: adding catalogue add-on item, marking as optional with max quantity 2, and adding inline custom item updates financial summary reactively', () => { + let state = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: mockStudioSettings, + packages: [mockPackage], + }); + + // 1. Select base package (2800.00 EUR Gross, 19% VAT) + state = proposalBuilderReducer(state, { + type: 'SELECT_PACKAGE', + pkg: mockPackage, + }); + + let totals = calculateProposalStateTotals(state); + // 280000 Gross => net: Math.round(280000 * 10000 / 11900) = 235294, tax: 44706, total: 280000 + expect(totals.totalMinor).toBe(280000); + expect(totals.taxMinor).toBe(44706); + expect(totals.subtotalMinor).toBe(280000); + + // 2. Add catalogue add-on item (250.00 EUR Gross, 19% VAT) + state = proposalBuilderReducer(state, { + type: 'ADD_CATALOGUE_ADDON', + item: mockCatalogueItem, + }); + expect(state.addonItems).toHaveLength(1); + expect(state.addonItems[0]?.name).toBe('Extra Hour Coverage'); + expect(state.addonItems[0]?.unitPriceMinor).toBe(25000); + expect(state.addonItems[0]?.isOptional).toBe(false); + + // Mark as optional + state = proposalBuilderReducer(state, { + type: 'UPDATE_ITEM_FIELD', + index: 0, + field: 'isOptional', + value: true, + }); + expect(state.addonItems[0]?.isOptional).toBe(true); + + // Set max quantity to 2 + state = proposalBuilderReducer(state, { + type: 'UPDATE_ITEM_FIELD', + index: 0, + field: 'maxQuantity', + value: 2, + }); + expect(state.addonItems[0]?.maxQuantity).toBe(2); + + // 3. Add inline custom line item (150.00 EUR Gross, 19% VAT) + state = proposalBuilderReducer(state, { + type: 'ADD_CUSTOM_ITEM', + }); + expect(state.addonItems).toHaveLength(2); + expect(state.addonItems[1]?.itemType).toBe('custom'); + + state = proposalBuilderReducer(state, { + type: 'UPDATE_ITEM_FIELD', + index: 1, + field: 'name', + value: 'Second Photographer Travel Fee', + }); + state = proposalBuilderReducer(state, { + type: 'UPDATE_ITEM_FIELD', + index: 1, + field: 'unitPriceMajor', + value: '150.00', + }); + expect(state.addonItems[1]?.name).toBe('Second Photographer Travel Fee'); + expect(state.addonItems[1]?.unitPriceMinor).toBe(15000); + + // Recalculate totals: 280000 (package) + 25000 (addon) + 15000 (custom) = 320000 Gross + // Net: Math.round(320000 * 10000 / 11900) = 268908 + // Tax: 320000 - 268908 = 51092 + totals = calculateProposalStateTotals(state); + expect(totals.totalMinor).toBe(320000); + expect(totals.taxMinor).toBe(51092); + expect(totals.subtotalMinor).toBe(320000); + expect(totals.breakdown).toHaveLength(1); + expect(totals.breakdown[0]?.taxRateBasisPoints).toBe(1900); + expect(totals.breakdown[0]?.taxMinor).toBe(51092); + }); + + it('AC-3: buildSaveDraftPayload correctly formats draft proposal and line items for PostgreSQL persistence', () => { + let state = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: mockStudioSettings, + packages: [mockPackage], + }); + + state = proposalBuilderReducer(state, { + type: 'SELECT_PACKAGE', + pkg: mockPackage, + }); + + state = proposalBuilderReducer(state, { + type: 'ADD_CATALOGUE_ADDON', + item: mockCatalogueItem, + }); + + const payload = buildSaveDraftPayload(state); + expect(payload.projectId).toBe(mockProject.id); + expect(payload.clientId).toBe(mockClient.id); + expect(payload.title).toBe('Emma & Liam Wedding — Full Day Wedding Coverage'); + expect(payload.currency).toBe('EUR'); + expect(payload.pricesIncludeTax).toBe(true); + expect(payload.isKleinunternehmer).toBe(false); + + expect(payload.lineItems).toBeDefined(); + const lineItems = payload.lineItems ?? []; + expect(lineItems).toHaveLength(2); + // Line item 0: base_package + expect(lineItems[0]?.itemType).toBe('base_package'); + expect(lineItems[0]?.packageId).toBe(mockPackage.id); + expect(lineItems[0]?.name).toBe('Full Day Wedding Coverage'); + expect(lineItems[0]?.unitPriceMinor).toBe(280000); + expect(lineItems[0]?.position).toBe(0); + + // Line item 1: addon + expect(lineItems[1]?.itemType).toBe('addon'); + expect(lineItems[1]?.catalogueItemId).toBe(mockCatalogueItem.id); + expect(lineItems[1]?.name).toBe('Extra Hour Coverage'); + expect(lineItems[1]?.unitPriceMinor).toBe(25000); + expect(lineItems[1]?.position).toBe(1); + }); + + it('AC-4: buildSendPayload validates default 14-day validity date and client recipient email', () => { + const fixedNow = new Date('2026-06-01T12:00:00Z'); + const defaultDate = getDefaultExpirationDateString(fixedNow); + expect(defaultDate).toBe('2026-06-15'); + + const state = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: mockStudioSettings, + packages: [mockPackage], + proposal: { + id: '01920000-0000-7000-8000-000000000201', + projectId: mockProject.id, + clientId: mockClient.id, + title: 'Draft Proposal', + status: 'draft', + version: 1, + currency: 'EUR', + subtotalMinor: 280000, + taxMinor: 44706, + totalMinor: 280000, + pricesIncludeTax: true, + isKleinunternehmer: false, + taxNotice: null, + validUntil: null, + tokenHash: 'abc', + tokenRevokedAt: null, + sentAt: null, + acceptedAt: null, + declinedAt: null, + declineReason: null, + acceptedByName: null, + acceptedByEmail: null, + acceptanceIpAnonymized: null, + acceptanceUserAgent: null, + acceptedOptionsSnapshot: null, + internalNotes: null, + createdAt: new Date(), + updatedAt: new Date(), + lineItems: [], + }, + }); + + expect(state.sendModalEmail).toBe('emma@example.com'); + expect(state.sendModalDate).toBeTruthy(); + + const validation = validateSendModal(state, mockT); + expect(validation.isValid).toBe(true); + + const sendPayload = buildSendPayload(state); + expect(sendPayload.proposalId).toBe('01920000-0000-7000-8000-000000000201'); + expect(sendPayload.recipientEmail).toBe('emma@example.com'); + expect(sendPayload.validUntil).toBeInstanceOf(Date); + }); + + it('AC-6: validateProposalForm catches empty title, negative item prices, and disables actions', () => { + let state = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: mockStudioSettings, + packages: [mockPackage], + }); + + // 1. Empty title validation + state = proposalBuilderReducer(state, { + type: 'SET_TITLE', + title: ' ', + }); + let result = validateProposalForm(state, mockT); + expect(result.isValid).toBe(false); + expect(result.errors.title).toBe('validation.titleRequired'); + + // 2. Valid title but negative item price + state = proposalBuilderReducer(state, { + type: 'SET_TITLE', + title: 'Valid Wedding Proposal', + }); + state = proposalBuilderReducer(state, { + type: 'SELECT_PACKAGE', + pkg: mockPackage, + }); + state = proposalBuilderReducer(state, { + type: 'ADD_CUSTOM_ITEM', + }); + state = proposalBuilderReducer(state, { + type: 'UPDATE_ITEM_FIELD', + index: 0, + field: 'name', + value: 'Custom Travel', + }); + state = proposalBuilderReducer(state, { + type: 'UPDATE_ITEM_FIELD', + index: 0, + field: 'unitPriceMajor', + value: '-50.00', + }); + + result = validateProposalForm(state, mockT); + expect(result.isValid).toBe(false); + expect(result.errors.items?.[0]?.unitPrice).toBe('validation.negativePrice'); + + // 3. Send modal validation catches expiration date in the past + state = proposalBuilderReducer(state, { + type: 'SET_SEND_MODAL_DATE', + date: '2020-01-01', + }); + const modalValidation = validateSendModal(state, mockT); + expect(modalValidation.isValid).toBe(false); + expect(modalValidation.errors.sendDate).toBe('validation.dateInPast'); + }); + + it('AC-2: handles Kleinunternehmer mode (§ 19 UStG) where tax is 0 minor units and total equals subtotal', () => { + const kleinunternehmerSettings = { + ...mockStudioSettings, + isKleinunternehmer: true, + kleinunternehmerNotice: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', + }; + + let state = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: kleinunternehmerSettings, + packages: [mockPackage], + }); + + state = proposalBuilderReducer(state, { + type: 'SELECT_PACKAGE', + pkg: mockPackage, + }); + + const totals = calculateProposalStateTotals(state); + expect(totals.totalMinor).toBe(280000); + expect(totals.taxMinor).toBe(0); + expect(totals.subtotalMinor).toBe(280000); + expect(totals.breakdown).toHaveLength(0); + }); + + it('AC-2: handles Net pricing display mode where VAT is added on top of net amount', () => { + const netPricingSettings = { + ...mockStudioSettings, + pricesIncludeTax: false, + }; + + let state = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: netPricingSettings, + packages: [mockPackage], + }); + + state = proposalBuilderReducer(state, { + type: 'SELECT_PACKAGE', + pkg: mockPackage, + }); + + // Net: 280000, Tax (19%): Math.round(280000 * 1900 / 10000) = 53200, Gross Total: 333200 + const totals = calculateProposalStateTotals(state); + expect(totals.subtotalMinor).toBe(280000); + expect(totals.taxMinor).toBe(53200); + expect(totals.totalMinor).toBe(333200); + }); + + it('handles item reordering and deliverable formatting cleanly', () => { + let state = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: mockStudioSettings, + packages: [mockPackage], + }); + + state = proposalBuilderReducer(state, { + type: 'ADD_CUSTOM_ITEM', + }); + state = proposalBuilderReducer(state, { + type: 'UPDATE_ITEM_FIELD', + index: 0, + field: 'name', + value: 'Item A', + }); + + state = proposalBuilderReducer(state, { + type: 'ADD_CUSTOM_ITEM', + }); + state = proposalBuilderReducer(state, { + type: 'UPDATE_ITEM_FIELD', + index: 1, + field: 'name', + value: 'Item B', + }); + + expect(state.addonItems[0]?.name).toBe('Item A'); + expect(state.addonItems[1]?.name).toBe('Item B'); + + // Move Item B up + state = proposalBuilderReducer(state, { + type: 'MOVE_ITEM', + from: 1, + to: 0, + }); + expect(state.addonItems[0]?.name).toBe('Item B'); + expect(state.addonItems[1]?.name).toBe('Item A'); + + // Remove item + state = proposalBuilderReducer(state, { + type: 'REMOVE_ITEM', + index: 0, + }); + expect(state.addonItems).toHaveLength(1); + expect(state.addonItems[0]?.name).toBe('Item A'); + + // Deliverables formatting & parsing + const deliverables = [ + { id: '1', title: '8 Hours Shoot', quantity: 1 }, + { id: '2', title: 'Edited Photos', quantity: 50 }, + ]; + const desc = formatDeliverablesDescription('Base package overview', deliverables); + expect(desc).toContain('Base package overview'); + expect(desc).toContain('• 8 Hours Shoot'); + expect(desc).toContain('• 50x Edited Photos'); + + const parsed = parseDeliverablesFromDescription(desc); + expect(parsed).toHaveLength(2); + expect(parsed[0]?.title).toBe('8 Hours Shoot'); + expect(parsed[1]?.quantity).toBe(50); + }); + + it('handles remaining reducer actions and edge cases', () => { + let state = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: mockStudioSettings, + packages: [mockPackage], + }); + + state = proposalBuilderReducer(state, { + type: 'SELECT_PACKAGE', + pkg: mockPackage, + }); + + // UPDATE_BASE_PACKAGE_PRICE + state = proposalBuilderReducer(state, { + type: 'UPDATE_BASE_PACKAGE_PRICE', + majorPrice: '3000.00', + }); + expect(state.basePackageItem?.unitPriceMinor).toBe(300000); + + // Negative base price + state = proposalBuilderReducer(state, { + type: 'UPDATE_BASE_PACKAGE_PRICE', + majorPrice: '-10.00', + }); + expect(state.basePackageItem?.unitPriceMinor).toBe(-1); + + // UPDATE_BASE_PACKAGE_TAX_RATE + state = proposalBuilderReducer(state, { + type: 'UPDATE_BASE_PACKAGE_TAX_RATE', + taxRateBasisPoints: 700, + }); + expect(state.basePackageItem?.taxRateBasisPoints).toBe(700); + + // UPDATE_DELIVERABLE_TITLE and UPDATE_DELIVERABLE_QTY + state = proposalBuilderReducer(state, { + type: 'UPDATE_DELIVERABLE_TITLE', + index: 0, + title: 'Updated Title', + }); + expect(state.deliverables[0]?.title).toBe('Updated Title'); + + state = proposalBuilderReducer(state, { + type: 'UPDATE_DELIVERABLE_QTY', + index: 0, + quantity: 5, + }); + expect(state.deliverables[0]?.quantity).toBe(5); + + // SELECT_PACKAGE with null resets package + state = proposalBuilderReducer(state, { + type: 'SELECT_PACKAGE', + pkg: null, + }); + expect(state.selectedPackageId).toBeNull(); + expect(state.basePackageItem).toBeNull(); + expect(state.deliverables).toHaveLength(0); + + // SET_SAVING, SET_SENDING, SET_LINK_COPIED, SET_SAVE_SUCCESS, SET_GENERAL_ERROR + state = proposalBuilderReducer(state, { type: 'SET_SAVING', saving: true }); + expect(state.isSaving).toBe(true); + state = proposalBuilderReducer(state, { type: 'SET_SENDING', sending: true }); + expect(state.isSending).toBe(true); + state = proposalBuilderReducer(state, { type: 'SET_LINK_COPIED', copied: true }); + expect(state.linkCopied).toBe(true); + state = proposalBuilderReducer(state, { type: 'SET_SAVE_SUCCESS', success: true }); + expect(state.saveSuccess).toBe(true); + state = proposalBuilderReducer(state, { type: 'SET_GENERAL_ERROR', error: 'Custom Error' }); + expect(state.generalError).toBe('Custom Error'); + + // buildSendPayload throws if proposal id is missing + expect(() => buildSendPayload(state)).toThrow('Cannot send proposal without saved ID'); + + // parseDeliverablesFromDescription handles null or text without deliverables + expect(parseDeliverablesFromDescription(null)).toEqual([]); + expect(parseDeliverablesFromDescription('')).toEqual([]); + expect(parseDeliverablesFromDescription('Just plain text')).toEqual([]); + }); + + it('exercises createProposalBuilderActionHandlers flows (save, send, copy)', async () => { + let state = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: mockStudioSettings, + packages: [mockPackage], + }); + + state = proposalBuilderReducer(state, { + type: 'SELECT_PACKAGE', + pkg: mockPackage, + }); + + const dispatched: ProposalBuilderAction[] = []; + const dispatch = (action: ProposalBuilderAction) => { + dispatched.push(action); + state = proposalBuilderReducer(state, action); + }; + + const mockRouter = { + push: () => { + /* no-op in mock */ + }, + }; + const savedId = '01920000-0000-7000-8000-000000000999'; + + const saveDraftAction = (input: unknown) => { + const payload = input as SaveDraftProposalInput; + return Promise.resolve({ + success: true as const, + data: { + id: savedId, + projectId: mockProject.id, + clientId: mockClient.id, + title: payload.title, + status: 'draft' as const, + version: 1, + currency: 'EUR', + subtotalMinor: 280000, + taxMinor: 44706, + totalMinor: 280000, + pricesIncludeTax: true, + isKleinunternehmer: false, + taxNotice: null, + validUntil: null, + tokenHash: 'xyz', + tokenRevokedAt: null, + sentAt: null, + acceptedAt: null, + declinedAt: null, + declineReason: null, + acceptedByName: null, + acceptedByEmail: null, + acceptanceIpAnonymized: null, + acceptanceUserAgent: null, + acceptedOptionsSnapshot: null, + internalNotes: null, + createdAt: new Date(), + updatedAt: new Date(), + lineItems: [], + }, + }); + }; + + const sendAction = () => + Promise.resolve({ + success: true as const, + data: { + proposalId: savedId, + rawToken: 'token123', + tokenUrl: 'http://localhost:3000/p/token123', + }, + }); + + const handlers = ( + await import('@/components/proposals/proposal-builder-helpers') + ).createProposalBuilderActionHandlers({ + state, + getState: () => state, + dispatch, + t: mockT, + router: mockRouter, + locale: 'en', + saveDraftAction, + sendAction, + }); + + // Save draft on new proposal + await handlers.onSaveDraft(); + expect(state.id).toBe(savedId); + expect(state.saveSuccess).toBe(true); + + // Open send modal on saved proposal + const handlersSaved = ( + await import('@/components/proposals/proposal-builder-helpers') + ).createProposalBuilderActionHandlers({ + state, + getState: () => state, + dispatch, + t: mockT, + router: mockRouter, + locale: 'en', + saveDraftAction, + sendAction, + }); + + await handlersSaved.onOpenSendModal(); + expect(state.sendModalOpen).toBe(true); + + // Submit send proposal + await handlersSaved.onSubmitSendProposal(); + expect(state.status).toBe('sent'); + expect(state.sentResult?.tokenUrl).toBe('http://localhost:3000/p/token123'); + expect(state.sendModalOpen).toBe(false); + + // Copy link + // mock navigator.clipboard + Object.assign(navigator, { + clipboard: { + writeText: () => Promise.resolve(), + }, + }); + await handlersSaved.onCopyLink(); + expect(state.linkCopied).toBe(true); + + // Copy link when sentResult is null + state = { ...state, sentResult: null, linkCopied: false }; + await handlersSaved.onCopyLink(); + expect(state.linkCopied).toBe(false); + + // Test onOpenSendModal when proposal is unsaved + let unsavedState = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: mockStudioSettings, + packages: [mockPackage], + }); + unsavedState = proposalBuilderReducer(unsavedState, { + type: 'SELECT_PACKAGE', + pkg: mockPackage, + }); + + const unsavedHandlers = ( + await import('@/components/proposals/proposal-builder-helpers') + ).createProposalBuilderActionHandlers({ + state: unsavedState, + getState: () => unsavedState, + dispatch: (act) => { + unsavedState = proposalBuilderReducer(unsavedState, act); + }, + t: mockT, + router: mockRouter, + locale: 'en', + saveDraftAction, + sendAction, + }); + + await unsavedHandlers.onOpenSendModal(); + expect(unsavedState.id).toBe(savedId); + expect(unsavedState.sendModalOpen).toBe(true); + + // Test onOpenSendModal with invalid form + unsavedState = { ...unsavedState, id: undefined, title: ' ' }; + await unsavedHandlers.onOpenSendModal(); + expect(unsavedState.generalError).toBeTruthy(); + + // Test onSaveDraft with validation failure + let invalidState = { ...unsavedState, title: '' }; + const invalidHandlers = ( + await import('@/components/proposals/proposal-builder-helpers') + ).createProposalBuilderActionHandlers({ + state: invalidState, + getState: () => invalidState, + dispatch: (act) => { + invalidState = proposalBuilderReducer(invalidState, act); + }, + t: mockT, + router: mockRouter, + locale: 'en', + saveDraftAction, + sendAction, + }); + await invalidHandlers.onSaveDraft(); + expect(invalidState.generalError).toBeTruthy(); + + // Test onSaveDraft server action error + let failState = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: mockStudioSettings, + packages: [mockPackage], + }); + const failingHandlers = ( + await import('@/components/proposals/proposal-builder-helpers') + ).createProposalBuilderActionHandlers({ + state: failState, + getState: () => failState, + dispatch: (act) => { + failState = proposalBuilderReducer(failState, act); + }, + t: mockT, + router: mockRouter, + locale: 'en', + saveDraftAction: () => + Promise.resolve({ success: false, code: 'FORBIDDEN', error: 'Forbidden' }), + sendAction: () => Promise.resolve({ success: false, code: 'ERROR', error: 'Send Error' }), + }); + await failingHandlers.onSaveDraft(); + expect(failState.generalError).toBe('Forbidden'); + + // Test onSaveDraft thrown exception + const throwingHandlers = ( + await import('@/components/proposals/proposal-builder-helpers') + ).createProposalBuilderActionHandlers({ + state: failState, + getState: () => failState, + dispatch: (act) => { + failState = proposalBuilderReducer(failState, act); + }, + t: mockT, + router: mockRouter, + locale: 'en', + saveDraftAction: () => Promise.reject(new Error('Boom')), + sendAction: () => Promise.reject(new Error('Boom Send')), + }); + await throwingHandlers.onSaveDraft(); + expect(failState.generalError).toBe('Boom'); + + // Test onSubmitSendProposal invalid date + failState = { ...failState, id: 'some-id', sendModalDate: '2020-01-01' }; + await failingHandlers.onSubmitSendProposal(); + expect(failState.generalError).toBeTruthy(); + + // Test onSubmitSendProposal server failure + failState = { ...failState, sendModalDate: getDefaultExpirationDateString() }; + await failingHandlers.onSubmitSendProposal(); + expect(failState.generalError).toBe('Send Error'); + + // Test onSubmitSendProposal exception + await throwingHandlers.onSubmitSendProposal(); + expect(failState.generalError).toBe('Boom Send'); + + // Test onOpenSendModal when unsaved and saveDraftAction fails + let unsavedFailState = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: mockStudioSettings, + packages: [mockPackage], + }); + const unsavedFailingHandlers = ( + await import('@/components/proposals/proposal-builder-helpers') + ).createProposalBuilderActionHandlers({ + state: unsavedFailState, + getState: () => unsavedFailState, + dispatch: (act) => { + unsavedFailState = proposalBuilderReducer(unsavedFailState, act); + }, + t: mockT, + router: mockRouter, + locale: 'en', + saveDraftAction: () => Promise.resolve({ success: false, code: 'ERR', error: 'Save Failed' }), + sendAction: () => Promise.resolve({ success: false, code: 'ERR', error: 'Send Error' }), + }); + await unsavedFailingHandlers.onOpenSendModal(); + expect(unsavedFailState.generalError).toBe('Save Failed'); + + // Test default reducer case and out-of-range MOVE_ITEM + const defaultState = proposalBuilderReducer(unsavedFailState, { + type: 'NON_EXISTENT_ACTION', + } as unknown as ProposalBuilderAction); + expect(defaultState).toBe(unsavedFailState); + + const invalidMove = proposalBuilderReducer(unsavedFailState, { + type: 'MOVE_ITEM', + from: -1, + to: 0, + }); + expect(invalidMove).toBe(unsavedFailState); + + const emailSet = proposalBuilderReducer(unsavedFailState, { + type: 'SET_SEND_MODAL_EMAIL', + email: 'test@example.com', + }); + expect(emailSet.sendModalEmail).toBe('test@example.com'); + + // Test validateProposalForm with long title + const longTitleState = { ...unsavedFailState, title: 'a'.repeat(250) }; + const longTitleRes = validateProposalForm(longTitleState, mockT); + expect(longTitleRes.isValid).toBe(false); + expect(longTitleRes.errors.title).toBe('validation.titleTooLong'); + + // Test formatDeliverablesDescription with empty + expect(formatDeliverablesDescription('', [])).toBe(''); + + // Test createInitialProposalState with populated proposal and matched package + const existingHydratedState = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: mockStudioSettings, + packages: [mockPackage], + proposal: { + id: '01920000-0000-7000-8000-000000000201', + projectId: mockProject.id, + clientId: mockClient.id, + title: 'Hydrated Proposal', + status: 'draft', + version: 1, + currency: 'EUR', + subtotalMinor: 305000, + taxMinor: 48697, + totalMinor: 305000, + pricesIncludeTax: true, + isKleinunternehmer: false, + taxNotice: null, + validUntil: new Date('2026-12-31T23:59:59.999Z'), + tokenHash: 'abc', + tokenRevokedAt: null, + sentAt: null, + acceptedAt: null, + declinedAt: null, + declineReason: null, + acceptedByName: null, + acceptedByEmail: null, + acceptanceIpAnonymized: null, + acceptanceUserAgent: null, + acceptedOptionsSnapshot: null, + internalNotes: null, + createdAt: new Date(), + updatedAt: new Date(), + lineItems: [ + { + id: 'item-1', + proposalId: '01920000-0000-7000-8000-000000000201', + packageId: mockPackage.id, + catalogueItemId: null, + itemType: 'base_package', + name: 'Base Package', + description: 'Included Deliverables:\n- 1x Album\n* Prints\nPlain line', + unitPriceMinor: 280000, + quantity: 1, + minQuantity: 1, + maxQuantity: 1, + taxRateBasisPoints: 1900, + isOptional: false, + isSelected: true, + position: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'item-2', + proposalId: '01920000-0000-7000-8000-000000000201', + packageId: null, + catalogueItemId: mockCatalogueItem.id, + itemType: 'addon', + name: 'Extra Hour', + description: null, + unitPriceMinor: 25000, + quantity: 1, + minQuantity: 0, + maxQuantity: 2, + taxRateBasisPoints: 1900, + isOptional: true, + isSelected: true, + position: 1, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'item-3', + proposalId: '01920000-0000-7000-8000-000000000201', + packageId: null, + catalogueItemId: null, + itemType: 'custom', + name: 'Custom Shoot', + description: null, + unitPriceMinor: 10000, + quantity: 1, + minQuantity: 0, + maxQuantity: 1, + taxRateBasisPoints: 1900, + isOptional: false, + isSelected: true, + position: 2, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }, + }); + expect(existingHydratedState.basePackageItem?.name).toBe('Base Package'); + expect(existingHydratedState.addonItems).toHaveLength(2); + expect(existingHydratedState.deliverables.length).toBeGreaterThan(0); + + // Test fallback deliverables parsing when package is not in packages list + const unlistedPkgState = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: mockStudioSettings, + packages: [], + proposal: { + id: '01920000-0000-7000-8000-000000000201', + projectId: mockProject.id, + clientId: mockClient.id, + title: 'Unlisted Pkg Proposal', + status: 'draft', + version: 1, + currency: 'EUR', + subtotalMinor: 280000, + taxMinor: 44706, + totalMinor: 280000, + pricesIncludeTax: true, + isKleinunternehmer: false, + taxNotice: null, + validUntil: null, + tokenHash: 'abc', + tokenRevokedAt: null, + sentAt: null, + acceptedAt: null, + declinedAt: null, + declineReason: null, + acceptedByName: null, + acceptedByEmail: null, + acceptanceIpAnonymized: null, + acceptanceUserAgent: null, + acceptedOptionsSnapshot: null, + internalNotes: null, + createdAt: new Date(), + updatedAt: new Date(), + lineItems: [ + { + id: 'item-1', + proposalId: '01920000-0000-7000-8000-000000000201', + packageId: 'unlisted-package-id', + catalogueItemId: null, + itemType: 'base_package', + name: 'Custom Base', + description: 'Included Deliverables:\n- 1x Album\n* Prints\nPlain line', + unitPriceMinor: 280000, + quantity: 1, + minQuantity: 1, + maxQuantity: 1, + taxRateBasisPoints: 1900, + isOptional: false, + isSelected: true, + position: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }, + }); + expect(unlistedPkgState.deliverables).toHaveLength(2); + + // Test validateProposalForm basePackageItem with negative price and addon empty name + const currentBase = existingHydratedState.basePackageItem; + const currentAddon = existingHydratedState.addonItems[0]; + if (!currentBase || !currentAddon) { + throw new Error('Expected hydrated state to have basePackageItem and addon'); + } + const invalidItemsState: ProposalBuilderState = { + ...existingHydratedState, + basePackageItem: { + ...currentBase, + unitPriceMinor: -100, + }, + addonItems: [ + { + ...currentAddon, + name: ' ', + }, + ], + }; + const invalidItemsRes = validateProposalForm(invalidItemsState, mockT); + expect(invalidItemsRes.isValid).toBe(false); + expect(invalidItemsRes.errors.basePackage?.unitPrice).toBe('validation.negativePrice'); + expect(invalidItemsRes.errors.items?.[0]?.name).toBe('validation.nameRequired'); + + // Test validateSendModal with empty date and invalid email + const invalidModalState: ProposalBuilderState = { + ...existingHydratedState, + sendModalDate: '', + sendModalEmail: 'invalid-email', + }; + const invalidModalRes = validateSendModal(invalidModalState, mockT); + expect(invalidModalRes.isValid).toBe(false); + expect(invalidModalRes.errors.sendDate).toBe('validation.dateInPast'); + expect(invalidModalRes.errors.sendEmail).toBe('validation.invalidEmail'); + + // Test non-Error exception handling in handlers + let nonErrorState: ProposalBuilderState = { + ...existingHydratedState, + id: 'some-id', + sendModalDate: getDefaultExpirationDateString(), + }; + const nonErrorHandlers = ( + await import('@/components/proposals/proposal-builder-helpers') + ).createProposalBuilderActionHandlers({ + state: nonErrorState, + getState: () => nonErrorState, + dispatch: (act) => { + nonErrorState = proposalBuilderReducer(nonErrorState, act); + }, + t: mockT, + router: mockRouter, + locale: 'en', + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- explicitly testing non-Error string exception fallback + saveDraftAction: () => Promise.reject('String save error'), + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- explicitly testing non-Error string exception fallback + sendAction: () => Promise.reject('String send error'), + }); + await nonErrorHandlers.onSaveDraft(); + expect(nonErrorState.generalError).toBe('Error saving draft proposal'); + + await nonErrorHandlers.onSubmitSendProposal(); + expect(nonErrorState.generalError).toBe('Error sending proposal'); + + // Test send modal error when sendDate is valid but email is invalid + const invalidEmailState: ProposalBuilderState = { + ...nonErrorState, + sendModalEmail: 'not-an-email', + }; + const emailErrHandlers = ( + await import('@/components/proposals/proposal-builder-helpers') + ).createProposalBuilderActionHandlers({ + state: invalidEmailState, + getState: () => invalidEmailState, + dispatch: (act) => { + nonErrorState = proposalBuilderReducer(nonErrorState, act); + }, + t: mockT, + router: mockRouter, + locale: 'en', + saveDraftAction: () => Promise.resolve({ success: true as const, data: null as never }), + sendAction: () => Promise.resolve({ success: true as const, data: null as never }), + }); + await emailErrHandlers.onSubmitSendProposal(); + expect(nonErrorState.generalError).toBe('validation.invalidEmail'); + + // Test onCopyLink clipboard exception handling + Object.assign(navigator, { + clipboard: { + writeText: () => Promise.reject(new Error('Clipboard denied')), + }, + }); + const clipboardFailState: ProposalBuilderState = { + ...nonErrorState, + sentResult: { proposalId: 'p-1', rawToken: 'tok', tokenUrl: 'http://test' }, + linkCopied: false, + }; + const clipboardFailHandlers = ( + await import('@/components/proposals/proposal-builder-helpers') + ).createProposalBuilderActionHandlers({ + state: clipboardFailState, + getState: () => clipboardFailState, + dispatch: (act) => { + nonErrorState = proposalBuilderReducer(nonErrorState, act); + }, + t: mockT, + router: mockRouter, + locale: 'en', + saveDraftAction: () => Promise.resolve({ success: true as const, data: null as never }), + sendAction: () => Promise.resolve({ success: true as const, data: null as never }), + }); + await clipboardFailHandlers.onCopyLink(); + expect(nonErrorState.linkCopied).toBe(true); + + // Test UPDATE_BASE_PACKAGE_PRICE and TAX_RATE when basePackageItem is null + const noBaseState = { ...failState, basePackageItem: null }; + expect( + proposalBuilderReducer(noBaseState, { type: 'UPDATE_BASE_PACKAGE_PRICE', majorPrice: '100' }), + ).toBe(noBaseState); + expect( + proposalBuilderReducer(noBaseState, { + type: 'UPDATE_BASE_PACKAGE_TAX_RATE', + taxRateBasisPoints: 1900, + }), + ).toBe(noBaseState); + + // Test onOpenSendModal with valid title but negative item price + let invalidItemSendState: ProposalBuilderState = { + ...failState, + id: undefined, + title: 'Valid Title', + basePackageItem: { + ...mockPackage, + itemType: 'base_package' as const, + unitPriceMajor: '-10', + unitPriceMinor: -1, + quantity: 1, + minQuantity: 1, + maxQuantity: 1, + position: 0, + isOptional: false, + isSelected: true, + }, + addonItems: [ + { + id: '1', + itemType: 'custom' as const, + name: 'Item 1', + unitPriceMajor: '-5', + unitPriceMinor: -1, + quantity: 1, + minQuantity: 0, + maxQuantity: 1, + taxRateBasisPoints: 1900, + isOptional: false, + isSelected: true, + position: 0, + }, + ], + }; + const invalidItemSendHandlers = ( + await import('@/components/proposals/proposal-builder-helpers') + ).createProposalBuilderActionHandlers({ + state: invalidItemSendState, + getState: () => invalidItemSendState, + dispatch: (act) => { + invalidItemSendState = proposalBuilderReducer(invalidItemSendState, act); + }, + t: mockT, + router: mockRouter, + locale: 'en', + saveDraftAction: () => Promise.resolve({ success: true as const, data: null as never }), + sendAction: () => Promise.resolve({ success: true as const, data: null as never }), + }); + await invalidItemSendHandlers.onOpenSendModal(); + expect(invalidItemSendState.generalError).toBe('validation.negativePrice'); + + // Test onSaveDraft with existing proposal id (no router.push) + const existingDraftState: ProposalBuilderState = { ...failState, id: 'some-existing-id' }; + let savedState: ProposalBuilderState = existingDraftState; + const existingSaveHandlers = ( + await import('@/components/proposals/proposal-builder-helpers') + ).createProposalBuilderActionHandlers({ + state: existingDraftState, + getState: () => savedState, + dispatch: (act) => { + savedState = proposalBuilderReducer(savedState, act); + }, + t: mockT, + router: mockRouter, + locale: 'en', + saveDraftAction, + sendAction, + }); + await existingSaveHandlers.onSaveDraft(); + expect(savedState.saveSuccess).toBe(true); + }); + + it('exercises createProposalBuilderViewHandlers and row helper controllers', async () => { + const { + createProposalBuilderViewHandlers, + createDeliverableRowHandlers, + createAddonRowHandlers, + } = await import('@/components/proposals/proposal-builder-helpers'); + + const dispatched: ProposalBuilderAction[] = []; + const dispatch = (act: ProposalBuilderAction) => { + dispatched.push(act); + }; + const viewHandlers = createProposalBuilderViewHandlers(dispatch); + + viewHandlers.onTitleChange('New Title'); + viewHandlers.onSelectPackage(mockPackage); + viewHandlers.onUpdateBasePrice('2500.00'); + viewHandlers.onUpdateTaxRate(700); + viewHandlers.onAddDeliverable('Album', 1); + viewHandlers.onUpdateDeliverableTitle(0, 'Updated Album'); + viewHandlers.onUpdateDeliverableQty(0, 2); + viewHandlers.onRemoveDeliverable(0); + viewHandlers.onAddCatalogueItem(mockCatalogueItem); + viewHandlers.onAddCustomItem(); + viewHandlers.onUpdateItemField(0, 'name', 'Custom Item'); + viewHandlers.onMoveItem(0, 1); + viewHandlers.onRemoveItem(0); + viewHandlers.onCloseSendModal(); + viewHandlers.onSendDateChange('2026-10-10'); + viewHandlers.onSendEmailChange('client@example.com'); + viewHandlers.handleTitleInputChange({ + target: { value: 'Input Title' }, + } as unknown as React.ChangeEvent); + + expect(dispatched).toHaveLength(18); + expect(dispatched[0]).toEqual({ type: 'SET_TITLE', title: 'New Title' }); + expect(dispatched[1]).toEqual({ type: 'SELECT_PACKAGE', pkg: mockPackage }); + expect(dispatched[2]).toEqual({ type: 'UPDATE_BASE_PACKAGE_PRICE', majorPrice: '2500.00' }); + + // Deliverable row handlers + const delUpdated: unknown[] = []; + const delHandlers = createDeliverableRowHandlers( + 1, + (idx, title) => { + delUpdated.push({ idx, title }); + }, + (idx, qty) => { + delUpdated.push({ idx, qty }); + }, + (idx) => { + delUpdated.push({ removeIdx: idx }); + }, + ); + delHandlers.handleQtyChange({ + target: { value: '3' }, + } as unknown as React.ChangeEvent); + delHandlers.handleTitleChange({ + target: { value: 'Canvas' }, + } as unknown as React.ChangeEvent); + delHandlers.handleRemove(); + expect(delUpdated).toEqual([{ idx: 1, qty: 3 }, { idx: 1, title: 'Canvas' }, { removeIdx: 1 }]); + + // Addon row handlers + const addonUpdated: unknown[] = []; + const addonHandlers = createAddonRowHandlers( + 2, + (idx, field, value) => { + addonUpdated.push({ idx, field, value }); + }, + (from, to) => { + addonUpdated.push({ moveFrom: from, moveTo: to }); + }, + (idx) => { + addonUpdated.push({ removeIdx: idx }); + }, + ); + addonHandlers.handleNameChange({ + target: { value: 'Drone' }, + } as unknown as React.ChangeEvent); + addonHandlers.handlePriceChange({ + target: { value: '450.00' }, + } as unknown as React.ChangeEvent); + addonHandlers.handleQuantityChange({ + target: { value: '2' }, + } as unknown as React.ChangeEvent); + addonHandlers.handleMaxQuantityChange({ + target: { value: '5' }, + } as unknown as React.ChangeEvent); + addonHandlers.handleTaxRateChange({ + target: { value: '1900' }, + } as unknown as React.ChangeEvent); + addonHandlers.handleOptionalChange({ + target: { checked: true }, + } as unknown as React.ChangeEvent); + addonHandlers.handleMoveUp(); + addonHandlers.handleMoveDown(); + addonHandlers.handleRemove(); + + expect(addonUpdated).toHaveLength(9); + expect(addonUpdated[0]).toEqual({ idx: 2, field: 'name', value: 'Drone' }); + expect(addonUpdated[5]).toEqual({ idx: 2, field: 'isOptional', value: true }); + expect(addonUpdated[6]).toEqual({ moveFrom: 2, moveTo: 1 }); + expect(addonUpdated[7]).toEqual({ moveFrom: 2, moveTo: 3 }); + expect(addonUpdated[8]).toEqual({ removeIdx: 2 }); + }); + + it('exercises createPackageSectionController, createAddonSectionController, and createSendModalController', async () => { + const { + createPackageSectionController, + createAddonSectionController, + createSendModalController, + } = await import('@/components/proposals/proposal-builder-helpers'); + + // Package Section Controller + const pkgActions: unknown[] = []; + let delTitle = ''; + let delQty = '1'; + const pkgController = createPackageSectionController( + (pkg) => { + pkgActions.push({ selectPkg: pkg }); + }, + (price) => { + pkgActions.push({ basePrice: price }); + }, + (rate) => { + pkgActions.push({ taxRate: rate }); + }, + (title, qty) => { + pkgActions.push({ addDel: title, qty }); + }, + [mockPackage], + (val) => { + delTitle = val; + }, + (val) => { + delQty = val; + }, + ); + + pkgController.handlePackageSelectChange(''); + expect(pkgActions[0]).toEqual({ selectPkg: null }); + + pkgController.handlePackageSelectChange(mockPackage.id); + expect(pkgActions[1]).toEqual({ selectPkg: mockPackage }); + + pkgController.handleBasePriceChange({ + target: { value: '3000.00' }, + } as unknown as React.ChangeEvent); + expect(pkgActions[2]).toEqual({ basePrice: '3000.00' }); + + pkgController.handleTaxRateChange({ + target: { value: '700' }, + } as unknown as React.ChangeEvent); + expect(pkgActions[3]).toEqual({ taxRate: 700 }); + + expect(pkgController.handleAddDeliverable(' ', '1')).toBe(false); + expect(pkgController.handleAddDeliverable('Prints', '5')).toBe(true); + expect(pkgActions[4]).toEqual({ addDel: 'Prints', qty: 5 }); + + pkgController.handleTitleInputChange({ + target: { value: 'New Deliverable' }, + } as unknown as React.ChangeEvent); + expect(delTitle).toBe('New Deliverable'); + + pkgController.handleQtyInputChange({ + target: { value: '3' }, + } as unknown as React.ChangeEvent); + expect(delQty).toBe('3'); + + const fakePreventDefault = vi.fn(); + const fakeSubmitEvent = { + preventDefault: fakePreventDefault, + } as unknown as React.SyntheticEvent; + + // Invalid submit (blank title) + expect(pkgController.handleAddDeliverableSubmit('', '1')(fakeSubmitEvent)).toBe(false); + expect(fakePreventDefault).toHaveBeenCalled(); + + // Valid submit + expect(pkgController.handleAddDeliverableSubmit('Albums', '2')(fakeSubmitEvent)).toBe(true); + expect(delTitle).toBe(''); + expect(delQty).toBe('1'); + + // Addon Section Controller + const addonActions: unknown[] = []; + let selectedCatId = ''; + const addonController = createAddonSectionController( + [mockCatalogueItem], + (item) => { + addonActions.push(item); + }, + (id) => { + selectedCatId = id; + }, + ); + + expect(addonController.findAndAddCatalogueItem('')).toBe(false); + expect(addonController.findAndAddCatalogueItem('non-existent-id')).toBe(false); + expect(addonController.findAndAddCatalogueItem(mockCatalogueItem.id)).toBe(true); + expect(addonActions[0]).toEqual(mockCatalogueItem); + expect(selectedCatId).toBe(''); + + addonController.handleCatalogueSelectChange({ + target: { value: mockCatalogueItem.id }, + } as unknown as React.ChangeEvent); + expect(selectedCatId).toBe(mockCatalogueItem.id); + + // handleAddCatalogueClick curried handler + expect(addonController.handleAddCatalogueClick('')()).toBe(false); + expect(addonController.handleAddCatalogueClick('unknown-id')()).toBe(false); + expect(addonController.handleAddCatalogueClick(mockCatalogueItem.id)()).toBe(true); + + // Send Modal Controller + let modalClosed = false; + let modalDate = ''; + let modalEmail = ''; + const mockFocus = vi.fn(); + const mockTriggerRef = { current: { focus: mockFocus } as unknown as HTMLElement }; + + const modalController = createSendModalController( + () => { + modalClosed = true; + }, + (date) => { + modalDate = date; + }, + (email) => { + modalEmail = email; + }, + mockTriggerRef, + ); + + modalController.handleOpenChange(true); + expect(modalClosed).toBe(false); + + modalController.handleOpenChange(false); + expect(modalClosed).toBe(true); + + const fakeClosePreventDefault = vi.fn(); + const fakeCloseEvent = { preventDefault: fakeClosePreventDefault } as unknown as Event; + modalController.handleCloseAutoFocus(fakeCloseEvent); + expect(fakeClosePreventDefault).toHaveBeenCalled(); + expect(mockFocus).toHaveBeenCalled(); + + modalController.handleDateChange({ + target: { value: '2026-06-30' }, + } as unknown as React.ChangeEvent); + expect(modalDate).toBe('2026-06-30'); + + modalController.handleEmailChange({ + target: { value: 'client@example.com' }, + } as unknown as React.ChangeEvent); + expect(modalEmail).toBe('client@example.com'); + }); +}); diff --git a/tests/unit/proposal-builder-i18n-parity.test.ts b/tests/unit/proposal-builder-i18n-parity.test.ts new file mode 100644 index 0000000..b48d568 --- /dev/null +++ b/tests/unit/proposal-builder-i18n-parity.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import enMessages from '@/../messages/en.json'; +import deMessages from '@/../messages/de.json'; + +function getNestedValue(obj: unknown, path: string): unknown { + const parts = path.split('.'); + let current: unknown = obj; + for (const part of parts) { + if (current && typeof current === 'object' && part in current) { + current = Reflect.get(current, part); + } else { + return undefined; + } + } + return current; +} + +function extractAllKeys(obj: Record, prefix = ''): string[] { + const keys: string[] = []; + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + keys.push(...extractAllKeys(value as Record, fullKey)); + } else { + keys.push(fullKey); + } + } + return keys; +} + +describe('TASK-0061 / AC-7: Proposal Builder i18n parity', () => { + it('AC-7: all proposals.builder keys exist in both en.json and de.json with non-empty translations', () => { + const enRecord = enMessages as { proposals?: { builder?: Record } }; + const deRecord = deMessages as { proposals?: { builder?: Record } }; + + const enProposals = enRecord.proposals; + const deProposals = deRecord.proposals; + + expect(enProposals).toBeDefined(); + expect(deProposals).toBeDefined(); + + const enBuilder = enProposals?.builder ?? {}; + const deBuilder = deProposals?.builder ?? {}; + + const enKeys = extractAllKeys(enBuilder, 'proposals.builder'); + const deKeys = extractAllKeys(deBuilder, 'proposals.builder'); + + expect(enKeys.length).toBeGreaterThan(20); + expect(deKeys.length).toBeGreaterThan(20); + + for (const key of enKeys) { + const enVal = getNestedValue(enMessages, key); + const deVal = getNestedValue(deMessages, key); + + expect(typeof enVal).toBe('string'); + expect(typeof enVal === 'string' && enVal.trim().length > 0).toBe(true); + + expect(typeof deVal).toBe('string'); + expect(typeof deVal === 'string' && deVal.trim().length > 0).toBe(true); + } + + expect(new Set(enKeys)).toEqual(new Set(deKeys)); + }); +}); diff --git a/tests/unit/proposal-builder-render.test.tsx b/tests/unit/proposal-builder-render.test.tsx new file mode 100644 index 0000000..1aa2f0b --- /dev/null +++ b/tests/unit/proposal-builder-render.test.tsx @@ -0,0 +1,913 @@ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +// Mock server-only +vi.mock('server-only', () => ({})); + +// Mock next/navigation +const mockPush = vi.fn(); +const mockReplace = vi.fn(); +const mockRefresh = vi.fn(); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + replace: mockReplace, + refresh: mockRefresh, + }), + usePathname: () => '/en/projects/01920000-0000-7000-8000-000000000071/proposals/new', + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes & { href: string }) => ( + + {children} + + ), + useRouter: () => ({ + push: mockPush, + replace: mockReplace, + refresh: mockRefresh, + }), + usePathname: () => '/en/projects/01920000-0000-7000-8000-000000000071/proposals/new', +})); + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + DialogHeader: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + DialogTitle: ({ children, ...props }: React.HTMLAttributes) => ( +

{children}

+ ), + DialogDescription: ({ children, ...props }: React.HTMLAttributes) => ( +

{children}

+ ), + DialogFooter: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), +})); + +// Mock translations with STRICT missing key check +import enMessages from '@/../messages/en.json'; +import deMessages from '@/../messages/de.json'; + +function getNestedValue(obj: Record, path: string): unknown { + const parts = path.split('.'); + let current: unknown = obj; + for (const part of parts) { + if (current && typeof current === 'object' && part in current) { + current = Reflect.get(current, part); + } else { + return undefined; + } + } + return current; +} + +let currentLocale: 'en' | 'de' = 'en'; + +vi.mock('next-intl', () => ({ + useTranslations: (namespace?: string) => { + return (key: string, values?: Record) => { + const messages = currentLocale === 'de' ? deMessages : enMessages; + const fullPath = namespace ? `${namespace}.${key}` : key; + const raw = getNestedValue(messages, fullPath); + if (typeof raw !== 'string') { + throw new Error(`Missing translation key: "${fullPath}" for locale "${currentLocale}"`); + } + let text = raw; + if (values) { + for (const [vKey, vVal] of Object.entries(values)) { + text = text.replace(new RegExp(`\\{${vKey}\\}`, 'g'), String(vVal)); + } + } + return text; + }; + }, +})); + +import { ProposalBuilderView } from '@/components/proposals/proposal-builder-view'; +import { SendProposalModal } from '@/components/proposals/send-proposal-modal'; +import { PackageSelectorSection } from '@/components/proposals/package-selector-section'; +import { AddonOptionsSection } from '@/components/proposals/addon-options-section'; +import { FinancialSummarySection } from '@/components/proposals/financial-summary-section'; +import { + createInitialProposalState, + type ProposalLineItemState, +} from '@/components/proposals/proposal-builder-helpers'; +import type { PackageWithIncludedItems } from '@/server/packages/repo'; +import type { CatalogueItem } from '@/server/db/schema/packages'; +import type { StudioSettings } from '@/server/db/schema/studio-settings'; +import type { ProposalWithLineItems } from '@/server/proposals/repo'; + +const mockPackage: PackageWithIncludedItems = { + id: '01920000-0000-7000-8000-000000000101', + name: 'Full Day Wedding Coverage', + slug: 'wedding-full-day', + description: 'Comprehensive 8-hour wedding photography', + projectTypeKey: 'wedding', + basePriceMinor: 280000, + currency: 'EUR', + taxRateBasisPoints: 1900, + position: 1, + isActive: true, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + includedItems: [ + { + id: '01920000-0000-7000-8000-000000000111', + packageId: '01920000-0000-7000-8000-000000000101', + title: '8 Hours Continuous Coverage', + description: 'Prep to first dance', + quantity: 1, + position: 1, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }, + ], +}; + +const mockCatalogueItem: CatalogueItem = { + id: '01920000-0000-7000-8000-000000000301', + name: 'Extra Hour Coverage', + slug: 'extra-hour-coverage', + description: 'Additional hour on location', + category: 'service', + unitPriceMinor: 25000, + currency: 'EUR', + taxRateBasisPoints: 1900, + position: 1, + isActive: true, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +}; + +const mockStudioSettings: StudioSettings = { + id: '01920000-0000-7000-8000-000000000001', + studio_name: 'Studio Light', + default_locale: 'en', + timezone: 'UTC', + currency: 'EUR', + mfa_required: false, + mfa_postponed_until: null, + forms_pow_hmac_key: null, + tax_number: '12/345/67890', + vat_id: 'DE123456789', + is_kleinunternehmer: false, + kleinunternehmer_notice: null, + default_tax_rate_basis_points: 1900, + prices_include_tax: true, + created_at: new Date('2026-01-01T00:00:00Z'), + updated_at: new Date('2026-01-01T00:00:00Z'), +}; + +const mockProject = { + id: '01920000-0000-7000-8000-000000000071', + title: 'Emma & Liam Wedding', + projectTypeKey: 'wedding', + currency: 'EUR', +}; + +const mockClient = { + id: '01920000-0000-7000-8000-000000000081', + displayName: 'Emma Watson', + email: 'emma@example.com', + phone: '+49 30 123456', +}; + +const mockExistingProposal: ProposalWithLineItems = { + id: '01920000-0000-7000-8000-000000000201', + projectId: mockProject.id, + clientId: mockClient.id, + title: 'Wedding Photography Proposal — Emma & Liam (Draft)', + status: 'draft', + version: 1, + currency: 'EUR', + subtotalMinor: 280000, + taxMinor: 44706, + totalMinor: 280000, + pricesIncludeTax: true, + isKleinunternehmer: false, + taxNotice: null, + validUntil: new Date('2026-12-31T23:59:59.999Z'), + tokenHash: 'tokenhash123', + tokenRevokedAt: null, + sentAt: null, + acceptedAt: null, + declinedAt: null, + declineReason: null, + acceptedByName: null, + acceptedByEmail: null, + acceptanceIpAnonymized: null, + acceptanceUserAgent: null, + acceptedOptionsSnapshot: null, + internalNotes: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + lineItems: [ + { + id: '01920000-0000-7000-8000-000000000211', + proposalId: '01920000-0000-7000-8000-000000000201', + packageId: mockPackage.id, + catalogueItemId: null, + itemType: 'base_package', + name: 'Full Day Wedding Coverage', + description: 'Comprehensive 8-hour wedding photography', + unitPriceMinor: 280000, + quantity: 1, + minQuantity: 1, + maxQuantity: 1, + taxRateBasisPoints: 1900, + isOptional: false, + isSelected: true, + position: 0, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }, + { + id: '01920000-0000-7000-8000-000000000212', + proposalId: '01920000-0000-7000-8000-000000000201', + packageId: null, + catalogueItemId: mockCatalogueItem.id, + itemType: 'addon', + name: 'Extra Hour Coverage', + description: null, + unitPriceMinor: 25000, + quantity: 1, + minQuantity: 0, + maxQuantity: 2, + taxRateBasisPoints: 1900, + isOptional: true, + isSelected: true, + position: 1, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }, + ], +}; + +describe('ProposalBuilderView Static Markup Rendering & i18n parity', () => { + it('AC-1 & AC-7: renders ProposalBuilderView in new proposal mode without missing keys in English', () => { + currentLocale = 'en'; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('data-testid="proposal-builder-view"'); + expect(html).toContain('data-testid="package-selector-section"'); + expect(html).toContain('data-testid="addon-options-section"'); + expect(html).toContain('data-testid="financial-summary-section"'); + expect(html).toContain('data-testid="proposal-title-input"'); + expect(html).toContain('data-testid="client-review-card"'); + expect(html).toContain('Emma Watson'); + expect(html).toContain('Proposal Composer'); + }); + + it('AC-7: renders ProposalBuilderView in German (/de/...) with zero missing translation keys', () => { + currentLocale = 'de'; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('data-testid="proposal-builder-view"'); + expect(html).toContain('Angebots-Editor'); + expect(html).toContain('1. Basispaket'); + expect(html).toContain('2. Zusatzoptionen & Individuelle Positionen'); + expect(html).toContain('3. Finanzübersicht & Versand'); + }); + + it('renders ProposalBuilderView in edit mode with populated package deliverables and addons', () => { + currentLocale = 'en'; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('data-testid="selected-package-banner"'); + expect(html).toContain('Full Day Wedding Coverage'); + expect(html).toContain('data-testid="addon-row-0"'); + expect(html).toContain('Extra Hour Coverage'); + expect(html).toContain('data-testid="financial-summary-total"'); + }); + + it('AC-4 & AC-7: renders SendProposalModal with open=true in both English and German', () => { + const initialState = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: { + currency: mockStudioSettings.currency, + isKleinunternehmer: mockStudioSettings.is_kleinunternehmer, + pricesIncludeTax: mockStudioSettings.prices_include_tax, + defaultTaxRateBasisPoints: mockStudioSettings.default_tax_rate_basis_points, + kleinunternehmerNotice: mockStudioSettings.kleinunternehmer_notice, + }, + packages: [mockPackage], + proposal: mockExistingProposal, + }); + + const openState = { + ...initialState, + sendModalOpen: true, + }; + + // Render in English + currentLocale = 'en'; + const htmlEn = renderToStaticMarkup( + , + ); + expect(htmlEn).toContain('data-testid="send-proposal-modal"'); + expect(htmlEn).toContain('Send Proposal to Client'); + expect(htmlEn).toContain('data-testid="send-recipient-email-input"'); + expect(htmlEn).toContain('data-testid="send-valid-until-input"'); + + // Render in German + currentLocale = 'de'; + const htmlDe = renderToStaticMarkup( + , + ); + expect(htmlDe).toContain('data-testid="send-proposal-modal"'); + expect(htmlDe).toContain('Angebot an Kunden senden'); + }); + + it('AC-4: renders SendProposalModal in sent result state with copy link notification', () => { + currentLocale = 'en'; + const initialState = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: { + currency: mockStudioSettings.currency, + isKleinunternehmer: mockStudioSettings.is_kleinunternehmer, + pricesIncludeTax: mockStudioSettings.prices_include_tax, + defaultTaxRateBasisPoints: mockStudioSettings.default_tax_rate_basis_points, + kleinunternehmerNotice: mockStudioSettings.kleinunternehmer_notice, + }, + packages: [mockPackage], + proposal: mockExistingProposal, + }); + + const sentState = { + ...initialState, + sendModalOpen: false, + sentResult: { + proposalId: mockExistingProposal.id, + rawToken: 'rawtokensecret', + tokenUrl: 'http://localhost:3000/p/rawtokensecret', + }, + linkCopied: true, + }; + + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('data-testid="copy-link-notification"'); + expect(html).toContain('Proposal Sent Successfully!'); + expect(html).toContain('http://localhost:3000/p/rawtokensecret'); + expect(html).toContain('data-testid="link-copied-badge"'); + }); + + it('renders PackageSelectorSection in unselected state and with deliverables', () => { + currentLocale = 'en'; + const htmlEmpty = renderToStaticMarkup( + , + ); + expect(htmlEmpty).toContain('data-testid="package-selector-section"'); + expect(htmlEmpty).toContain('data-testid="no-deliverables-msg"'); + + const htmlWithDeliverables = renderToStaticMarkup( + , + ); + expect(htmlWithDeliverables).toContain('data-testid="deliverable-item-0"'); + expect(htmlWithDeliverables).toContain('data-testid="deliverable-item-1"'); + expect(htmlWithDeliverables).toContain('Invalid price error'); + }); + + it('renders AddonOptionsSection in empty state and with configured items', () => { + currentLocale = 'en'; + const htmlEmpty = renderToStaticMarkup( + , + ); + expect(htmlEmpty).toContain('data-testid="no-addons-msg"'); + + const htmlWithAddons = renderToStaticMarkup( + , + ); + expect(htmlWithAddons).toContain('data-testid="addon-row-0"'); + expect(htmlWithAddons).toContain('data-testid="addon-row-1"'); + expect(htmlWithAddons).toContain('data-testid="addon-row-2"'); + expect(htmlWithAddons).toContain('Item price error'); + expect(htmlWithAddons).toContain('Item name error'); + expect(htmlWithAddons).toContain('data-testid="addon-optional-badge-0"'); + expect(htmlWithAddons).toContain('data-testid="addon-optional-badge-1"'); + }); + + it('renders FinancialSummarySection in Kleinunternehmer mode and net pricing mode', () => { + currentLocale = 'en'; + const state = createInitialProposalState({ + project: mockProject, + client: mockClient, + studioSettings: { + currency: 'EUR', + isKleinunternehmer: true, + pricesIncludeTax: false, + defaultTaxRateBasisPoints: 1900, + kleinunternehmerNotice: 'Custom § 19 UStG notice text.', + }, + packages: [mockPackage], + }); + + const kuState = { + ...state, + isKleinunternehmer: true, + taxNotice: 'Custom § 19 UStG notice text.', + saveSuccess: true, + }; + + const htmlKu = renderToStaticMarkup( + , + ); + expect(htmlKu).toContain('data-testid="financial-summary-section"'); + expect(htmlKu).toContain('data-testid="kleinunternehmer-notice"'); + expect(htmlKu).toContain('Custom § 19 UStG notice text.'); + expect(htmlKu).toContain('data-testid="save-success-notification"'); + + // FinancialSummarySection with isSaving and generalError + const errorFinState = { + ...state, + isSaving: true, + generalError: 'Server database failure', + }; + const htmlFinErr = renderToStaticMarkup( + , + ); + expect(htmlFinErr).toContain('data-testid="general-error-notification"'); + expect(htmlFinErr).toContain('Server database failure'); + expect(htmlFinErr).toContain('Saving Draft...'); + + // SendProposalModal with validation errors + const modalErrState = { + ...state, + sendModalOpen: true, + sendModalEmail: 'bad-email', + sendModalDate: '2020-01-01', + isSending: true, + }; + const htmlModalErr = renderToStaticMarkup( + , + ); + expect(htmlModalErr).toContain('data-testid="send-email-error"'); + expect(htmlModalErr).toContain('data-testid="send-date-error"'); + expect(htmlModalErr).toContain('Sending...'); + }); + + it('I1-U01 & I1-V01 regression: renders localized tax rate options in package selector and addon options for both EN and DE', () => { + const testBasePackage: ProposalLineItemState = { + id: 'bp-1', + packageId: mockPackage.id, + catalogueItemId: null, + name: 'Wedding Photography', + description: null, + itemType: 'base_package', + unitPriceMajor: '2500.00', + unitPriceMinor: 250000, + taxRateBasisPoints: 1900, + quantity: 1, + minQuantity: 1, + maxQuantity: 1, + isOptional: false, + isSelected: true, + position: 0, + }; + + const testAddonItems: ProposalLineItemState[] = [ + { + id: 'addon-1', + packageId: null, + catalogueItemId: mockCatalogueItem.id, + name: 'Extra Hour', + description: null, + itemType: 'addon', + unitPriceMajor: '150.00', + unitPriceMinor: 15000, + taxRateBasisPoints: 1900, + quantity: 1, + minQuantity: 0, + maxQuantity: 2, + isOptional: true, + isSelected: true, + position: 1, + }, + ]; + + // English rendering + currentLocale = 'en'; + const htmlPkgEn = renderToStaticMarkup( + , + ); + expect(htmlPkgEn).toContain('19% Standard'); + expect(htmlPkgEn).toContain('7% Reduced'); + expect(htmlPkgEn).toContain('0% Tax Exempt'); + + const htmlAddonEn = renderToStaticMarkup( + , + ); + expect(htmlAddonEn).toContain('19% Standard'); + expect(htmlAddonEn).toContain('7% Reduced'); + expect(htmlAddonEn).toContain('0% Tax Exempt'); + + // German rendering + currentLocale = 'de'; + const htmlPkgDe = renderToStaticMarkup( + , + ); + expect(htmlPkgDe).toContain('19% (Regelsteuersatz)'); + expect(htmlPkgDe).toContain('7% (Ermäßigt)'); + expect(htmlPkgDe).toContain('0% (Steuerbefreit)'); + expect(htmlPkgDe).not.toContain('19% Standard'); + + const htmlAddonDe = renderToStaticMarkup( + , + ); + expect(htmlAddonDe).toContain('19% (Regelsteuersatz)'); + expect(htmlAddonDe).toContain('7% (Ermäßigt)'); + expect(htmlAddonDe).toContain('0% (Steuerbefreit)'); + expect(htmlAddonDe).not.toContain('19% Standard'); + + // Reset locale + currentLocale = 'en'; + }); + + it('I1-U03 regression: renders visible text label for optional toggle on mobile viewports', () => { + currentLocale = 'en'; + const testAddon: ProposalLineItemState[] = [ + { + id: 'addon-opt-1', + packageId: null, + catalogueItemId: mockCatalogueItem.id, + name: 'Extra Photobook', + description: null, + itemType: 'addon', + unitPriceMajor: '250.00', + unitPriceMinor: 25000, + taxRateBasisPoints: 1900, + quantity: 1, + minQuantity: 0, + maxQuantity: 2, + isOptional: true, + isSelected: true, + position: 1, + }, + ]; + + const htmlAddon = renderToStaticMarkup( + , + ); + // Label should be visible on mobile (md:sr-only cursor-pointer) + expect(htmlAddon).toContain('for="addon-optional-toggle-0"'); + expect(htmlAddon).toContain('text-xs font-medium text-foreground md:sr-only cursor-pointer'); + expect(htmlAddon).toContain('Optional for Client'); + + // In German + currentLocale = 'de'; + const htmlAddonDe = renderToStaticMarkup( + , + ); + expect(htmlAddonDe).toContain('Optional für Kunden'); + currentLocale = 'en'; + }); + + it('I1-U04 regression: renders localized aria-labels for deliverable inputs in both EN and DE', () => { + currentLocale = 'en'; + const testDeliverables = [ + { + id: 'del-1', + title: 'Full Day Coverage', + quantity: 1, + includedInPackage: true, + }, + ]; + + const htmlPkgEn = renderToStaticMarkup( + , + ); + expect(htmlPkgEn).toContain('aria-label="Full Day Coverage quantity"'); + expect(htmlPkgEn).toContain('aria-label="Deliverable title 1"'); + + // German + currentLocale = 'de'; + const htmlPkgDe = renderToStaticMarkup( + , + ); + expect(htmlPkgDe).toContain('aria-label="Full Day Coverage Anzahl"'); + expect(htmlPkgDe).toContain('aria-label="Leistungstitel 1"'); + expect(htmlPkgDe).not.toContain('quantity'); + expect(htmlPkgDe).not.toContain('Deliverable title'); + currentLocale = 'en'; + }); +});