@aglyn/plugins-commerce 1.0.0-beta.288 → 1.0.0-beta.290

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 (129) hide show
  1. package/README.md +2 -2
  2. package/package.json +13 -13
  3. package/src/lib/components/commerce-console-page.js +29 -2
  4. package/src/lib/components/commerce-console-page.js.map +1 -1
  5. package/src/lib/components/console/commerce-analytics-card.component.js +4 -2
  6. package/src/lib/components/console/commerce-analytics-card.component.js.map +1 -1
  7. package/src/lib/components/console/commerce-pdf-download.d.ts +30 -0
  8. package/src/lib/components/console/commerce-pdf-download.js +53 -0
  9. package/src/lib/components/console/commerce-pdf-download.js.map +1 -0
  10. package/src/lib/components/console/host-orders-card.component.js +5 -1
  11. package/src/lib/components/console/host-orders-card.component.js.map +1 -1
  12. package/src/lib/components/console/host-quotes-card.component.d.ts +34 -0
  13. package/src/lib/components/console/host-quotes-card.component.js +263 -0
  14. package/src/lib/components/console/host-quotes-card.component.js.map +1 -0
  15. package/src/lib/components/console/order-invoice.component.d.ts +6 -4
  16. package/src/lib/components/console/order-invoice.component.js +67 -11
  17. package/src/lib/components/console/order-invoice.component.js.map +1 -1
  18. package/src/lib/components/console/pos-ops/pos-check-ins-card.component.js +33 -28
  19. package/src/lib/components/console/pos-ops/pos-check-ins-card.component.js.map +1 -1
  20. package/src/lib/components/console/quote-editor-dialog.component.d.ts +45 -0
  21. package/src/lib/components/console/quote-editor-dialog.component.js +1317 -0
  22. package/src/lib/components/console/quote-editor-dialog.component.js.map +1 -0
  23. package/src/lib/components/console/quote-pay-link.component.d.ts +38 -0
  24. package/src/lib/components/console/quote-pay-link.component.js +213 -0
  25. package/src/lib/components/console/quote-pay-link.component.js.map +1 -0
  26. package/src/lib/components/console/quote-proofs.component.d.ts +37 -0
  27. package/src/lib/components/console/quote-proofs.component.js +626 -0
  28. package/src/lib/components/console/quote-proofs.component.js.map +1 -0
  29. package/src/lib/components/console/quote-settings-card.component.d.ts +29 -0
  30. package/src/lib/components/console/quote-settings-card.component.js +121 -0
  31. package/src/lib/components/console/quote-settings-card.component.js.map +1 -0
  32. package/src/lib/components/console/tax-settings-card.component.js +4 -1
  33. package/src/lib/components/console/tax-settings-card.component.js.map +1 -1
  34. package/src/lib/components/quote-page-body.d.ts +33 -0
  35. package/src/lib/components/quote-page-body.js +724 -0
  36. package/src/lib/components/quote-page-body.js.map +1 -0
  37. package/src/lib/components/quote-page.d.ts +29 -0
  38. package/src/lib/components/quote-page.js +96 -0
  39. package/src/lib/components/quote-page.js.map +1 -0
  40. package/src/lib/components/quote-proof-panel.d.ts +42 -0
  41. package/src/lib/components/quote-proof-panel.js +436 -0
  42. package/src/lib/components/quote-proof-panel.js.map +1 -0
  43. package/src/lib/components/storefront-payment-element.d.ts +7 -1
  44. package/src/lib/components/storefront-payment-element.js +8 -3
  45. package/src/lib/components/storefront-payment-element.js.map +1 -1
  46. package/src/lib/constants/orders-list-query.js +6 -1
  47. package/src/lib/constants/orders-list-query.js.map +1 -1
  48. package/src/lib/constants/quote-page.d.ts +30 -0
  49. package/src/lib/constants/quote-page.js +35 -0
  50. package/src/lib/constants/quote-page.js.map +1 -0
  51. package/src/lib/model/commerce-orders.d.ts +25 -2
  52. package/src/lib/model/commerce-orders.js +10 -1
  53. package/src/lib/model/commerce-orders.js.map +1 -1
  54. package/src/lib/model/commerce-quote-pay.d.ts +256 -0
  55. package/src/lib/model/commerce-quote-pay.js +426 -0
  56. package/src/lib/model/commerce-quote-pay.js.map +1 -0
  57. package/src/lib/model/commerce-quote-proofs.d.ts +159 -0
  58. package/src/lib/model/commerce-quote-proofs.js +348 -0
  59. package/src/lib/model/commerce-quote-proofs.js.map +1 -0
  60. package/src/lib/model/commerce-quotes.d.ts +335 -0
  61. package/src/lib/model/commerce-quotes.js +522 -0
  62. package/src/lib/model/commerce-quotes.js.map +1 -0
  63. package/src/lib/model/index.d.ts +3 -0
  64. package/src/lib/model/index.js +3 -0
  65. package/src/lib/model/index.js.map +1 -1
  66. package/src/lib/model/plugin-permissions.d.ts +1 -1
  67. package/src/lib/model/plugin-permissions.js +20 -0
  68. package/src/lib/model/plugin-permissions.js.map +1 -1
  69. package/src/lib/server/api-v1/orders-and-products.js +6 -2
  70. package/src/lib/server/api-v1/orders-and-products.js.map +1 -1
  71. package/src/lib/server/billing-webhook.js +127 -52
  72. package/src/lib/server/billing-webhook.js.map +1 -1
  73. package/src/lib/server/checkout-session-expiry.d.ts +36 -0
  74. package/src/lib/server/checkout-session-expiry.js +63 -0
  75. package/src/lib/server/checkout-session-expiry.js.map +1 -0
  76. package/src/lib/server/commerce-pdf.d.ts +100 -0
  77. package/src/lib/server/commerce-pdf.js +283 -0
  78. package/src/lib/server/commerce-pdf.js.map +1 -0
  79. package/src/lib/server/draft-order.js +16 -8
  80. package/src/lib/server/draft-order.js.map +1 -1
  81. package/src/lib/server/order-print.d.ts +6 -0
  82. package/src/lib/server/order-print.js +22 -5
  83. package/src/lib/server/order-print.js.map +1 -1
  84. package/src/lib/server/pdf-response.d.ts +31 -0
  85. package/src/lib/server/pdf-response.js +30 -0
  86. package/src/lib/server/pdf-response.js.map +1 -0
  87. package/src/lib/server/person-eraser.d.ts +7 -0
  88. package/src/lib/server/person-eraser.js +24 -2
  89. package/src/lib/server/person-eraser.js.map +1 -1
  90. package/src/lib/server/pos-reader-splash.d.ts +2 -18
  91. package/src/lib/server/pos-reader-splash.js +8 -93
  92. package/src/lib/server/pos-reader-splash.js.map +1 -1
  93. package/src/lib/server/pos-return.js +3 -1
  94. package/src/lib/server/pos-return.js.map +1 -1
  95. package/src/lib/server/quote-link-token.d.ts +39 -0
  96. package/src/lib/server/quote-link-token.js +97 -0
  97. package/src/lib/server/quote-link-token.js.map +1 -0
  98. package/src/lib/server/quote-pay.d.ts +110 -0
  99. package/src/lib/server/quote-pay.js +606 -0
  100. package/src/lib/server/quote-pay.js.map +1 -0
  101. package/src/lib/server/quote-proofs.d.ts +51 -0
  102. package/src/lib/server/quote-proofs.js +500 -0
  103. package/src/lib/server/quote-proofs.js.map +1 -0
  104. package/src/lib/server/quote-uploads.d.ts +33 -0
  105. package/src/lib/server/quote-uploads.js +29 -0
  106. package/src/lib/server/quote-uploads.js.map +1 -0
  107. package/src/lib/server/quote.d.ts +63 -0
  108. package/src/lib/server/quote.js +846 -0
  109. package/src/lib/server/quote.js.map +1 -0
  110. package/src/lib/server/related.js +2 -0
  111. package/src/lib/server/related.js.map +1 -1
  112. package/src/lib/server/site-image-source.d.ts +38 -0
  113. package/src/lib/server/site-image-source.js +130 -0
  114. package/src/lib/server/site-image-source.js.map +1 -0
  115. package/src/lib/server/site-page-resolver.d.ts +2 -0
  116. package/src/lib/server/site-page-resolver.js +75 -0
  117. package/src/lib/server/site-page-resolver.js.map +1 -1
  118. package/src/lib/server.js +33 -1
  119. package/src/lib/server.js.map +1 -1
  120. package/src/lib/site.js +6 -0
  121. package/src/lib/site.js.map +1 -1
  122. package/src/lib/transfer/tracking.server.js +6 -0
  123. package/src/lib/transfer/tracking.server.js.map +1 -1
  124. package/src/lib/utils/commerce-document-pdf.d.ts +77 -0
  125. package/src/lib/utils/commerce-document-pdf.js +608 -0
  126. package/src/lib/utils/commerce-document-pdf.js.map +1 -0
  127. package/src/lib/utils/order-invoice.d.ts +52 -3
  128. package/src/lib/utils/order-invoice.js +116 -64
  129. package/src/lib/utils/order-invoice.js.map +1 -1
@@ -0,0 +1,263 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
19
+ import * as CommerceModel from "../../model/index.js";
20
+ import { posMoney } from "../../model/commerce-pos-ops.js";
21
+ import { commerceSitePermissionRefusal } from "../../model/plugin-permissions.js";
22
+ import { pluginDocsHelp } from "@aglyn/aglyn";
23
+ import { CardDisplay } from "@aglyn/shared-ui-jsx";
24
+ import { BlockedControl } from "@aglyn/shared-ui-jsx/components/blocked-control.component";
25
+ import { ListTable } from "@aglyn/shared-ui-jsx/components/list-table.component";
26
+ import { ceilingedWindow, collectionCeiling, useFirestore, useFirestoreCollection } from "@aglyn/tenant-feature-instance";
27
+ import { Button, Chip, Stack, Typography } from "@mui/material";
28
+ import { collection, limit, query, where } from "firebase/firestore";
29
+ import { useEffect, useMemo, useState } from "react";
30
+ import QuoteEditorDialog from "./quote-editor-dialog.component.js";
31
+ /**
32
+ * How many quotes the list reads. The whole set is read and the grid sorts
33
+ * it by any column; a store past this many is told the list stops short.
34
+ */ const QUOTE_CEILING = 500;
35
+ /** The products the editor's picker offers. */ const PRODUCT_WINDOW = 200;
36
+ const REFUSAL = commerceSitePermissionRefusal('commerce.quotes');
37
+ /** One quote as its row draws and sorts it. */ function quoteListRow(quote) {
38
+ var _quote_lineItems, _ref, _ref1, _ref2, _ref3;
39
+ var _quote_quote, _items_, _quote_totals, _quote_quote1, _CommerceModel_latestQuoteResponse;
40
+ const customer = (_quote_quote = quote.quote) == null ? void 0 : _quote_quote.customer;
41
+ const status = CommerceModel.quoteDisplayStatus(quote);
42
+ const items = (_quote_lineItems = quote.lineItems) != null ? _quote_lineItems : [];
43
+ return _extends({}, quote, {
44
+ customerLabel: (customer == null ? void 0 : customer.name) || (customer == null ? void 0 : customer.company) || (customer == null ? void 0 : customer.email) || '',
45
+ firstItem: (_ref = (_items_ = items[0]) == null ? void 0 : _items_.name) != null ? _ref : '',
46
+ moreItems: Math.max(0, items.length - 1),
47
+ statusKey: status.key,
48
+ statusLabel: status.label,
49
+ totalCents: Number((_ref1 = (_quote_totals = quote.totals) == null ? void 0 : _quote_totals.totalCents) != null ? _ref1 : 0),
50
+ // The customer's latest answer counts as an update (AGL-4096), so a quote
51
+ // they just approved or asked changes to sorts to the top.
52
+ updatedAtMs: Math.max(Number((_ref2 = (_quote_quote1 = quote.quote) == null ? void 0 : _quote_quote1.updatedAtMs) != null ? _ref2 : 0), Number((_ref3 = (_CommerceModel_latestQuoteResponse = CommerceModel.latestQuoteResponse(quote.quote)) == null ? void 0 : _CommerceModel_latestQuoteResponse.atMs) != null ? _ref3 : 0))
53
+ });
54
+ }
55
+ /**
56
+ * Quotes (AGL-4093): prices put to a customer before they buy, beside the
57
+ * orders they become. A quote is an unpaid order on the `quote` channel, so
58
+ * this reads `hosts/{hostId}/orders` for that channel alone; the Orders list
59
+ * above never shows one until it is paid.
60
+ */ export function HostQuotesCard(props) {
61
+ var _quotes_rows_find;
62
+ const { hostId, canEdit, canSend } = props;
63
+ const firestore = useFirestore();
64
+ const { data: quoteDocs, status } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'orders'), where('channel', '==', 'quote'), limit(QUOTE_CEILING + 1)), [
65
+ firestore,
66
+ hostId
67
+ ], {
68
+ idField: '$id'
69
+ });
70
+ const quotes = useMemo(()=>ceilingedWindow(quoteDocs != null ? quoteDocs : undefined, QUOTE_CEILING), [
71
+ quoteDocs
72
+ ]);
73
+ const rows = useMemo(()=>Array.from(quotes.rows, quoteListRow), [
74
+ quotes
75
+ ]);
76
+ const { data: productDocs } = useFirestoreCollection(()=>collectionCeiling(collection(firestore, 'hosts', hostId, 'products'), PRODUCT_WINDOW), [
77
+ firestore,
78
+ hostId
79
+ ], {
80
+ idField: '$id'
81
+ });
82
+ const products = useMemo(()=>ceilingedWindow(productDocs != null ? productDocs : undefined, PRODUCT_WINDOW).rows.filter((product)=>!product['deletedAt']), [
83
+ productDocs
84
+ ]);
85
+ /** The quote open in the editor: its id, or `new`. */ const [openId, setOpenId] = useState(null);
86
+ const openQuote = openId && openId !== 'new' ? (_quotes_rows_find = quotes.rows.find((row)=>row.$id === openId)) != null ? _quotes_rows_find : null : null;
87
+ // A manager notice opens its quote (AGL-4096): `?quote=<id>` on the Orders
88
+ // page, once that quote has loaded.
89
+ const [linkedId, setLinkedId] = useState(()=>{
90
+ var _get;
91
+ return typeof window === 'undefined' ? '' : (_get = new URLSearchParams(window.location.search).get('quote')) != null ? _get : '';
92
+ });
93
+ useEffect(()=>{
94
+ if (!linkedId || !quotes.rows.some((row)=>row.$id === linkedId)) return;
95
+ setOpenId(linkedId);
96
+ setLinkedId('');
97
+ }, [
98
+ linkedId,
99
+ quotes.rows
100
+ ]);
101
+ const columns = useMemo(()=>[
102
+ {
103
+ field: 'number',
104
+ headerName: 'Quote',
105
+ flex: 1,
106
+ minWidth: 160,
107
+ renderCell: ({ row })=>/*#__PURE__*/ _jsxs(Stack, {
108
+ sx: {
109
+ minWidth: 0
110
+ },
111
+ children: [
112
+ /*#__PURE__*/ _jsx(Typography, {
113
+ variant: "body2",
114
+ noWrap: true,
115
+ children: CommerceModel.formatQuoteNumber(row, row.$id)
116
+ }),
117
+ /*#__PURE__*/ _jsx(Typography, {
118
+ variant: "caption",
119
+ color: "text.secondary",
120
+ noWrap: true,
121
+ children: row.moreItems ? `${row.firstItem} and more` : row.firstItem
122
+ })
123
+ ]
124
+ })
125
+ },
126
+ {
127
+ field: 'customerLabel',
128
+ headerName: 'Customer',
129
+ flex: 1,
130
+ minWidth: 160,
131
+ renderCell: ({ row })=>/*#__PURE__*/ _jsx(Typography, {
132
+ variant: "body2",
133
+ noWrap: true,
134
+ children: row.customerLabel || '—'
135
+ })
136
+ },
137
+ {
138
+ field: 'totalCents',
139
+ headerName: 'Total',
140
+ type: 'number',
141
+ width: 140,
142
+ align: 'right',
143
+ headerAlign: 'right',
144
+ renderCell: ({ row })=>{
145
+ var _row_quote_tax, _row_quote;
146
+ return /*#__PURE__*/ _jsxs(Stack, {
147
+ sx: {
148
+ alignItems: 'flex-end',
149
+ minWidth: 0,
150
+ width: 1
151
+ },
152
+ children: [
153
+ /*#__PURE__*/ _jsx(Typography, {
154
+ variant: "body2",
155
+ children: posMoney(row.totalCents)
156
+ }),
157
+ ((_row_quote = row.quote) == null ? void 0 : (_row_quote_tax = _row_quote.tax) == null ? void 0 : _row_quote_tax.kind) === 'at-payment' && CommerceModel.isUnpaidQuote(row) ? /*#__PURE__*/ _jsx(Typography, {
158
+ variant: "caption",
159
+ color: "text.secondary",
160
+ noWrap: true,
161
+ children: 'plus tax at payment'
162
+ }) : null
163
+ ]
164
+ });
165
+ }
166
+ },
167
+ {
168
+ field: 'statusLabel',
169
+ headerName: 'Status',
170
+ width: 180,
171
+ renderCell: ({ row })=>/*#__PURE__*/ _jsx(Chip, {
172
+ size: "small",
173
+ variant: "outlined",
174
+ label: row.statusLabel,
175
+ color: CommerceModel.QUOTE_STATUS_COLOR[row.statusKey]
176
+ })
177
+ },
178
+ {
179
+ field: 'updatedAtMs',
180
+ headerName: 'Updated',
181
+ type: 'number',
182
+ width: 140,
183
+ align: 'left',
184
+ headerAlign: 'left',
185
+ renderCell: ({ row })=>/*#__PURE__*/ _jsx(Typography, {
186
+ variant: "body2",
187
+ noWrap: true,
188
+ children: row.updatedAtMs ? new Date(row.updatedAtMs).toLocaleDateString() : '—'
189
+ })
190
+ }
191
+ ], []);
192
+ const empty = status === 'success' && rows.length === 0;
193
+ return /*#__PURE__*/ _jsxs(CardDisplay, {
194
+ header: 'Quotes',
195
+ subheader: 'Prices for a customer’s order before they buy',
196
+ help: pluginDocsHelp('quotes', {
197
+ anchor: '#make-a-quote',
198
+ title: 'Quotes',
199
+ excerpt: 'Put a price to a customer with your products, items typed for the job and anything ' + 'included. A quote becomes an order when it is paid.'
200
+ }),
201
+ HeaderProps: {
202
+ action: /*#__PURE__*/ _jsx(BlockedControl, {
203
+ blocked: !canEdit,
204
+ reason: REFUSAL,
205
+ children: /*#__PURE__*/ _jsx(Button, {
206
+ size: "small",
207
+ variant: "contained",
208
+ onClick: ()=>setOpenId('new'),
209
+ children: 'New quote'
210
+ })
211
+ })
212
+ },
213
+ contentGutterX: true,
214
+ contentGutterY: true,
215
+ children: [
216
+ empty ? /*#__PURE__*/ _jsx(Typography, {
217
+ variant: "body2",
218
+ color: "text.secondary",
219
+ children: 'No quotes yet. A quote prices a customer’s order before they buy: your products, ' + 'items typed for the job, and anything you include at no charge.'
220
+ }) : /*#__PURE__*/ _jsxs(Stack, {
221
+ spacing: 1,
222
+ children: [
223
+ quotes.truncated ? /*#__PURE__*/ _jsx(Typography, {
224
+ variant: "caption",
225
+ color: "text.secondary",
226
+ children: 'This store has more quotes than the list shows at once.'
227
+ }) : null,
228
+ /*#__PURE__*/ _jsx(ListTable, {
229
+ "aria-label": "Quotes",
230
+ rows: rows,
231
+ columns: columns,
232
+ loading: status === 'loading',
233
+ onOpen: (id)=>setOpenId(id),
234
+ quickFilter: true,
235
+ initialState: {
236
+ sorting: {
237
+ sortModel: [
238
+ {
239
+ field: 'updatedAtMs',
240
+ sort: 'desc'
241
+ }
242
+ ]
243
+ }
244
+ },
245
+ noRowsLabel: "No quotes match"
246
+ })
247
+ ]
248
+ }),
249
+ openId === 'new' || openQuote ? /*#__PURE__*/ _jsx(QuoteEditorDialog, {
250
+ hostId: hostId,
251
+ quote: openQuote,
252
+ products: products,
253
+ canEdit: canEdit,
254
+ canSend: canSend,
255
+ onClose: ()=>setOpenId(null)
256
+ }, openId) : null
257
+ ]
258
+ });
259
+ }
260
+ HostQuotesCard.displayName = 'HostQuotesCard';
261
+ export default HostQuotesCard;
262
+
263
+ //# sourceMappingURL=host-quotes-card.component.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/host-quotes-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n'use client'\n\nimport * as CommerceModel from '../../model'\nimport { posMoney } from '../../model/commerce-pos-ops'\nimport { commerceSitePermissionRefusal } from '../../model/plugin-permissions'\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { BlockedControl } from '@aglyn/shared-ui-jsx/components/blocked-control.component'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport {\n ceilingedWindow,\n collectionCeiling,\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport { Button, Chip, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection, limit, query, where } from 'firebase/firestore'\nimport { useEffect, useMemo, useState } from 'react'\nimport QuoteEditorDialog, { type QuoteRow } from './quote-editor-dialog.component'\n\nexport interface HostQuotesCardProps {\n hostId: string\n /** Holds \"Create and edit quotes\" (`commerce.quotes`). */\n canEdit: boolean\n /** Holds \"Send quotes and invoices to customers\" (`commerce.quotes.send`). */\n canSend: boolean\n}\n\n/**\n * How many quotes the list reads. The whole set is read and the grid sorts\n * it by any column; a store past this many is told the list stops short.\n */\nconst QUOTE_CEILING = 500\n\n/** The products the editor's picker offers. */\nconst PRODUCT_WINDOW = 200\n\nconst REFUSAL = commerceSitePermissionRefusal('commerce.quotes')\n\ninterface QuoteListRow extends QuoteRow {\n customerLabel: string\n firstItem: string\n moreItems: number\n statusKey: ReturnType<typeof CommerceModel.quoteDisplayStatus>['key']\n statusLabel: string\n totalCents: number\n updatedAtMs: number\n}\n\n/** One quote as its row draws and sorts it. */\nfunction quoteListRow(quote: QuoteRow): QuoteListRow {\n const customer = quote.quote?.customer\n const status = CommerceModel.quoteDisplayStatus(quote)\n const items = quote.lineItems ?? []\n return {\n ...quote,\n customerLabel: customer?.name || customer?.company || customer?.email || '',\n firstItem: items[0]?.name ?? '',\n moreItems: Math.max(0, items.length - 1),\n statusKey: status.key,\n statusLabel: status.label,\n totalCents: Number(quote.totals?.totalCents ?? 0),\n // The customer's latest answer counts as an update (AGL-4096), so a quote\n // they just approved or asked changes to sorts to the top.\n updatedAtMs: Math.max(\n Number(quote.quote?.updatedAtMs ?? 0),\n Number(CommerceModel.latestQuoteResponse(quote.quote)?.atMs ?? 0),\n ),\n }\n}\n\n/**\n * Quotes (AGL-4093): prices put to a customer before they buy, beside the\n * orders they become. A quote is an unpaid order on the `quote` channel, so\n * this reads `hosts/{hostId}/orders` for that channel alone; the Orders list\n * above never shows one until it is paid.\n */\nexport function HostQuotesCard(props: HostQuotesCardProps) {\n const { hostId, canEdit, canSend } = props\n const firestore = useFirestore()\n const { data: quoteDocs, status } = useFirestoreCollection<QuoteRow>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'orders'),\n where('channel', '==', 'quote'),\n limit(QUOTE_CEILING + 1),\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const quotes = useMemo(() => ceilingedWindow<QuoteRow>(quoteDocs ?? undefined, QUOTE_CEILING), [quoteDocs])\n const rows = useMemo(() => Array.from(quotes.rows, quoteListRow), [quotes])\n\n const { data: productDocs } = useFirestoreCollection<Record<string, any>>(\n () => collectionCeiling(collection(firestore, 'hosts', hostId, 'products'), PRODUCT_WINDOW),\n [firestore, hostId],\n { idField: '$id' },\n )\n const products = useMemo(\n () => ceilingedWindow<Record<string, any>>(productDocs ?? undefined, PRODUCT_WINDOW).rows.filter((product) => !product['deletedAt']),\n [productDocs],\n )\n\n /** The quote open in the editor: its id, or `new`. */\n const [openId, setOpenId] = useState<string | null>(null)\n const openQuote = openId && openId !== 'new' ? (quotes.rows.find((row) => row.$id === openId) ?? null) : null\n\n // A manager notice opens its quote (AGL-4096): `?quote=<id>` on the Orders\n // page, once that quote has loaded.\n const [linkedId, setLinkedId] = useState(() =>\n typeof window === 'undefined' ? '' : (new URLSearchParams(window.location.search).get('quote') ?? ''),\n )\n useEffect(() => {\n if (!linkedId || !quotes.rows.some((row) => row.$id === linkedId)) return\n setOpenId(linkedId)\n setLinkedId('')\n }, [linkedId, quotes.rows])\n\n const columns = useMemo<GridColDef[]>(\n () => [\n {\n field: 'number',\n headerName: 'Quote',\n flex: 1,\n minWidth: 160,\n renderCell: ({ row }: { row: QuoteListRow }) => (\n <Stack sx={{ minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {CommerceModel.formatQuoteNumber(row, row.$id)}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {row.moreItems ? `${row.firstItem} and more` : row.firstItem}\n </Typography>\n </Stack>\n ),\n },\n {\n field: 'customerLabel',\n headerName: 'Customer',\n flex: 1,\n minWidth: 160,\n renderCell: ({ row }: { row: QuoteListRow }) => (\n <Typography variant=\"body2\" noWrap>\n {row.customerLabel || '—'}\n </Typography>\n ),\n },\n {\n field: 'totalCents',\n headerName: 'Total',\n type: 'number',\n width: 140,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: { row: QuoteListRow }) => (\n <Stack sx={{ alignItems: 'flex-end', minWidth: 0, width: 1 }}>\n <Typography variant=\"body2\">{posMoney(row.totalCents)}</Typography>\n {row.quote?.tax?.kind === 'at-payment' && CommerceModel.isUnpaidQuote(row) ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {'plus tax at payment'}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n {\n field: 'statusLabel',\n headerName: 'Status',\n width: 180,\n renderCell: ({ row }: { row: QuoteListRow }) => (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={row.statusLabel}\n color={CommerceModel.QUOTE_STATUS_COLOR[row.statusKey]}\n />\n ),\n },\n {\n field: 'updatedAtMs',\n headerName: 'Updated',\n type: 'number',\n width: 140,\n align: 'left',\n headerAlign: 'left',\n renderCell: ({ row }: { row: QuoteListRow }) => (\n <Typography variant=\"body2\" noWrap>\n {row.updatedAtMs ? new Date(row.updatedAtMs).toLocaleDateString() : '—'}\n </Typography>\n ),\n },\n ],\n [],\n )\n\n const empty = status === 'success' && rows.length === 0\n\n return (\n <CardDisplay\n header={'Quotes'}\n subheader={'Prices for a customer’s order before they buy'}\n help={pluginDocsHelp('quotes', {\n anchor: '#make-a-quote',\n title: 'Quotes',\n excerpt:\n 'Put a price to a customer with your products, items typed for the job and anything ' +\n 'included. A quote becomes an order when it is paid.',\n })}\n HeaderProps={{\n action: (\n <BlockedControl blocked={!canEdit} reason={REFUSAL}>\n <Button size=\"small\" variant=\"contained\" onClick={() => setOpenId('new')}>\n {'New quote'}\n </Button>\n </BlockedControl>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n {empty ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No quotes yet. A quote prices a customer’s order before they buy: your products, ' +\n 'items typed for the job, and anything you include at no charge.'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n {quotes.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This store has more quotes than the list shows at once.'}\n </Typography>\n ) : null}\n <ListTable\n aria-label=\"Quotes\"\n rows={rows}\n columns={columns}\n loading={status === 'loading'}\n onOpen={(id) => setOpenId(id)}\n quickFilter\n initialState={{ sorting: { sortModel: [{ field: 'updatedAtMs', sort: 'desc' }] } }}\n noRowsLabel=\"No quotes match\"\n />\n </Stack>\n )}\n {/* A quote deleted while open closes its editor rather than turn it into a new one. */}\n {openId === 'new' || openQuote ? (\n <QuoteEditorDialog\n key={openId}\n hostId={hostId}\n quote={openQuote}\n products={products}\n canEdit={canEdit}\n canSend={canSend}\n onClose={() => setOpenId(null)}\n />\n ) : null}\n </CardDisplay>\n )\n}\nHostQuotesCard.displayName = 'HostQuotesCard'\n\nexport default HostQuotesCard\n"],"names":["CommerceModel","posMoney","commerceSitePermissionRefusal","pluginDocsHelp","CardDisplay","BlockedControl","ListTable","ceilingedWindow","collectionCeiling","useFirestore","useFirestoreCollection","Button","Chip","Stack","Typography","collection","limit","query","where","useEffect","useMemo","useState","QuoteEditorDialog","QUOTE_CEILING","PRODUCT_WINDOW","REFUSAL","quoteListRow","quote","items","customer","status","quoteDisplayStatus","lineItems","customerLabel","name","company","email","firstItem","moreItems","Math","max","length","statusKey","key","statusLabel","label","totalCents","Number","totals","updatedAtMs","latestQuoteResponse","atMs","HostQuotesCard","props","quotes","hostId","canEdit","canSend","firestore","data","quoteDocs","idField","undefined","rows","Array","from","productDocs","products","filter","product","openId","setOpenId","openQuote","find","row","$id","linkedId","setLinkedId","window","URLSearchParams","location","search","get","some","columns","field","headerName","flex","minWidth","renderCell","sx","variant","noWrap","formatQuoteNumber","color","type","width","align","headerAlign","alignItems","tax","kind","isUnpaidQuote","size","QUOTE_STATUS_COLOR","Date","toLocaleDateString","empty","header","subheader","help","anchor","title","excerpt","HeaderProps","action","blocked","reason","onClick","contentGutterX","contentGutterY","spacing","truncated","aria-label","loading","onOpen","id","quickFilter","initialState","sorting","sortModel","sort","noRowsLabel","onClose","displayName"],"mappings":"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"}
@@ -21,10 +21,12 @@ export interface OrderInvoiceButtonProps {
21
21
  order: CommerceModel.HostOrder;
22
22
  }
23
23
  /**
24
- * "Invoice" (AGL-3611), beside "Packing slip" in the order dialog: the order
25
- * as a printable bill, which the print dialog also saves as a PDF. The store's
26
- * name, currency and footer are read when it is clicked, not while the dialog
27
- * is open, so the dialog listens to nothing more.
24
+ * "Invoice" (AGL-3611), beside "Packing slip" in the order dialog: a menu of
25
+ * "Print invoice", the order as a printable bill, and "Download PDF"
26
+ * (AGL-4095), the branded invoice the server makes in the site's colors with
27
+ * its logo (`commerce/order-print?kind=invoice-pdf`, "Manage orders"). The
28
+ * store's name, currency and footer are read when one is clicked, not while
29
+ * the dialog is open, so the dialog listens to nothing more.
28
30
  */
29
31
  export declare function OrderInvoiceButton(props: OrderInvoiceButtonProps): import("react").JSX.Element;
30
32
  export declare namespace OrderInvoiceButton {
@@ -14,22 +14,57 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ 'use client';
17
- import { jsx as _jsx } from "react/jsx-runtime";
18
- import { useFirestore } from "@aglyn/tenant-feature-instance";
19
- import { Button } from "@mui/material";
17
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
18
+ import { useSnackbar } from "@aglyn/shared-ui-snackstack";
19
+ import { useFirestore, useUser } from "@aglyn/tenant-feature-instance";
20
+ import { Button, Menu, MenuItem } from "@mui/material";
20
21
  import { doc, getDoc } from "firebase/firestore";
21
22
  import { useCallback, useState } from "react";
22
23
  import { buildOrderInvoiceHtml, invoiceStoreFrom } from "../../utils/order-invoice.js";
24
+ import { downloadCommercePdf } from "./commerce-pdf-download.js";
23
25
  /**
24
- * "Invoice" (AGL-3611), beside "Packing slip" in the order dialog: the order
25
- * as a printable bill, which the print dialog also saves as a PDF. The store's
26
- * name, currency and footer are read when it is clicked, not while the dialog
27
- * is open, so the dialog listens to nothing more.
26
+ * "Invoice" (AGL-3611), beside "Packing slip" in the order dialog: a menu of
27
+ * "Print invoice", the order as a printable bill, and "Download PDF"
28
+ * (AGL-4095), the branded invoice the server makes in the site's colors with
29
+ * its logo (`commerce/order-print?kind=invoice-pdf`, "Manage orders"). The
30
+ * store's name, currency and footer are read when one is clicked, not while
31
+ * the dialog is open, so the dialog listens to nothing more.
28
32
  */ export function OrderInvoiceButton(props) {
29
33
  const { hostId, orderId, order } = props;
30
34
  const firestore = useFirestore();
35
+ const { data: user } = useUser();
36
+ const { enqueueSnackbar } = useSnackbar();
31
37
  const [busy, setBusy] = useState(false);
38
+ const [anchor, setAnchor] = useState(null);
39
+ const handleDownload = useCallback(async ()=>{
40
+ setAnchor(null);
41
+ setBusy(true);
42
+ try {
43
+ var _order_number;
44
+ const query = new URLSearchParams({
45
+ hostId,
46
+ orderId,
47
+ kind: 'invoice-pdf'
48
+ });
49
+ const refused = await downloadCommercePdf(user, `/api/commerce/order-print?${query.toString()}`, {
50
+ method: 'GET'
51
+ }, `invoice-${(_order_number = order.number) != null ? _order_number : orderId.slice(-6)}.pdf`);
52
+ if (refused) enqueueSnackbar(refused, {
53
+ variant: 'error',
54
+ allowDuplicate: true
55
+ });
56
+ } finally{
57
+ setBusy(false);
58
+ }
59
+ }, [
60
+ user,
61
+ hostId,
62
+ orderId,
63
+ order.number,
64
+ enqueueSnackbar
65
+ ]);
32
66
  const handleInvoice = useCallback(async ()=>{
67
+ setAnchor(null);
33
68
  // Opened before the reads, inside the click, so a popup blocker sees the
34
69
  // user's gesture.
35
70
  const win = window.open('', '_blank', 'width=720,height=900');
@@ -61,10 +96,31 @@ import { buildOrderInvoiceHtml, invoiceStoreFrom } from "../../utils/order-invoi
61
96
  orderId,
62
97
  order
63
98
  ]);
64
- return /*#__PURE__*/ _jsx(Button, {
65
- disabled: busy,
66
- onClick: handleInvoice,
67
- children: 'Invoice'
99
+ return /*#__PURE__*/ _jsxs(_Fragment, {
100
+ children: [
101
+ /*#__PURE__*/ _jsx(Button, {
102
+ disabled: busy,
103
+ "aria-haspopup": "menu",
104
+ "aria-expanded": anchor ? 'true' : undefined,
105
+ onClick: (event)=>setAnchor(event.currentTarget),
106
+ children: 'Invoice'
107
+ }),
108
+ /*#__PURE__*/ _jsxs(Menu, {
109
+ anchorEl: anchor,
110
+ open: Boolean(anchor),
111
+ onClose: ()=>setAnchor(null),
112
+ children: [
113
+ /*#__PURE__*/ _jsx(MenuItem, {
114
+ onClick: ()=>void handleInvoice(),
115
+ children: 'Print invoice'
116
+ }),
117
+ /*#__PURE__*/ _jsx(MenuItem, {
118
+ onClick: ()=>void handleDownload(),
119
+ children: 'Download PDF'
120
+ })
121
+ ]
122
+ })
123
+ ]
68
124
  });
69
125
  }
70
126
  OrderInvoiceButton.displayName = 'OrderInvoiceButton';
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/order-invoice.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n'use client'\n\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { Button } from '@mui/material'\nimport { doc, getDoc } from 'firebase/firestore'\nimport { useCallback, useState } from 'react'\nimport type * as CommerceModel from '../../model'\nimport { buildOrderInvoiceHtml, invoiceStoreFrom } from '../../utils/order-invoice'\n\nexport interface OrderInvoiceButtonProps {\n hostId: string\n orderId: string\n order: CommerceModel.HostOrder\n}\n\n/**\n * \"Invoice\" (AGL-3611), beside \"Packing slip\" in the order dialog: the order\n * as a printable bill, which the print dialog also saves as a PDF. The store's\n * name, currency and footer are read when it is clicked, not while the dialog\n * is open, so the dialog listens to nothing more.\n */\nexport function OrderInvoiceButton(props: OrderInvoiceButtonProps) {\n const { hostId, orderId, order } = props\n const firestore = useFirestore()\n const [busy, setBusy] = useState(false)\n\n const handleInvoice = useCallback(async () => {\n // Opened before the reads, inside the click, so a popup blocker sees the\n // user's gesture.\n const win = window.open('', '_blank', 'width=720,height=900')\n if (!win) return\n setBusy(true)\n try {\n const [host, store] = await Promise.all([\n getDoc(doc(firestore, 'hosts', hostId)).catch(() => null),\n getDoc(doc(firestore, 'hosts', hostId, 'settings', 'store')).catch(() => null),\n ])\n const html = buildOrderInvoiceHtml({\n order,\n orderId,\n store: invoiceStoreFrom(\n host?.data() as Record<string, unknown> | undefined,\n store?.data() as Record<string, unknown> | undefined,\n ),\n })\n // Every value in `html` is escaped by the builder (AGL-2283).\n win.document.write(html)\n win.document.close()\n // Print from the opener, not an injected script: the popup inherits\n // the console's CSP (AGL-523).\n win.focus()\n win.print()\n } finally {\n setBusy(false)\n }\n }, [firestore, hostId, orderId, order])\n\n return (\n <Button disabled={busy} onClick={handleInvoice}>\n {'Invoice'}\n </Button>\n )\n}\nOrderInvoiceButton.displayName = 'OrderInvoiceButton'\n\nexport default OrderInvoiceButton\n"],"names":["useFirestore","Button","doc","getDoc","useCallback","useState","buildOrderInvoiceHtml","invoiceStoreFrom","OrderInvoiceButton","props","hostId","orderId","order","firestore","busy","setBusy","handleInvoice","win","window","open","host","store","Promise","all","catch","html","data","document","write","close","focus","print","disabled","onClick","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;AAEA,SAASA,YAAY,QAAQ,iCAAgC;AAC7D,SAASC,MAAM,QAAQ,gBAAe;AACtC,SAASC,GAAG,EAAEC,MAAM,QAAQ,qBAAoB;AAChD,SAASC,WAAW,EAAEC,QAAQ,QAAQ,QAAO;AAE7C,SAASC,qBAAqB,EAAEC,gBAAgB,QAAQ,+BAA2B;AAQnF;;;;;CAKC,GACD,OAAO,SAASC,mBAAmBC,KAA8B;IAC/D,MAAM,EAAEC,MAAM,EAAEC,OAAO,EAAEC,KAAK,EAAE,GAAGH;IACnC,MAAMI,YAAYb;IAClB,MAAM,CAACc,MAAMC,QAAQ,GAAGV,SAAS;IAEjC,MAAMW,gBAAgBZ,YAAY;QAChC,yEAAyE;QACzE,kBAAkB;QAClB,MAAMa,MAAMC,OAAOC,IAAI,CAAC,IAAI,UAAU;QACtC,IAAI,CAACF,KAAK;QACVF,QAAQ;QACR,IAAI;YACF,MAAM,CAACK,MAAMC,MAAM,GAAG,MAAMC,QAAQC,GAAG,CAAC;gBACtCpB,OAAOD,IAAIW,WAAW,SAASH,SAASc,KAAK,CAAC,IAAM;gBACpDrB,OAAOD,IAAIW,WAAW,SAASH,QAAQ,YAAY,UAAUc,KAAK,CAAC,IAAM;aAC1E;YACD,MAAMC,OAAOnB,sBAAsB;gBACjCM;gBACAD;gBACAU,OAAOd,iBACLa,wBAAAA,KAAMM,IAAI,IACVL,yBAAAA,MAAOK,IAAI;YAEf;YACA,8DAA8D;YAC9DT,IAAIU,QAAQ,CAACC,KAAK,CAACH;YACnBR,IAAIU,QAAQ,CAACE,KAAK;YAClB,oEAAoE;YACpE,+BAA+B;YAC/BZ,IAAIa,KAAK;YACTb,IAAIc,KAAK;QACX,SAAU;YACRhB,QAAQ;QACV;IACF,GAAG;QAACF;QAAWH;QAAQC;QAASC;KAAM;IAEtC,qBACE,KAACX;QAAO+B,UAAUlB;QAAMmB,SAASjB;kBAC9B;;AAGP;AACAR,mBAAmB0B,WAAW,GAAG;AAEjC,eAAe1B,mBAAkB"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/order-invoice.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n'use client'\n\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useFirestore, useUser } from '@aglyn/tenant-feature-instance'\nimport { Button, Menu, MenuItem } from '@mui/material'\nimport { doc, getDoc } from 'firebase/firestore'\nimport { useCallback, useState } from 'react'\nimport type * as CommerceModel from '../../model'\nimport { buildOrderInvoiceHtml, invoiceStoreFrom } from '../../utils/order-invoice'\nimport { downloadCommercePdf } from './commerce-pdf-download'\n\nexport interface OrderInvoiceButtonProps {\n hostId: string\n orderId: string\n order: CommerceModel.HostOrder\n}\n\n/**\n * \"Invoice\" (AGL-3611), beside \"Packing slip\" in the order dialog: a menu of\n * \"Print invoice\", the order as a printable bill, and \"Download PDF\"\n * (AGL-4095), the branded invoice the server makes in the site's colors with\n * its logo (`commerce/order-print?kind=invoice-pdf`, \"Manage orders\"). The\n * store's name, currency and footer are read when one is clicked, not while\n * the dialog is open, so the dialog listens to nothing more.\n */\nexport function OrderInvoiceButton(props: OrderInvoiceButtonProps) {\n const { hostId, orderId, order } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const [busy, setBusy] = useState(false)\n const [anchor, setAnchor] = useState<HTMLElement | null>(null)\n\n const handleDownload = useCallback(async () => {\n setAnchor(null)\n setBusy(true)\n try {\n const query = new URLSearchParams({ hostId, orderId, kind: 'invoice-pdf' })\n const refused = await downloadCommercePdf(\n user,\n `/api/commerce/order-print?${query.toString()}`,\n { method: 'GET' },\n `invoice-${order.number ?? orderId.slice(-6)}.pdf`,\n )\n if (refused) enqueueSnackbar(refused, { variant: 'error', allowDuplicate: true })\n } finally {\n setBusy(false)\n }\n }, [user, hostId, orderId, order.number, enqueueSnackbar])\n\n const handleInvoice = useCallback(async () => {\n setAnchor(null)\n // Opened before the reads, inside the click, so a popup blocker sees the\n // user's gesture.\n const win = window.open('', '_blank', 'width=720,height=900')\n if (!win) return\n setBusy(true)\n try {\n const [host, store] = await Promise.all([\n getDoc(doc(firestore, 'hosts', hostId)).catch(() => null),\n getDoc(doc(firestore, 'hosts', hostId, 'settings', 'store')).catch(() => null),\n ])\n const html = buildOrderInvoiceHtml({\n order,\n orderId,\n store: invoiceStoreFrom(\n host?.data() as Record<string, unknown> | undefined,\n store?.data() as Record<string, unknown> | undefined,\n ),\n })\n // Every value in `html` is escaped by the builder (AGL-2283).\n win.document.write(html)\n win.document.close()\n // Print from the opener, not an injected script: the popup inherits\n // the console's CSP (AGL-523).\n win.focus()\n win.print()\n } finally {\n setBusy(false)\n }\n }, [firestore, hostId, orderId, order])\n\n return (\n <>\n <Button\n disabled={busy}\n aria-haspopup=\"menu\"\n aria-expanded={anchor ? 'true' : undefined}\n onClick={(event) => setAnchor(event.currentTarget)}\n >\n {'Invoice'}\n </Button>\n <Menu anchorEl={anchor} open={Boolean(anchor)} onClose={() => setAnchor(null)}>\n <MenuItem onClick={() => void handleInvoice()}>{'Print invoice'}</MenuItem>\n <MenuItem onClick={() => void handleDownload()}>{'Download PDF'}</MenuItem>\n </Menu>\n </>\n )\n}\nOrderInvoiceButton.displayName = 'OrderInvoiceButton'\n\nexport default OrderInvoiceButton\n"],"names":["useSnackbar","useFirestore","useUser","Button","Menu","MenuItem","doc","getDoc","useCallback","useState","buildOrderInvoiceHtml","invoiceStoreFrom","downloadCommercePdf","OrderInvoiceButton","props","hostId","orderId","order","firestore","data","user","enqueueSnackbar","busy","setBusy","anchor","setAnchor","handleDownload","query","URLSearchParams","kind","refused","toString","method","number","slice","variant","allowDuplicate","handleInvoice","win","window","open","host","store","Promise","all","catch","html","document","write","close","focus","print","disabled","aria-haspopup","aria-expanded","undefined","onClick","event","currentTarget","anchorEl","Boolean","onClose","displayName"],"mappings":"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"}
@@ -165,6 +165,39 @@ function PosCheckInsBody(props) {
165
165
  minWidth: 150,
166
166
  valueGetter: (_value, row)=>row.customerName || row.customerEmail || ''
167
167
  },
168
+ {
169
+ // What a pass waiting at the window can still have done to it: a
170
+ // rain check, or one taken back; and a stay's guest count (AGL-4043).
171
+ // Not a value, so nothing to sort.
172
+ //
173
+ // Second, beside the name it acts on, rather than trailing. The grid
174
+ // has no column pinning, so a trailing column sat past the right edge
175
+ // of any window narrower than every column put together — on a
176
+ // laptop, and always on a phone — and the window could not see it.
177
+ field: 'rainCheckAction',
178
+ headerName: 'Actions',
179
+ minWidth: 150,
180
+ sortable: false,
181
+ filterable: false,
182
+ hideable: false,
183
+ disableColumnMenu: true,
184
+ renderCell: ({ row })=>row.source === 'reservation' && row.reservationId ? /*#__PURE__*/ _jsx(Button, {
185
+ size: "small",
186
+ disabled: !canManagePos,
187
+ onClick: ()=>setGuestsFor(row.reservationId),
188
+ children: 'Guests'
189
+ }) : row.source !== 'order' || row.status !== 'expected' ? null : row.rainCheck ? /*#__PURE__*/ _jsx(Button, {
190
+ size: "small",
191
+ disabled: !canManagePos,
192
+ onClick: ()=>void undoRainCheck(row),
193
+ children: 'Undo rain check'
194
+ }) : /*#__PURE__*/ _jsx(Button, {
195
+ size: "small",
196
+ disabled: !canManagePos || !todayKey,
197
+ onClick: ()=>setRainCheckRow(row),
198
+ children: 'Rain check'
199
+ })
200
+ },
168
201
  {
169
202
  field: 'itemName',
170
203
  headerName: 'Item',
@@ -225,34 +258,6 @@ function PosCheckInsBody(props) {
225
258
  headerName: 'Wristband',
226
259
  minWidth: 120,
227
260
  valueGetter: (_value, row)=>describeWristband(row.wristband)
228
- },
229
- {
230
- // What a pass waiting at the window can still have done to it: a
231
- // rain check, or one taken back; and a stay's guest count (AGL-4043).
232
- // Not a value, so nothing to sort.
233
- field: 'rainCheckAction',
234
- headerName: '',
235
- minWidth: 150,
236
- sortable: false,
237
- filterable: false,
238
- hideable: false,
239
- disableColumnMenu: true,
240
- renderCell: ({ row })=>row.source === 'reservation' && row.reservationId ? /*#__PURE__*/ _jsx(Button, {
241
- size: "small",
242
- disabled: !canManagePos,
243
- onClick: ()=>setGuestsFor(row.reservationId),
244
- children: 'Guests'
245
- }) : row.source !== 'order' || row.status !== 'expected' ? null : row.rainCheck ? /*#__PURE__*/ _jsx(Button, {
246
- size: "small",
247
- disabled: !canManagePos,
248
- onClick: ()=>void undoRainCheck(row),
249
- children: 'Undo rain check'
250
- }) : /*#__PURE__*/ _jsx(Button, {
251
- size: "small",
252
- disabled: !canManagePos || !todayKey,
253
- onClick: ()=>setRainCheckRow(row),
254
- children: 'Rain check'
255
- })
256
261
  }
257
262
  ], [
258
263
  list,