Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
125 changes: 125 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
92 changes: 92 additions & 0 deletions src/app/[locale]/projects/[id]/proposals/[proposalId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 (
<ProposalBuilderView
project={projectDetail.project}
client={projectDetail.client}
packages={packages}
catalogueItems={catalogueItems}
studioSettings={studioSettings}
proposal={proposal}
locale={locale}
/>
);
}
Loading
Loading