@voyant-travel/quotes-react 0.122.0 → 0.123.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/admin/index.d.ts +51 -0
  2. package/dist/admin/index.d.ts.map +1 -0
  3. package/dist/admin/index.js +85 -0
  4. package/dist/admin/pages/quote-detail-page.d.ts +13 -0
  5. package/dist/admin/pages/quote-detail-page.d.ts.map +1 -0
  6. package/dist/admin/pages/quote-detail-page.js +397 -0
  7. package/dist/admin/pipeline-dialogs.d.ts +16 -0
  8. package/dist/admin/pipeline-dialogs.d.ts.map +1 -0
  9. package/dist/admin/pipeline-dialogs.js +105 -0
  10. package/dist/admin/quote-content-sections.d.ts +57 -0
  11. package/dist/admin/quote-content-sections.d.ts.map +1 -0
  12. package/dist/admin/quote-content-sections.js +181 -0
  13. package/dist/admin/quote-detail-sections.d.ts +34 -0
  14. package/dist/admin/quote-detail-sections.d.ts.map +1 -0
  15. package/dist/admin/quote-detail-sections.js +52 -0
  16. package/dist/admin/quotes-board-host.d.ts +10 -0
  17. package/dist/admin/quotes-board-host.d.ts.map +1 -0
  18. package/dist/admin/quotes-board-host.js +72 -0
  19. package/dist/hooks/use-quote-media-mutation.d.ts +51 -0
  20. package/dist/hooks/use-quote-media-mutation.d.ts.map +1 -0
  21. package/dist/hooks/use-quote-media-mutation.js +63 -0
  22. package/dist/hooks/use-quote-media.d.ts +21 -0
  23. package/dist/hooks/use-quote-media.d.ts.map +1 -0
  24. package/dist/hooks/use-quote-media.js +19 -0
  25. package/dist/hooks/use-quote-mutation.d.ts +8 -0
  26. package/dist/hooks/use-quote-mutation.d.ts.map +1 -1
  27. package/dist/hooks/use-quote-participant-mutation.d.ts +24 -0
  28. package/dist/hooks/use-quote-participant-mutation.d.ts.map +1 -0
  29. package/dist/hooks/use-quote-participant-mutation.js +28 -0
  30. package/dist/hooks/use-quote-participants.d.ts +15 -0
  31. package/dist/hooks/use-quote-participants.d.ts.map +1 -0
  32. package/dist/hooks/use-quote-participants.js +16 -0
  33. package/dist/hooks/use-quote-product-mutation.d.ts +57 -0
  34. package/dist/hooks/use-quote-product-mutation.d.ts.map +1 -0
  35. package/dist/hooks/use-quote-product-mutation.js +36 -0
  36. package/dist/hooks/use-quote-products.d.ts +22 -0
  37. package/dist/hooks/use-quote-products.d.ts.map +1 -0
  38. package/dist/hooks/use-quote-products.js +19 -0
  39. package/dist/hooks/use-quote-version-mutation.d.ts +79 -0
  40. package/dist/hooks/use-quote-version-mutation.d.ts.map +1 -1
  41. package/dist/hooks/use-quote-version-mutation.js +58 -0
  42. package/dist/hooks/use-quote.d.ts +4 -0
  43. package/dist/hooks/use-quote.d.ts.map +1 -1
  44. package/dist/hooks/use-quotes.d.ts +4 -0
  45. package/dist/hooks/use-quotes.d.ts.map +1 -1
  46. package/dist/i18n/en/commerce.d.ts +145 -0
  47. package/dist/i18n/en/commerce.d.ts.map +1 -1
  48. package/dist/i18n/en/commerce.js +145 -0
  49. package/dist/i18n/en.d.ts +145 -0
  50. package/dist/i18n/en.d.ts.map +1 -1
  51. package/dist/i18n/messages.d.ts +145 -0
  52. package/dist/i18n/messages.d.ts.map +1 -1
  53. package/dist/i18n/provider.d.ts +290 -0
  54. package/dist/i18n/provider.d.ts.map +1 -1
  55. package/dist/i18n/ro/commerce.d.ts +145 -0
  56. package/dist/i18n/ro/commerce.d.ts.map +1 -1
  57. package/dist/i18n/ro/commerce.js +145 -0
  58. package/dist/i18n/ro.d.ts +145 -0
  59. package/dist/i18n/ro.d.ts.map +1 -1
  60. package/dist/index.d.ts +7 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +7 -1
  63. package/dist/query-keys.d.ts +3 -0
  64. package/dist/query-keys.d.ts.map +1 -1
  65. package/dist/query-keys.js +3 -0
  66. package/dist/query-options.d.ts +32 -0
  67. package/dist/query-options.d.ts.map +1 -1
  68. package/dist/schemas.d.ts +142 -0
  69. package/dist/schemas.d.ts.map +1 -1
  70. package/dist/schemas.js +47 -0
  71. package/package.json +27 -7
@@ -0,0 +1,51 @@
1
+ import { type AdminExtension, type NavItem } from "@voyant-travel/admin";
2
+ /**
3
+ * Semantic destinations the quotes admin surfaces navigate to (packaged-admin
4
+ * RFC §4.7). The board opens a quote's detail; the detail page links back to
5
+ * the board — instead of importing a host route tree they resolve these keys
6
+ * through `useAdminNavigate` from `@voyant-travel/admin`. Both are route-backed
7
+ * (pure path interpolation), so the host's resolvers are generated.
8
+ */
9
+ declare module "@voyant-travel/admin" {
10
+ interface AdminDestinations {
11
+ /** The quotes board (landing) page. */
12
+ "quote.list": Record<string, never>;
13
+ /** A quote's detail page, where its versions live. */
14
+ "quote.detail": {
15
+ quoteId: string;
16
+ };
17
+ }
18
+ }
19
+ export { quotesQueryKeys } from "../query-keys.js";
20
+ export interface CreateQuotesAdminExtensionOptions {
21
+ /** Mount path of the quotes pages inside the admin workspace. Default `/quotes`. */
22
+ basePath?: string;
23
+ /** Localized nav/page labels. Defaults are the English operator nav labels. */
24
+ labels?: {
25
+ quotes?: string;
26
+ };
27
+ /** Nav icon — icon choice stays with the host (e.g. lucide `FileText`). */
28
+ icon?: NavItem["icon"];
29
+ }
30
+ /**
31
+ * The quotes admin contribution (packaged-admin RFC Phase 3,
32
+ * `@voyant-travel/<domain>-react/admin` convention).
33
+ *
34
+ * NAVIGATION: package-delivered. The Quotes item is NOT part of the BASE
35
+ * operator navigation (`createOperatorAdminNavigation` in
36
+ * `@voyant-travel/admin`), so the extension contributes it — spliced directly
37
+ * after Bookings via `insertAfter` because both belong to the
38
+ * quote → accept → book lifecycle. The icon stays a host choice.
39
+ *
40
+ * ROUTES: two contributions carry the FULL route implementation — the board
41
+ * (landing) at `basePath`, where operators manage pipelines/stages and create
42
+ * quotes, and a quote detail at `basePath/$id`, where that quote's VERSIONS
43
+ * live (listed inline, created in context). Versions are never a top-level
44
+ * surface: they are revisions of a quote. Both pages keep their filter/state
45
+ * local (no URL search contract); cross-route links resolve through the
46
+ * semantic destinations declared above.
47
+ *
48
+ * WIDGETS: none contributed and no slots exposed yet.
49
+ */
50
+ export declare function createQuotesAdminExtension(options?: CreateQuotesAdminExtensionOptions): AdminExtension;
51
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/admin/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,cAAc,EAKnB,KAAK,OAAO,EACb,MAAM,sBAAsB,CAAA;AAM7B;;;;;;GAMG;AACH,OAAO,QAAQ,sBAAsB,CAAC;IACpC,UAAU,iBAAiB;QACzB,uCAAuC;QACvC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;QACnC,sDAAsD;QACtD,cAAc,EAAE;YAAE,OAAO,EAAE,MAAM,CAAA;SAAE,CAAA;KACpC;CACF;AAOD,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAElD,MAAM,WAAW,iCAAiC;IAChD,oFAAoF;IACpF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,+EAA+E;IAC/E,MAAM,CAAC,EAAE;QACP,MAAM,CAAC,EAAE,MAAM,CAAA;KAChB,CAAA;IACD,2EAA2E;IAC3E,IAAI,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,GAAE,iCAAsC,GAC9C,cAAc,CAkDhB"}
@@ -0,0 +1,85 @@
1
+ import { adminRoutePageModule, defineAdminExtension, } from "@voyant-travel/admin";
2
+ // Lean static only: the shared fetcher fallback. The page-data helpers
3
+ // resolve via dynamic import inside the loaders so the REST query options stay
4
+ // out of the workspace-chrome chunk that evaluates this factory.
5
+ import { defaultFetcher } from "@voyant-travel/react";
6
+ // Endgame rule (packaged-admin RFC §4.8): this barrel re-exports NO page or
7
+ // host component values — it is evaluated with the workspace chrome, so a
8
+ // static host re-export would pin the Quotes page modules (board, dialogs,
9
+ // detail table) into the entry chunk. Hosts import from their specific
10
+ // modules; only the lightweight query keys re-export here.
11
+ export { quotesQueryKeys } from "../query-keys.js";
12
+ /**
13
+ * The quotes admin contribution (packaged-admin RFC Phase 3,
14
+ * `@voyant-travel/<domain>-react/admin` convention).
15
+ *
16
+ * NAVIGATION: package-delivered. The Quotes item is NOT part of the BASE
17
+ * operator navigation (`createOperatorAdminNavigation` in
18
+ * `@voyant-travel/admin`), so the extension contributes it — spliced directly
19
+ * after Bookings via `insertAfter` because both belong to the
20
+ * quote → accept → book lifecycle. The icon stays a host choice.
21
+ *
22
+ * ROUTES: two contributions carry the FULL route implementation — the board
23
+ * (landing) at `basePath`, where operators manage pipelines/stages and create
24
+ * quotes, and a quote detail at `basePath/$id`, where that quote's VERSIONS
25
+ * live (listed inline, created in context). Versions are never a top-level
26
+ * surface: they are revisions of a quote. Both pages keep their filter/state
27
+ * local (no URL search contract); cross-route links resolve through the
28
+ * semantic destinations declared above.
29
+ *
30
+ * WIDGETS: none contributed and no slots exposed yet.
31
+ */
32
+ export function createQuotesAdminExtension(options = {}) {
33
+ const { basePath = "/quotes", labels = {}, icon } = options;
34
+ const { quotes = "Quotes" } = labels;
35
+ return defineAdminExtension({
36
+ id: "quotes",
37
+ navigation: [
38
+ {
39
+ insertAfter: "bookings",
40
+ items: [{ id: "quotes", title: quotes, url: basePath, icon }],
41
+ },
42
+ ],
43
+ routes: [
44
+ {
45
+ id: "quotes-index",
46
+ path: basePath,
47
+ title: quotes,
48
+ destination: "quote.list",
49
+ ssr: "data-only",
50
+ page: () => import("./quotes-board-host.js").then((module) => adminRoutePageModule(module.QuotesBoardHost)),
51
+ // Dynamic import on purpose: the helper pulls the REST query options,
52
+ // and a static import here would pin them into the workspace-chrome
53
+ // chunk that evaluates this factory.
54
+ loader: async ({ queryClient, runtime }) => {
55
+ const { getPipelinesQueryOptions } = await import("../query-options.js");
56
+ return queryClient.ensureQueryData(getPipelinesQueryOptions(loaderClient(runtime), { entityType: "quote", limit: 50 }));
57
+ },
58
+ },
59
+ {
60
+ id: "quotes-detail",
61
+ path: `${basePath}/$id`,
62
+ title: quotes,
63
+ destination: "quote.detail",
64
+ destinationParams: { id: "quoteId" },
65
+ ssr: "data-only",
66
+ page: () => import("./pages/quote-detail-page.js"),
67
+ loader: async ({ queryClient, runtime, params }) => {
68
+ const id = params.id;
69
+ if (!id)
70
+ return;
71
+ const { getQuoteQueryOptions } = await import("../query-options.js");
72
+ return queryClient.ensureQueryData(getQuoteQueryOptions(loaderClient(runtime), id));
73
+ },
74
+ },
75
+ ],
76
+ });
77
+ }
78
+ /**
79
+ * Bridge the host-supplied {@link AdminRouteRuntime} (optional fetcher) to
80
+ * the required-fetcher client contract the query options take — SSR loaders
81
+ * run with the host runtime's cookie-forwarding fetcher.
82
+ */
83
+ function loaderClient(runtime) {
84
+ return { baseUrl: runtime.baseUrl, fetcher: runtime.fetcher ?? defaultFetcher };
85
+ }
@@ -0,0 +1,13 @@
1
+ import type { AdminRoutePageProps } from "@voyant-travel/admin";
2
+ /**
3
+ * Packaged admin page for a single Quote (packaged-admin RFC Phase 3). The
4
+ * detail is a STAGED editor: every card edits a local draft and nothing
5
+ * persists until "Save", which commits the quote fields, line-item and
6
+ * traveler diffs, then snapshots a new proposal version that supersedes the
7
+ * prior one. "Discard" reverts to the loaded state. The quote value is derived
8
+ * from the draft's line items. Client/owner data flows through
9
+ * relationships-react / auth-react; links resolve via the shared
10
+ * `person.detail` / `organization.detail` semantic destinations.
11
+ */
12
+ export default function QuoteDetailPage({ params }: AdminRoutePageProps): import("react/jsx-runtime").JSX.Element;
13
+ //# sourceMappingURL=quote-detail-page.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"quote-detail-page.d.ts","sourceRoot":"","sources":["../../../src/admin/pages/quote-detail-page.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAA;AAkK/D;;;;;;;;;GASG;AACH,MAAM,CAAC,OAAO,UAAU,eAAe,CAAC,EAAE,MAAM,EAAE,EAAE,mBAAmB,2CAmmBtE"}
@@ -0,0 +1,397 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useAdminNavigate } from "@voyant-travel/admin";
4
+ import { formatMessage } from "@voyant-travel/i18n";
5
+ import { useActivities, useOrganization, usePerson } from "@voyant-travel/relationships-react";
6
+ import { Button, Card, CardContent, CardHeader, CardTitle, ConfirmActionButton, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Textarea, toast, } from "@voyant-travel/ui/components";
7
+ import { DatePicker } from "@voyant-travel/ui/components/date-picker";
8
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@voyant-travel/ui/components/table";
9
+ import { ArrowLeft, Ban, CheckCircle2, Loader2, Save, Share2 } from "lucide-react";
10
+ import { useEffect, useMemo, useRef, useState } from "react";
11
+ import { formatCrmDate, formatCrmMoney, formatCrmRelative } from "../../components/crm-format.js";
12
+ import { useCrmUiI18nOrDefault } from "../../i18n/index.js";
13
+ import { useQuote, useQuoteMedia, useQuoteMediaMutation, useQuoteMutation, useQuoteParticipantMutation, useQuoteParticipants, useQuoteProductMutation, useQuoteProducts, useQuoteVersionMutation, useQuoteVersions, useStages, } from "../../index.js";
14
+ import { QuoteClientCard, QuoteLineItemsCard, QuoteMediaCard, QuoteOwnershipCard, QuoteTravelersCard, } from "../quote-content-sections.js";
15
+ import { QuoteActivitiesCard, QuoteDetailsCard, QuoteTagsCard } from "../quote-detail-sections.js";
16
+ function buildDraft(quote, products, travelers) {
17
+ return {
18
+ title: quote.title,
19
+ stageId: quote.stageId,
20
+ status: quote.status,
21
+ valueCurrency: quote.valueCurrency,
22
+ expectedCloseDate: quote.expectedCloseDate,
23
+ source: quote.source,
24
+ description: quote.description,
25
+ lostReason: quote.lostReason,
26
+ personId: quote.personId,
27
+ organizationId: quote.organizationId,
28
+ ownerId: quote.ownerId,
29
+ paxCount: quote.paxCount,
30
+ tags: quote.tags,
31
+ lineItems: products.map((product) => ({
32
+ id: product.id,
33
+ isNew: false,
34
+ nameSnapshot: product.nameSnapshot,
35
+ description: product.description,
36
+ quantity: product.quantity,
37
+ unitPriceAmountCents: product.unitPriceAmountCents,
38
+ currency: product.currency,
39
+ })),
40
+ travelers: travelers.map((traveler) => ({
41
+ id: traveler.id,
42
+ isNew: false,
43
+ personId: traveler.personId,
44
+ isPrimary: traveler.isPrimary,
45
+ })),
46
+ };
47
+ }
48
+ /** Order-stable signature for the dirty check (ignores temp ids of new rows). */
49
+ function serializeDraft(draft) {
50
+ return JSON.stringify({
51
+ title: draft.title,
52
+ stageId: draft.stageId,
53
+ status: draft.status,
54
+ valueCurrency: draft.valueCurrency,
55
+ expectedCloseDate: draft.expectedCloseDate,
56
+ source: draft.source,
57
+ description: draft.description,
58
+ lostReason: draft.lostReason,
59
+ personId: draft.personId,
60
+ organizationId: draft.organizationId,
61
+ ownerId: draft.ownerId,
62
+ paxCount: draft.paxCount,
63
+ tags: [...draft.tags].sort(),
64
+ lineItems: draft.lineItems.map((line) => ({
65
+ id: line.isNew ? null : line.id,
66
+ n: line.nameSnapshot,
67
+ d: line.description,
68
+ q: line.quantity,
69
+ u: line.unitPriceAmountCents,
70
+ c: line.currency,
71
+ })),
72
+ travelers: [...draft.travelers.map((traveler) => traveler.personId)].sort(),
73
+ });
74
+ }
75
+ /**
76
+ * Packaged admin page for a single Quote (packaged-admin RFC Phase 3). The
77
+ * detail is a STAGED editor: every card edits a local draft and nothing
78
+ * persists until "Save", which commits the quote fields, line-item and
79
+ * traveler diffs, then snapshots a new proposal version that supersedes the
80
+ * prior one. "Discard" reverts to the loaded state. The quote value is derived
81
+ * from the draft's line items. Client/owner data flows through
82
+ * relationships-react / auth-react; links resolve via the shared
83
+ * `person.detail` / `organization.detail` semantic destinations.
84
+ */
85
+ export default function QuoteDetailPage({ params }) {
86
+ const id = params.id ?? "";
87
+ const i18n = useCrmUiI18nOrDefault();
88
+ const { messages } = i18n;
89
+ const t = messages.quoteDetailPage;
90
+ const navigate = useAdminNavigate();
91
+ const [showLostDialog, setShowLostDialog] = useState(false);
92
+ const [lostReasonDraft, setLostReasonDraft] = useState("");
93
+ const [draft, setDraft] = useState(null);
94
+ const [isSaving, setIsSaving] = useState(false);
95
+ const quoteQuery = useQuote(id);
96
+ const quote = quoteQuery.data;
97
+ const { update, remove } = useQuoteMutation();
98
+ const versionMutation = useQuoteVersionMutation();
99
+ const productMutation = useQuoteProductMutation();
100
+ const participantMutation = useQuoteParticipantMutation();
101
+ const mediaMutation = useQuoteMediaMutation();
102
+ const stagesQuery = useStages({
103
+ pipelineId: quote?.pipelineId,
104
+ limit: 100,
105
+ enabled: Boolean(quote?.pipelineId),
106
+ });
107
+ const versionsQuery = useQuoteVersions({ quoteId: id, limit: 50, enabled: Boolean(quote) });
108
+ const activitiesQuery = useActivities({
109
+ entityType: "quote",
110
+ entityId: id,
111
+ limit: 50,
112
+ enabled: Boolean(quote),
113
+ });
114
+ const productsQuery = useQuoteProducts(id, { enabled: Boolean(quote) });
115
+ const participantsQuery = useQuoteParticipants(id, { enabled: Boolean(quote) });
116
+ const mediaQuery = useQuoteMedia(id, { enabled: Boolean(quote) });
117
+ const products = useMemo(() => productsQuery.data?.data ?? [], [productsQuery.data]);
118
+ const travelers = useMemo(() => participantsQuery.data?.data ?? [], [participantsQuery.data]);
119
+ // The draft built from the server's current state. Draft edits diverge from
120
+ // this until Save; the server signature resyncs the draft after a save lands.
121
+ const serverDraft = useMemo(() => (quote ? buildDraft(quote, products, travelers) : null), [quote, products, travelers]);
122
+ const serverSignature = serverDraft ? serializeDraft(serverDraft) : null;
123
+ const syncedRef = useRef(null);
124
+ // Resync the draft to the server whenever the server state changes (initial
125
+ // load, and after a save commits) — but never mid-save, which would clobber
126
+ // pending edits as each mutation invalidates queries.
127
+ useEffect(() => {
128
+ if (isSaving || !serverDraft || serverSignature === null)
129
+ return;
130
+ if (serverSignature !== syncedRef.current) {
131
+ syncedRef.current = serverSignature;
132
+ setDraft(serverDraft);
133
+ }
134
+ }, [isSaving, serverDraft, serverSignature]);
135
+ // Person/org records resolve from the DRAFT ids so the client card reflects
136
+ // a freshly-picked (unsaved) selection immediately.
137
+ const personQuery = usePerson(draft?.personId ?? undefined, {
138
+ enabled: Boolean(draft?.personId),
139
+ });
140
+ const organizationQuery = useOrganization(draft?.organizationId ?? undefined, {
141
+ enabled: Boolean(draft?.organizationId),
142
+ });
143
+ const versions = versionsQuery.data?.data ?? [];
144
+ const versionNumberById = new Map();
145
+ [...versions]
146
+ .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))
147
+ .forEach((version, index) => {
148
+ versionNumberById.set(version.id, index + 1);
149
+ });
150
+ const orderedVersions = [...versions].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
151
+ const currentVersion = orderedVersions.find((version) => version.status === "draft" || version.status === "sent") ??
152
+ null;
153
+ const currentVersionId = currentVersion?.id ?? null;
154
+ const stages = useMemo(() => [...(stagesQuery.data?.data ?? [])].sort((a, b) => a.sortOrder - b.sortOrder), [stagesQuery.data]);
155
+ const goToList = () => navigate("quote.list", {});
156
+ if (quoteQuery.isPending) {
157
+ return (_jsx("div", { className: "flex items-center justify-center py-12", children: _jsx(Loader2, { className: "h-6 w-6 animate-spin text-muted-foreground" }) }));
158
+ }
159
+ if (!quote) {
160
+ return (_jsxs("div", { className: "flex flex-col items-center justify-center gap-4 py-12", children: [_jsx("p", { className: "text-muted-foreground", children: t.notFound }), _jsx(Button, { variant: "outline", onClick: goToList, children: t.back })] }));
161
+ }
162
+ // Quote loaded but the draft is still being initialized from it (one tick).
163
+ if (!draft) {
164
+ return (_jsx("div", { className: "flex items-center justify-center py-12", children: _jsx(Loader2, { className: "h-6 w-6 animate-spin text-muted-foreground" }) }));
165
+ }
166
+ const currentDraft = draft;
167
+ const patchDraft = (patch) => setDraft((previous) => (previous ? { ...previous, ...patch } : previous));
168
+ const itemsTotalCents = currentDraft.lineItems.reduce((sum, line) => sum + line.quantity * (line.unitPriceAmountCents ?? 0), 0);
169
+ const isDirty = serverDraft ? serializeDraft(currentDraft) !== serializeDraft(serverDraft) : false;
170
+ // Synthetic records so the (presentational) cards can render the draft.
171
+ const draftLineItems = currentDraft.lineItems.map((line) => ({
172
+ id: line.id,
173
+ quoteId: id,
174
+ productId: null,
175
+ supplierServiceId: null,
176
+ nameSnapshot: line.nameSnapshot,
177
+ description: line.description,
178
+ quantity: line.quantity,
179
+ unitPriceAmountCents: line.unitPriceAmountCents,
180
+ costAmountCents: null,
181
+ currency: line.currency,
182
+ discountAmountCents: null,
183
+ createdAt: "",
184
+ updatedAt: "",
185
+ }));
186
+ const draftTravelers = currentDraft.travelers.map((traveler) => ({
187
+ id: traveler.id,
188
+ quoteId: id,
189
+ personId: traveler.personId,
190
+ role: "traveler",
191
+ isPrimary: traveler.isPrimary,
192
+ createdAt: "",
193
+ }));
194
+ function markWon() {
195
+ const wonStage = stages.find((stage) => stage.isWon);
196
+ patchDraft({ status: "won", ...(wonStage ? { stageId: wonStage.id } : {}) });
197
+ }
198
+ function submitLost() {
199
+ const lostStage = stages.find((stage) => stage.isLost);
200
+ patchDraft({
201
+ status: "lost",
202
+ lostReason: lostReasonDraft.trim() || null,
203
+ ...(lostStage ? { stageId: lostStage.id } : {}),
204
+ });
205
+ setShowLostDialog(false);
206
+ setLostReasonDraft("");
207
+ }
208
+ function reopen() {
209
+ patchDraft({ status: "open", lostReason: null });
210
+ }
211
+ function discard() {
212
+ if (serverDraft)
213
+ setDraft(serverDraft);
214
+ }
215
+ async function shareProposal() {
216
+ if (!currentVersion)
217
+ return;
218
+ try {
219
+ // Always copy the deployment-resolved public proposal URL (the public
220
+ // origin can differ from the admin origin). Draft → send returns it;
221
+ // an already-sent version resolves it via the side-effect-free link route.
222
+ const result = currentVersion.status === "draft"
223
+ ? await versionMutation.sendProposal.mutateAsync({ id: currentVersion.id })
224
+ : await versionMutation.fetchProposalLink.mutateAsync({ id: currentVersion.id });
225
+ const url = result.proposalUrl?.startsWith("http")
226
+ ? result.proposalUrl
227
+ : `${window.location.origin}/proposal/${currentVersion.id}`;
228
+ await navigator.clipboard?.writeText(url).catch(() => { });
229
+ toast.success(t.proposalLinkCopied);
230
+ }
231
+ catch {
232
+ toast.error(t.proposalSendFailed);
233
+ }
234
+ }
235
+ async function save() {
236
+ if (isSaving)
237
+ return;
238
+ setIsSaving(true);
239
+ try {
240
+ // 1. Quote fields — full payload so the partial-update schema doesn't
241
+ // inject insert defaults (status/tags) and clobber them.
242
+ await update.mutateAsync({
243
+ id,
244
+ input: {
245
+ title: currentDraft.title,
246
+ pipelineId: quote?.pipelineId,
247
+ stageId: currentDraft.stageId,
248
+ status: currentDraft.status,
249
+ personId: currentDraft.personId,
250
+ organizationId: currentDraft.organizationId,
251
+ ownerId: currentDraft.ownerId,
252
+ valueCurrency: currentDraft.valueCurrency,
253
+ expectedCloseDate: currentDraft.expectedCloseDate,
254
+ source: currentDraft.source,
255
+ description: currentDraft.description,
256
+ lostReason: currentDraft.lostReason,
257
+ tags: currentDraft.tags,
258
+ paxCount: currentDraft.paxCount,
259
+ },
260
+ });
261
+ // 2. Line-item diff
262
+ for (const serverItem of products) {
263
+ if (!currentDraft.lineItems.some((line) => !line.isNew && line.id === serverItem.id)) {
264
+ await productMutation.remove.mutateAsync({ id: serverItem.id, quoteId: id });
265
+ }
266
+ }
267
+ for (const line of currentDraft.lineItems) {
268
+ if (line.isNew) {
269
+ await productMutation.create.mutateAsync({
270
+ quoteId: id,
271
+ input: {
272
+ nameSnapshot: line.nameSnapshot,
273
+ description: line.description,
274
+ quantity: line.quantity,
275
+ unitPriceAmountCents: line.unitPriceAmountCents,
276
+ currency: line.currency,
277
+ },
278
+ });
279
+ continue;
280
+ }
281
+ const serverItem = products.find((product) => product.id === line.id);
282
+ const changed = serverItem &&
283
+ (serverItem.nameSnapshot !== line.nameSnapshot ||
284
+ (serverItem.description ?? null) !== (line.description ?? null) ||
285
+ serverItem.quantity !== line.quantity ||
286
+ (serverItem.unitPriceAmountCents ?? null) !== (line.unitPriceAmountCents ?? null) ||
287
+ (serverItem.currency ?? null) !== (line.currency ?? null));
288
+ if (changed) {
289
+ await productMutation.update.mutateAsync({
290
+ id: line.id,
291
+ quoteId: id,
292
+ input: {
293
+ nameSnapshot: line.nameSnapshot,
294
+ description: line.description,
295
+ quantity: line.quantity,
296
+ unitPriceAmountCents: line.unitPriceAmountCents,
297
+ currency: line.currency,
298
+ },
299
+ });
300
+ }
301
+ }
302
+ // 3. Traveler diff
303
+ for (const serverTraveler of travelers) {
304
+ if (!currentDraft.travelers.some((tv) => !tv.isNew && tv.id === serverTraveler.id)) {
305
+ await participantMutation.remove.mutateAsync({ id: serverTraveler.id, quoteId: id });
306
+ }
307
+ }
308
+ for (const traveler of currentDraft.travelers) {
309
+ if (traveler.isNew) {
310
+ await participantMutation.create.mutateAsync({
311
+ quoteId: id,
312
+ input: { personId: traveler.personId, role: "traveler" },
313
+ });
314
+ }
315
+ }
316
+ // 4. Snapshot the saved state into a new proposal version.
317
+ await versionMutation.snapshot.mutateAsync({ quoteId: id });
318
+ toast.success(t.saveSuccess);
319
+ }
320
+ catch {
321
+ toast.error(t.saveError);
322
+ }
323
+ finally {
324
+ setIsSaving(false);
325
+ }
326
+ }
327
+ return (_jsxs("div", { className: "flex min-h-screen flex-col", children: [_jsxs("div", { className: "sticky top-0 z-10 flex items-center gap-3 border-b bg-background px-6 py-3", children: [_jsx(Button, { variant: "ghost", size: "icon", onClick: goToList, className: "h-8 w-8", children: _jsx(ArrowLeft, { className: "h-4 w-4" }) }), _jsxs("div", { className: "flex items-center gap-2 text-muted-foreground text-sm", children: [_jsx("button", { type: "button", onClick: goToList, className: "hover:text-foreground", children: t.breadcrumbRoot }), _jsx("span", { children: "/" }), _jsx("span", { className: "truncate text-foreground", children: currentDraft.title })] }), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [currentDraft.status === "open" ? (_jsxs(_Fragment, { children: [_jsxs(Button, { size: "sm", variant: "outline", onClick: markWon, disabled: isSaving, children: [_jsx(CheckCircle2, { className: "mr-1.5 h-4 w-4" }), t.markWon] }), _jsxs(Button, { size: "sm", variant: "outline", onClick: () => setShowLostDialog(true), disabled: isSaving, children: [_jsx(Ban, { className: "mr-1.5 h-4 w-4" }), t.markLost] })] })) : (_jsx(Button, { size: "sm", variant: "outline", onClick: reopen, disabled: isSaving, children: t.reopen })), isDirty ? (_jsx(Button, { size: "sm", variant: "ghost", onClick: discard, disabled: isSaving, children: t.discard })) : null, _jsxs(Button, { size: "sm", onClick: () => void save(), disabled: !isDirty || isSaving, children: [isSaving ? (_jsx(Loader2, { className: "mr-1.5 h-4 w-4 animate-spin" })) : (_jsx(Save, { className: "mr-1.5 h-4 w-4" })), t.save] }), _jsx(ConfirmActionButton, { buttonLabel: t.delete, confirmLabel: t.deleteConfirm.confirm, cancelLabel: messages.common.cancel, title: t.deleteConfirm.title, description: t.deleteConfirm.description, variant: "destructive", confirmVariant: "destructive", disabled: remove.isPending || isSaving, onConfirm: async () => {
328
+ await remove.mutateAsync(id);
329
+ goToList();
330
+ } })] })] }), _jsxs("div", { className: "grid flex-1 grid-cols-12 gap-4 p-4 lg:p-6", children: [_jsxs("aside", { className: "col-span-12 flex flex-col gap-4 lg:col-span-4", children: [_jsx(QuoteDetailsCard, { quote: {
331
+ ...quote,
332
+ title: currentDraft.title,
333
+ stageId: currentDraft.stageId,
334
+ status: currentDraft.status,
335
+ valueAmountCents: itemsTotalCents,
336
+ valueCurrency: currentDraft.valueCurrency,
337
+ expectedCloseDate: currentDraft.expectedCloseDate,
338
+ source: currentDraft.source,
339
+ lostReason: currentDraft.lostReason,
340
+ }, stages: stages, onUpdateField: async (patch) => patchDraft(patch) }), _jsx(QuoteClientCard, { person: personQuery.data, organization: organizationQuery.data, busy: isSaving, onSetPerson: async (personId) => patchDraft({ personId }), onSetOrganization: async (organizationId) => patchDraft({ organizationId }), onOpenPerson: () => {
341
+ if (currentDraft.personId)
342
+ navigate("person.detail", { personId: currentDraft.personId });
343
+ }, onOpenOrganization: () => {
344
+ if (currentDraft.organizationId) {
345
+ navigate("organization.detail", { organizationId: currentDraft.organizationId });
346
+ }
347
+ } }), _jsx(QuoteTravelersCard, { travelers: draftTravelers, paxCount: currentDraft.paxCount, isPending: participantsQuery.isPending, busy: isSaving, onPaxCountChange: async (paxCount) => patchDraft({ paxCount }), onAdd: async (personId) => patchDraft({
348
+ travelers: [
349
+ ...currentDraft.travelers,
350
+ { id: `tmp_${crypto.randomUUID()}`, isNew: true, personId, isPrimary: false },
351
+ ],
352
+ }), onRemove: async (travelerId) => patchDraft({
353
+ travelers: currentDraft.travelers.filter((traveler) => traveler.id !== travelerId),
354
+ }) }), _jsx(QuoteOwnershipCard, { ownerId: currentDraft.ownerId, createdBy: quote.createdBy, updatedBy: quote.updatedBy, createdAt: quote.createdAt, updatedAt: quote.updatedAt, busy: isSaving, onSetOwner: async (ownerId) => patchDraft({ ownerId }) }), _jsx(QuoteTagsCard, { tags: currentDraft.tags, onChange: async (tags) => patchDraft({ tags }) })] }), _jsxs("main", { className: "col-span-12 flex flex-col gap-4 lg:col-span-8", children: [_jsxs(Card, { children: [_jsx(CardHeader, { className: "pb-3", children: _jsx(CardTitle, { children: t.descriptionTitle }) }), _jsx(CardContent, { children: _jsx(Textarea, { value: currentDraft.description ?? "", onChange: (event) => patchDraft({ description: event.target.value === "" ? null : event.target.value }), placeholder: t.descriptionPlaceholder, rows: 4, disabled: isSaving }) })] }), _jsx(QuoteMediaCard, { media: mediaQuery.data?.data ?? [], isPending: mediaQuery.isPending, busy: mediaMutation.upload.isPending || mediaMutation.remove.isPending, onUploadFiles: async (files) => {
355
+ for (const file of Array.from(files)) {
356
+ await mediaMutation.upload.mutateAsync({ quoteId: id, file });
357
+ }
358
+ }, onRemove: async (mediaId) => {
359
+ await mediaMutation.remove.mutateAsync({ id: mediaId, quoteId: id });
360
+ } }), _jsx(QuoteLineItemsCard, { products: draftLineItems, isPending: productsQuery.isPending, currency: currentDraft.valueCurrency ?? "USD", busy: isSaving, onAdd: async (input) => patchDraft({
361
+ lineItems: [
362
+ ...currentDraft.lineItems,
363
+ {
364
+ id: `tmp_${crypto.randomUUID()}`,
365
+ isNew: true,
366
+ nameSnapshot: input.nameSnapshot,
367
+ description: input.description ?? null,
368
+ quantity: input.quantity ?? 1,
369
+ unitPriceAmountCents: input.unitPriceAmountCents ?? null,
370
+ currency: input.currency ?? null,
371
+ },
372
+ ],
373
+ }), onUpdate: async (lineId, input) => patchDraft({
374
+ lineItems: currentDraft.lineItems.map((line) => line.id === lineId
375
+ ? {
376
+ ...line,
377
+ ...(input.nameSnapshot !== undefined
378
+ ? { nameSnapshot: input.nameSnapshot }
379
+ : {}),
380
+ ...(input.quantity !== undefined ? { quantity: input.quantity } : {}),
381
+ ...(input.unitPriceAmountCents !== undefined
382
+ ? { unitPriceAmountCents: input.unitPriceAmountCents }
383
+ : {}),
384
+ }
385
+ : line),
386
+ }), onRemove: async (lineId) => patchDraft({
387
+ lineItems: currentDraft.lineItems.filter((line) => line.id !== lineId),
388
+ }) }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between gap-2", children: [_jsx(CardTitle, { children: t.versionsTitle }), currentVersion ? (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => void shareProposal(), disabled: versionMutation.sendProposal.isPending, children: [_jsx(Share2, { className: "mr-1.5 h-4 w-4" }), currentVersion.status === "draft" ? t.sendToClient : t.copyReviewLink] })) : null] }), _jsx(CardContent, { children: versionsQuery.isError ? (_jsx("p", { className: "py-6 text-center text-destructive text-sm", children: t.versionsLoadFailed })) : versionsQuery.isPending ? (_jsx("div", { className: "flex justify-center py-6", children: _jsx(Loader2, { className: "h-5 w-5 animate-spin text-muted-foreground" }) })) : versions.length === 0 ? (_jsx("p", { className: "py-6 text-center text-muted-foreground text-sm", children: t.versionsEmpty })) : (_jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: messages.quoteVersionsPage.columns.quoteVersion }), _jsx(TableHead, { children: messages.quoteVersionsPage.columns.status }), _jsx(TableHead, { children: messages.quoteVersionsPage.columns.total }), _jsx(TableHead, { children: messages.quoteVersionsPage.columns.validUntil }), _jsx(TableHead, { children: messages.quoteVersionsPage.columns.updated })] }) }), _jsx(TableBody, { children: orderedVersions.map((version) => (_jsxs(TableRow, { children: [_jsx(TableCell, { className: "font-medium", children: version.label ??
389
+ formatMessage(t.versionLabel, {
390
+ number: versionNumberById.get(version.id) ?? 0,
391
+ }) }), _jsx(TableCell, { children: version.id === currentVersionId
392
+ ? t.versionActive
393
+ : (messages.common.quoteVersionStatusLabels[version.status] ?? version.status) }), _jsx(TableCell, { children: formatCrmMoney(i18n, version.totalAmountCents, version.currency) }), _jsx(TableCell, { className: "text-muted-foreground", children: version.id === currentVersionId ? (_jsx(DatePicker, { value: version.validUntil, onChange: (next) => void versionMutation.setValidUntil.mutateAsync({
394
+ id: version.id,
395
+ validUntil: next,
396
+ }), placeholder: messages.createQuoteVersionDialog.placeholders.pickDate, clearable: true })) : version.validUntil ? (formatCrmDate(i18n, version.validUntil)) : (messages.common.none) }), _jsx(TableCell, { className: "text-muted-foreground", children: formatCrmRelative(i18n, version.updatedAt) })] }, version.id))) })] })) })] }), _jsx(QuoteActivitiesCard, { isPending: activitiesQuery.isPending, activities: activitiesQuery.data?.data ?? [] })] })] }), _jsx(Dialog, { open: showLostDialog, onOpenChange: setShowLostDialog, children: _jsxs(DialogContent, { children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: t.lostDialog.title }) }), _jsx("p", { className: "text-muted-foreground text-sm", children: t.lostDialog.description }), _jsx(Textarea, { value: lostReasonDraft, onChange: (event) => setLostReasonDraft(event.target.value), placeholder: t.lostDialog.placeholder, rows: 3 }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", size: "sm", onClick: () => setShowLostDialog(false), children: messages.common.cancel }), _jsx(Button, { size: "sm", onClick: submitLost, children: t.lostDialog.confirm })] })] }) })] }));
397
+ }
@@ -0,0 +1,16 @@
1
+ import { type StageRecord } from "../index.js";
2
+ export interface CreatePipelineDialogProps {
3
+ open: boolean;
4
+ onOpenChange: (open: boolean) => void;
5
+ existingCount: number;
6
+ onCreated: (pipelineId: string) => void;
7
+ }
8
+ export declare function CreatePipelineDialog({ open, onOpenChange, existingCount, onCreated, }: CreatePipelineDialogProps): import("react/jsx-runtime").JSX.Element;
9
+ export interface ManageStagesDialogProps {
10
+ open: boolean;
11
+ onOpenChange: (open: boolean) => void;
12
+ pipelineId: string;
13
+ stages: StageRecord[];
14
+ }
15
+ export declare function ManageStagesDialog({ open, onOpenChange, pipelineId, stages, }: ManageStagesDialogProps): import("react/jsx-runtime").JSX.Element;
16
+ //# sourceMappingURL=pipeline-dialogs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pipeline-dialogs.d.ts","sourceRoot":"","sources":["../../src/admin/pipeline-dialogs.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAAE,KAAK,WAAW,EAAuB,MAAM,aAAa,CAAA;AAEnE,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,aAAa,EAAE,MAAM,CAAA;IACrB,SAAS,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAA;CACxC;AAED,wBAAgB,oBAAoB,CAAC,EACnC,IAAI,EACJ,YAAY,EACZ,aAAa,EACb,SAAS,GACV,EAAE,yBAAyB,2CA4E3B;AAED,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,WAAW,EAAE,CAAA;CACtB;AAED,wBAAgB,kBAAkB,CAAC,EACjC,IAAI,EACJ,YAAY,EACZ,UAAU,EACV,MAAM,GACP,EAAE,uBAAuB,2CAsIzB"}