From 2a8ae0ad652570f9f5d59178e20f35790eb380de Mon Sep 17 00:00:00 2001
From: Ediminator <211899731+Ediminator@users.noreply.github.com>
Date: Thu, 5 Nov 2026 00:22:47 +0100
Subject: [PATCH 1/2] feat(proposals): project detail proposals integration and
e5 e2e journey (TASK-0063)
Signed-off-by: Ediminator <211899731+Ediminator@users.noreply.github.com>
---
messages/de.json | 37 +
messages/en.json | 37 +
src/components/clients/client-timeline.tsx | 7 +
.../projects/project-detail-view.tsx | 47 +-
.../projects/proposals-section-helpers.ts | 322 ++++++++
src/components/projects/proposals-section.tsx | 278 +++++++
.../projects/revoke-proposal-dialog.tsx | 109 +++
src/server/db/seeds/proposals.ts | 31 +-
src/server/projects/service.ts | 72 +-
src/server/proposals/actions.ts | 89 +-
tests/e2e/epic-e5-journey.spec.ts | 435 ++++++++++
tests/unit/project-detail-proposals.test.tsx | 773 ++++++++++++++++++
.../unit/proposal-public-link-action.test.ts | 142 ++++
13 files changed, 2373 insertions(+), 6 deletions(-)
create mode 100644 src/components/projects/proposals-section-helpers.ts
create mode 100644 src/components/projects/proposals-section.tsx
create mode 100644 src/components/projects/revoke-proposal-dialog.tsx
create mode 100644 tests/e2e/epic-e5-journey.spec.ts
create mode 100644 tests/unit/project-detail-proposals.test.tsx
create mode 100644 tests/unit/proposal-public-link-action.test.ts
diff --git a/messages/de.json b/messages/de.json
index 7b79283..43ad791 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1763,6 +1763,7 @@
"cancel": "Abbrechen",
"tabs": {
"all": "Übersicht",
+ "proposals": "Angebote",
"shoots": "Shootings & Sessions",
"locations": "Orte & Venues",
"timeline": "Ablauf & Zeitplan",
@@ -1870,6 +1871,42 @@
"incompleteAria": "Als erledigt markieren",
"submit": "Aufgabe hinzufügen",
"submitting": "Wird hinzugefügt..."
+ },
+ "proposals": {
+ "title": "Kostenvoranschläge & Angebote",
+ "description": "Kommerzielle Angebote, Paketauswahlen und Kundenannahme-Links für dieses Projekt.",
+ "newProposal": "Neues Angebot",
+ "empty": "Noch keine Angebote für dieses Projekt erstellt.",
+ "version": "Version v{version}",
+ "createdAt": "Erstellt am {date}",
+ "total": "Gesamtbetrag",
+ "validUntil": "Gültig bis {date}",
+ "acceptedOn": "Angenommen am {date}",
+ "noExpiration": "Keine Befristung",
+ "statuses": {
+ "draft": "Entwurf",
+ "sent": "Versendet",
+ "accepted": "Angenommen",
+ "declined": "Abgelehnt",
+ "expired": "Abgelaufen",
+ "superseded": "Ersetzt"
+ },
+ "actions": {
+ "editDraft": "Entwurf bearbeiten",
+ "copyPublicLink": "Öffentlichen Link kopieren",
+ "linkCopied": "Link kopiert!",
+ "revokeLink": "Link widerrufen",
+ "linkRevoked": "Link widerrufen",
+ "copyError": "Link konnte nicht in die Zwischenablage kopiert werden.",
+ "revokeError": "Link konnte nicht widerrufen werden."
+ },
+ "revokeDialog": {
+ "title": "Öffentlichen Link widerrufen",
+ "description": "Möchten Sie den öffentlichen Link für dieses Angebot wirklich widerrufen? Der Kunde verliert sofort den Zugriff und kann das Angebot nicht mehr online einsehen oder annehmen.",
+ "cancel": "Abbrechen",
+ "confirm": "Link widerrufen",
+ "revoking": "Wird widerrufen..."
+ }
}
}
},
diff --git a/messages/en.json b/messages/en.json
index 7eef499..d6212cb 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1763,6 +1763,7 @@
"cancel": "Cancel",
"tabs": {
"all": "Overview",
+ "proposals": "Proposals",
"shoots": "Shoots & Sessions",
"locations": "Locations",
"timeline": "Timeline",
@@ -1870,6 +1871,42 @@
"incompleteAria": "Mark as completed",
"submit": "Add Task",
"submitting": "Adding..."
+ },
+ "proposals": {
+ "title": "Quotes & Proposals",
+ "description": "Commercial proposals, package selections, and client acceptance links for this project.",
+ "newProposal": "New Proposal",
+ "empty": "No proposals created for this project yet.",
+ "version": "Revision v{version}",
+ "createdAt": "Created on {date}",
+ "total": "Total Amount",
+ "validUntil": "Valid until {date}",
+ "acceptedOn": "Accepted on {date}",
+ "noExpiration": "No expiration date",
+ "statuses": {
+ "draft": "Draft",
+ "sent": "Sent",
+ "accepted": "Accepted",
+ "declined": "Declined",
+ "expired": "Expired",
+ "superseded": "Superseded"
+ },
+ "actions": {
+ "editDraft": "Edit Draft",
+ "copyPublicLink": "Copy Public Link",
+ "linkCopied": "Link Copied!",
+ "revokeLink": "Revoke Link",
+ "linkRevoked": "Link Revoked",
+ "copyError": "Failed to copy link to clipboard.",
+ "revokeError": "Failed to revoke proposal link."
+ },
+ "revokeDialog": {
+ "title": "Revoke Public Link",
+ "description": "Are you sure you want to revoke the public link for this proposal? The client will immediately lose access and will no longer be able to view or accept this proposal online.",
+ "cancel": "Cancel",
+ "confirm": "Revoke Link",
+ "revoking": "Revoking..."
+ }
}
}
},
diff --git a/src/components/clients/client-timeline.tsx b/src/components/clients/client-timeline.tsx
index 0163462..8e03ce2 100644
--- a/src/components/clients/client-timeline.tsx
+++ b/src/components/clients/client-timeline.tsx
@@ -18,6 +18,7 @@ import {
Mail,
FolderKanban,
ArrowRightCircle,
+ CheckCircle2,
} from 'lucide-react';
import type { ResolvedTimelineItem } from '@/server/timeline/repo';
import { formatDateTime } from '@/lib/formatters';
@@ -357,11 +358,17 @@ export function ClientTimeline({
{event.type === 'project.stage_changed' && (
)}
+ {event.type === 'proposal.accepted' && (
+
+ )}
{isNote && }
+ {event.type === 'proposal.accepted' && (
+
{t('events.proposal_accepted')}
+ )}
{event.type === 'client.created' && (
{t('events.client_created')}
)}
diff --git a/src/components/projects/project-detail-view.tsx b/src/components/projects/project-detail-view.tsx
index 9b58c7a..75e98c1 100644
--- a/src/components/projects/project-detail-view.tsx
+++ b/src/components/projects/project-detail-view.tsx
@@ -38,6 +38,7 @@ import {
transitionProjectStageAction,
} from '@/server/projects/actions';
import { TaskWarningDialog, type IncompleteTaskItem } from './task-warning-dialog';
+import { ProposalsSection } from './proposals-section';
import { ShootsSection } from './shoots-section';
import { LocationsSection } from './locations-section';
import { TimelineScheduleSection } from './timeline-schedule-section';
@@ -49,7 +50,7 @@ export interface ProjectDetailViewProps {
locale: string;
}
-type TabKey = 'all' | 'shoots' | 'locations' | 'timeline' | 'participants' | 'tasks';
+type TabKey = 'all' | 'proposals' | 'shoots' | 'locations' | 'timeline' | 'participants' | 'tasks';
const PROJECT_TYPES = [
'wedding',
@@ -261,6 +262,11 @@ export function ProjectDetailView({ initialData, locale }: ProjectDetailViewProp
// Tabs metadata
const tabList: { key: TabKey; label: string; count?: number }[] = [
{ key: 'all', label: tDetail('tabs.all') },
+ {
+ key: 'proposals',
+ label: tDetail('tabs.proposals'),
+ count: initialData.proposals?.length ?? 0,
+ },
{ key: 'shoots', label: tDetail('tabs.shoots'), count: shoots.length },
{ key: 'locations', label: tDetail('tabs.locations'), count: locations.length },
{ key: 'timeline', label: tDetail('tabs.timeline'), count: timelineItems.length },
@@ -430,7 +436,7 @@ export function ProjectDetailView({ initialData, locale }: ProjectDetailViewProp
aria-label="Project sections"
className="flex flex-wrap gap-2 -mb-px overflow-x-auto py-1"
>
- {tabList.map((tab) => {
+ {tabList.map((tab, idx) => {
const isActive = activeTab === tab.key;
return (