@open-mercato/core 0.4.8-develop-d16e2f51dc → 0.4.8-develop-4b58cde65d

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 (85) hide show
  1. package/dist/generated/entities/sales_return/index.js +31 -0
  2. package/dist/generated/entities/sales_return/index.js.map +7 -0
  3. package/dist/generated/entities/sales_return_line/index.js +29 -0
  4. package/dist/generated/entities/sales_return_line/index.js.map +7 -0
  5. package/dist/generated/entities.ids.generated.js +2 -0
  6. package/dist/generated/entities.ids.generated.js.map +2 -2
  7. package/dist/generated/entity-fields-registry.js +4 -0
  8. package/dist/generated/entity-fields-registry.js.map +2 -2
  9. package/dist/modules/sales/acl.js +2 -0
  10. package/dist/modules/sales/acl.js.map +2 -2
  11. package/dist/modules/sales/api/document-history/route.js +20 -2
  12. package/dist/modules/sales/api/document-history/route.js.map +2 -2
  13. package/dist/modules/sales/api/documents/factory.js +34 -0
  14. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  15. package/dist/modules/sales/api/returns/[id]/route.js +147 -0
  16. package/dist/modules/sales/api/returns/[id]/route.js.map +7 -0
  17. package/dist/modules/sales/api/returns/route.js +158 -0
  18. package/dist/modules/sales/api/returns/route.js.map +7 -0
  19. package/dist/modules/sales/backend/sales/documents/[id]/page.js +20 -1
  20. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  21. package/dist/modules/sales/commands/index.js +1 -0
  22. package/dist/modules/sales/commands/index.js.map +2 -2
  23. package/dist/modules/sales/commands/returns.js +467 -0
  24. package/dist/modules/sales/commands/returns.js.map +7 -0
  25. package/dist/modules/sales/components/documents/ReturnDialog.js +176 -0
  26. package/dist/modules/sales/components/documents/ReturnDialog.js.map +7 -0
  27. package/dist/modules/sales/components/documents/ReturnsSection.js +188 -0
  28. package/dist/modules/sales/components/documents/ReturnsSection.js.map +7 -0
  29. package/dist/modules/sales/data/entities.js +115 -1
  30. package/dist/modules/sales/data/entities.js.map +2 -2
  31. package/dist/modules/sales/data/validators.js +13 -0
  32. package/dist/modules/sales/data/validators.js.map +2 -2
  33. package/dist/modules/sales/events.js +4 -0
  34. package/dist/modules/sales/events.js.map +2 -2
  35. package/dist/modules/sales/lib/calculations.js +7 -0
  36. package/dist/modules/sales/lib/calculations.js.map +2 -2
  37. package/dist/modules/sales/lib/dictionaries.js +1 -0
  38. package/dist/modules/sales/lib/dictionaries.js.map +2 -2
  39. package/dist/modules/sales/lib/documentNumberTokens.js +2 -0
  40. package/dist/modules/sales/lib/documentNumberTokens.js.map +2 -2
  41. package/dist/modules/sales/lib/historyHelpers.js +15 -7
  42. package/dist/modules/sales/lib/historyHelpers.js.map +2 -2
  43. package/dist/modules/sales/lib/makeSalesLineRoute.js +42 -37
  44. package/dist/modules/sales/lib/makeSalesLineRoute.js.map +2 -2
  45. package/dist/modules/sales/migrations/Migration20260309073310.js +23 -0
  46. package/dist/modules/sales/migrations/Migration20260309073310.js.map +7 -0
  47. package/dist/modules/sales/services/salesDocumentNumberGenerator.js +8 -6
  48. package/dist/modules/sales/services/salesDocumentNumberGenerator.js.map +2 -2
  49. package/dist/modules/sales/setup.js +1 -1
  50. package/dist/modules/sales/setup.js.map +2 -2
  51. package/dist/modules/sales/widgets/injection/document-history/widget.client.js +25 -16
  52. package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
  53. package/generated/entities/sales_return/index.ts +14 -0
  54. package/generated/entities/sales_return_line/index.ts +13 -0
  55. package/generated/entities.ids.generated.ts +2 -0
  56. package/generated/entity-fields-registry.ts +4 -0
  57. package/package.json +3 -3
  58. package/src/modules/sales/AGENTS.md +1 -0
  59. package/src/modules/sales/acl.ts +2 -0
  60. package/src/modules/sales/api/document-history/route.ts +25 -1
  61. package/src/modules/sales/api/documents/factory.ts +35 -0
  62. package/src/modules/sales/api/returns/[id]/route.ts +156 -0
  63. package/src/modules/sales/api/returns/route.ts +171 -0
  64. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +18 -0
  65. package/src/modules/sales/commands/index.ts +1 -0
  66. package/src/modules/sales/commands/returns.ts +540 -0
  67. package/src/modules/sales/components/documents/ReturnDialog.tsx +216 -0
  68. package/src/modules/sales/components/documents/ReturnsSection.tsx +270 -0
  69. package/src/modules/sales/data/entities.ts +99 -3
  70. package/src/modules/sales/data/validators.ts +16 -0
  71. package/src/modules/sales/events.ts +5 -0
  72. package/src/modules/sales/i18n/de.json +32 -0
  73. package/src/modules/sales/i18n/en.json +32 -0
  74. package/src/modules/sales/i18n/es.json +32 -0
  75. package/src/modules/sales/i18n/pl.json +32 -0
  76. package/src/modules/sales/lib/calculations.ts +9 -0
  77. package/src/modules/sales/lib/dictionaries.ts +1 -0
  78. package/src/modules/sales/lib/documentNumberTokens.ts +2 -1
  79. package/src/modules/sales/lib/historyHelpers.ts +20 -9
  80. package/src/modules/sales/lib/makeSalesLineRoute.ts +42 -37
  81. package/src/modules/sales/migrations/.snapshot-open-mercato.json +398 -0
  82. package/src/modules/sales/migrations/Migration20260309073310.ts +26 -0
  83. package/src/modules/sales/services/salesDocumentNumberGenerator.ts +15 -4
  84. package/src/modules/sales/setup.ts +1 -1
  85. package/src/modules/sales/widgets/injection/document-history/widget.client.tsx +26 -17
@@ -0,0 +1,216 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
5
+ import { Input } from '@open-mercato/ui/primitives/input'
6
+ import { Textarea } from '@open-mercato/ui/primitives/textarea'
7
+ import { Label } from '@open-mercato/ui/primitives/label'
8
+ import { Button } from '@open-mercato/ui/primitives/button'
9
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
10
+ import { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
11
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
12
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
13
+
14
+ export type ReturnOrderLine = {
15
+ id: string
16
+ title: string
17
+ lineNumber: number | null
18
+ quantity: number
19
+ returnedQuantity: number
20
+ thumbnail?: string | null
21
+ }
22
+
23
+ type ReturnDialogProps = {
24
+ open: boolean
25
+ orderId: string
26
+ lines: ReturnOrderLine[]
27
+ onClose: () => void
28
+ onSaved: () => Promise<void>
29
+ }
30
+
31
+ const normalizeNumber = (value: unknown): number => {
32
+ if (typeof value === 'number' && Number.isFinite(value)) return value
33
+ if (typeof value === 'string' && value.trim().length) {
34
+ const parsed = Number(value)
35
+ if (Number.isFinite(parsed)) return parsed
36
+ }
37
+ return 0
38
+ }
39
+
40
+ export function ReturnDialog({ open, orderId, lines, onClose, onSaved }: ReturnDialogProps) {
41
+ const t = useT()
42
+ const { runMutation } = useGuardedMutation({ contextId: `sales-returns-${orderId}` })
43
+ const [reason, setReason] = React.useState('')
44
+ const [notes, setNotes] = React.useState('')
45
+ const [quantities, setQuantities] = React.useState<Record<string, string>>({})
46
+ const [saving, setSaving] = React.useState(false)
47
+
48
+ const availableLines = React.useMemo(() => {
49
+ return lines
50
+ .map((line) => {
51
+ const available = Math.max(0, line.quantity - line.returnedQuantity)
52
+ return { ...line, available }
53
+ })
54
+ .filter((line) => line.available > 0)
55
+ }, [lines])
56
+
57
+ React.useEffect(() => {
58
+ if (!open) return
59
+ setReason('')
60
+ setNotes('')
61
+ setQuantities({})
62
+ }, [open])
63
+
64
+ const submit = React.useCallback(async () => {
65
+ if (saving) return
66
+ let hasInvalidQuantity = false
67
+ const linesForRequest: Array<{ orderLineId: string; quantity: string }> = []
68
+ availableLines.forEach((line) => {
69
+ const raw = quantities[line.id]
70
+ const qty = normalizeNumber(raw)
71
+ if (!Number.isFinite(qty) || qty <= 0) return
72
+ if (qty - 1e-6 > line.available) {
73
+ hasInvalidQuantity = true
74
+ return
75
+ }
76
+ linesForRequest.push({ orderLineId: line.id, quantity: qty.toString() })
77
+ })
78
+
79
+ if (hasInvalidQuantity) {
80
+ flash(t('sales.returns.errors.quantityExceeded', 'Cannot return more than available quantity.'), 'error')
81
+ return
82
+ }
83
+
84
+ if (!linesForRequest.length) {
85
+ flash(t('sales.returns.errors.linesRequired', 'Select at least one line to return.'), 'error')
86
+ return
87
+ }
88
+
89
+ setSaving(true)
90
+ try {
91
+ await runMutation({
92
+ context: { kind: 'order', record: { id: orderId } },
93
+ mutationPayload: { orderId, lines: linesForRequest, reason, notes },
94
+ operation: async () => {
95
+ const response = await apiCallOrThrow<{ id: string | null }>(
96
+ '/api/sales/returns',
97
+ {
98
+ method: 'POST',
99
+ headers: { 'content-type': 'application/json' },
100
+ body: JSON.stringify({
101
+ orderId,
102
+ lines: linesForRequest,
103
+ ...(reason.trim().length ? { reason: reason.trim() } : {}),
104
+ ...(notes.trim().length ? { notes: notes.trim() } : {}),
105
+ }),
106
+ },
107
+ { errorMessage: t('sales.returns.errors.create', 'Failed to create return.') },
108
+ )
109
+ return response.result?.id ?? null
110
+ },
111
+ })
112
+ flash(t('sales.returns.created', 'Return created.'), 'success')
113
+ onClose()
114
+ await onSaved()
115
+ } catch {
116
+ flash(t('sales.returns.errors.create', 'Failed to create return.'), 'error')
117
+ } finally {
118
+ setSaving(false)
119
+ }
120
+ }, [availableLines, notes, onClose, onSaved, orderId, quantities, reason, runMutation, saving, t])
121
+
122
+ const onKeyDown = React.useCallback(
123
+ (e: React.KeyboardEvent) => {
124
+ if (e.key === 'Escape') onClose()
125
+ if ((e.key === 'Enter' || e.key === 'NumpadEnter') && (e.metaKey || e.ctrlKey)) {
126
+ e.preventDefault()
127
+ submit()
128
+ }
129
+ },
130
+ [onClose, submit],
131
+ )
132
+
133
+ return (
134
+ <Dialog open={open} onOpenChange={(next) => (!next ? onClose() : undefined)}>
135
+ <DialogContent onKeyDown={onKeyDown} className="max-w-2xl">
136
+ <DialogHeader>
137
+ <DialogTitle>{t('sales.returns.create.title', 'Create return')}</DialogTitle>
138
+ </DialogHeader>
139
+
140
+ <div className="space-y-4">
141
+ <div className="space-y-2">
142
+ <div className="text-sm font-medium">{t('sales.returns.create.lines', 'Lines')}</div>
143
+ {!availableLines.length ? (
144
+ <div className="text-sm text-muted-foreground">{t('sales.returns.empty.available', 'No items available to return.')}</div>
145
+ ) : (
146
+ <div className="max-h-[280px] overflow-auto rounded-md border">
147
+ <div className="divide-y">
148
+ {availableLines.map((line) => {
149
+ const value = quantities[line.id] ?? ''
150
+ return (
151
+ <div key={line.id} className="flex items-center gap-3 p-3">
152
+ <div className="min-w-0 flex-1">
153
+ <div className="truncate text-sm font-medium">
154
+ {line.lineNumber ? `#${line.lineNumber} · ` : ''}
155
+ {line.title}
156
+ </div>
157
+ <div className="text-xs text-muted-foreground">
158
+ {t('sales.returns.available', 'Available')}: {line.available}
159
+ </div>
160
+ </div>
161
+ <div className="w-32">
162
+ <Label className="sr-only" htmlFor={`return-qty-${line.id}`}>
163
+ {t('sales.returns.quantity', 'Quantity')}
164
+ </Label>
165
+ <Input
166
+ id={`return-qty-${line.id}`}
167
+ inputMode="decimal"
168
+ placeholder="0"
169
+ value={value}
170
+ onChange={(e) => setQuantities((prev) => ({ ...prev, [line.id]: e.target.value }))}
171
+ />
172
+ </div>
173
+ </div>
174
+ )
175
+ })}
176
+ </div>
177
+ </div>
178
+ )}
179
+ </div>
180
+
181
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
182
+ <div className="space-y-1.5">
183
+ <Label htmlFor="return-reason">{t('sales.returns.reason', 'Reason')}</Label>
184
+ <Input
185
+ id="return-reason"
186
+ value={reason}
187
+ onChange={(e) => setReason(e.target.value)}
188
+ placeholder={t('sales.returns.reason.placeholder', 'Optional')}
189
+ />
190
+ </div>
191
+ <div className="space-y-1.5 md:col-span-2">
192
+ <Label htmlFor="return-notes">{t('sales.returns.notes', 'Notes')}</Label>
193
+ <Textarea
194
+ id="return-notes"
195
+ value={notes}
196
+ onChange={(e) => setNotes(e.target.value)}
197
+ placeholder={t('sales.returns.notes.placeholder', 'Optional')}
198
+ rows={3}
199
+ />
200
+ </div>
201
+ </div>
202
+
203
+ <div className="flex items-center justify-end gap-2">
204
+ <Button type="button" variant="outline" onClick={onClose} disabled={saving}>
205
+ {t('common.cancel', 'Cancel')}
206
+ </Button>
207
+ <Button type="button" onClick={submit} disabled={saving || !availableLines.length}>
208
+ {saving ? t('common.saving', 'Saving…') : t('sales.returns.create.submit', 'Create return')}
209
+ </Button>
210
+ </div>
211
+ </div>
212
+ </DialogContent>
213
+ </Dialog>
214
+ )
215
+ }
216
+
@@ -0,0 +1,270 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Undo2, Plus } from 'lucide-react'
5
+ import { Button } from '@open-mercato/ui/primitives/button'
6
+ import { Badge } from '@open-mercato/ui/primitives/badge'
7
+ import { ErrorMessage, LoadingMessage, TabEmptyState } from '@open-mercato/ui/backend/detail'
8
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
9
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
10
+ import {
11
+ emitSalesDocumentTotalsRefresh,
12
+ subscribeSalesDocumentTotalsRefresh,
13
+ } from '@open-mercato/core/modules/sales/lib/frontend/documentTotalsEvents'
14
+ import { formatMoney, normalizeNumber } from './lineItemUtils'
15
+ import { ReturnDialog, type ReturnOrderLine } from './ReturnDialog'
16
+
17
+ type ReturnRow = {
18
+ id: string
19
+ returnNumber: string
20
+ status: string | null
21
+ returnedAt: string | null
22
+ totalNetAmount: number | null
23
+ totalGrossAmount: number | null
24
+ }
25
+
26
+ type SalesReturnsSectionProps = {
27
+ orderId: string
28
+ currencyCode?: string | null
29
+ }
30
+
31
+ function formatDisplayDate(value: string | null | undefined): string | null {
32
+ if (!value) return null
33
+ const date = new Date(value)
34
+ if (Number.isNaN(date.getTime())) return null
35
+ return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date)
36
+ }
37
+
38
+ export function SalesReturnsSection({ orderId, currencyCode }: SalesReturnsSectionProps) {
39
+ const t = useT()
40
+ const [returns, setReturns] = React.useState<ReturnRow[]>([])
41
+ const [lines, setLines] = React.useState<ReturnOrderLine[]>([])
42
+ const [loading, setLoading] = React.useState(false)
43
+ const [error, setError] = React.useState<string | null>(null)
44
+ const [dialogOpen, setDialogOpen] = React.useState(false)
45
+
46
+ const loadLines = React.useCallback(async () => {
47
+ const params = new URLSearchParams({ page: '1', pageSize: '100', orderId })
48
+ const response = await apiCall<{ items?: Array<Record<string, unknown>> }>(
49
+ `/api/sales/order-lines?${params.toString()}`,
50
+ undefined,
51
+ { fallback: { items: [] } },
52
+ )
53
+ const items = Array.isArray(response.result?.items) ? response.result?.items ?? [] : []
54
+ const mapped: ReturnOrderLine[] = items
55
+ .map((item) => {
56
+ const map = item as Record<string, unknown>
57
+ const id = typeof map.id === 'string' ? map.id : null
58
+ if (!id) return null
59
+ const snapshot = map['catalog_snapshot']
60
+ const snapshotName =
61
+ snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)
62
+ ? (snapshot as Record<string, unknown>)['name']
63
+ : null
64
+ const name =
65
+ typeof map.name === 'string'
66
+ ? map.name
67
+ : typeof snapshotName === 'string'
68
+ ? snapshotName
69
+ : null
70
+ const lineNumber =
71
+ typeof map['line_number'] === 'number'
72
+ ? (map['line_number'] as number)
73
+ : typeof map.lineNumber === 'number'
74
+ ? (map.lineNumber as number)
75
+ : null
76
+ const quantity =
77
+ typeof map.quantity === 'number'
78
+ ? (map.quantity as number)
79
+ : typeof map.quantity === 'string'
80
+ ? Number(map.quantity)
81
+ : 0
82
+ const returnedQuantity =
83
+ typeof map['returned_quantity'] === 'number'
84
+ ? (map['returned_quantity'] as number)
85
+ : typeof map.returnedQuantity === 'number'
86
+ ? (map.returnedQuantity as number)
87
+ : typeof map['returned_quantity'] === 'string'
88
+ ? Number(map['returned_quantity'])
89
+ : typeof map.returnedQuantity === 'string'
90
+ ? Number(map.returnedQuantity)
91
+ : 0
92
+ return {
93
+ id,
94
+ title: name ?? id,
95
+ lineNumber,
96
+ quantity: Number.isFinite(quantity) ? quantity : 0,
97
+ returnedQuantity: Number.isFinite(returnedQuantity) ? returnedQuantity : 0,
98
+ }
99
+ })
100
+ .filter((entry): entry is ReturnOrderLine => Boolean(entry?.id))
101
+ setLines(mapped)
102
+ }, [orderId])
103
+
104
+ const loadReturns = React.useCallback(async () => {
105
+ setLoading(true)
106
+ setError(null)
107
+ try {
108
+ const params = new URLSearchParams({ page: '1', pageSize: '100', orderId })
109
+ const response = await apiCall<{ items?: Array<Record<string, unknown>> }>(
110
+ `/api/sales/returns?${params.toString()}`,
111
+ undefined,
112
+ { fallback: { items: [] } },
113
+ )
114
+ const items = Array.isArray(response.result?.items) ? response.result?.items ?? [] : []
115
+ const mapped: ReturnRow[] = items
116
+ .map((item) => {
117
+ const map = item as Record<string, unknown>
118
+ const id = typeof map.id === 'string' ? map.id : null
119
+ if (!id) return null
120
+ const returnNumber =
121
+ typeof map['return_number'] === 'string'
122
+ ? (map['return_number'] as string)
123
+ : typeof map.returnNumber === 'string'
124
+ ? (map.returnNumber as string)
125
+ : id
126
+ const returnedAt =
127
+ typeof map['returned_at'] === 'string'
128
+ ? (map['returned_at'] as string)
129
+ : typeof map.returnedAt === 'string'
130
+ ? (map.returnedAt as string)
131
+ : null
132
+ const totalNetAmount =
133
+ typeof map['total_net_amount'] === 'number'
134
+ ? (map['total_net_amount'] as number)
135
+ : typeof map.totalNetAmount === 'number'
136
+ ? (map.totalNetAmount as number)
137
+ : null
138
+ const totalGrossAmount =
139
+ typeof map['total_gross_amount'] === 'number'
140
+ ? (map['total_gross_amount'] as number)
141
+ : typeof map.totalGrossAmount === 'number'
142
+ ? (map.totalGrossAmount as number)
143
+ : null
144
+ return {
145
+ id,
146
+ returnNumber,
147
+ status: typeof map.status === 'string' ? (map.status as string) : null,
148
+ returnedAt,
149
+ totalNetAmount,
150
+ totalGrossAmount,
151
+ }
152
+ })
153
+ .filter((entry): entry is ReturnRow => Boolean(entry?.id))
154
+ setReturns(mapped)
155
+ await loadLines()
156
+ } catch {
157
+ setError(t('sales.returns.errors.load', 'Failed to load returns.'))
158
+ } finally {
159
+ setLoading(false)
160
+ }
161
+ }, [loadLines, orderId, t])
162
+
163
+ React.useEffect(() => {
164
+ loadReturns()
165
+ }, [loadReturns])
166
+
167
+ React.useEffect(() => {
168
+ return subscribeSalesDocumentTotalsRefresh((detail) => {
169
+ if (detail.documentId !== orderId) return
170
+ loadReturns()
171
+ })
172
+ }, [loadReturns, orderId])
173
+
174
+ const emptyState = React.useMemo(
175
+ () => ({
176
+ title: t('sales.returns.empty.title', 'No returns yet.'),
177
+ description: t('sales.returns.empty.description', 'Create a return to generate credit adjustments for returned items.'),
178
+ }),
179
+ [t],
180
+ )
181
+
182
+ const rows = React.useMemo(() => {
183
+ return returns.map((ret) => {
184
+ const total = normalizeNumber(ret.totalGrossAmount ?? ret.totalNetAmount ?? 0, 0)
185
+ return {
186
+ ...ret,
187
+ total,
188
+ }
189
+ })
190
+ }, [returns])
191
+
192
+ if (loading) return <LoadingMessage label={t('sales.returns.loading', 'Loading returns…')} />
193
+ if (error) return <ErrorMessage label={error} />
194
+
195
+ if (!rows.length) {
196
+ return (
197
+ <div className="space-y-4">
198
+ <TabEmptyState
199
+ title={emptyState.title}
200
+ description={emptyState.description}
201
+ action={{
202
+ label: t('sales.returns.create', 'Create return'),
203
+ icon: <Plus className="mr-2 h-4 w-4" aria-hidden />,
204
+ onClick: () => setDialogOpen(true),
205
+ }}
206
+ />
207
+ <ReturnDialog
208
+ open={dialogOpen}
209
+ orderId={orderId}
210
+ lines={lines}
211
+ onClose={() => setDialogOpen(false)}
212
+ onSaved={async () => {
213
+ emitSalesDocumentTotalsRefresh({ documentId: orderId, kind: 'order' })
214
+ await loadReturns()
215
+ }}
216
+ />
217
+ </div>
218
+ )
219
+ }
220
+
221
+ return (
222
+ <div className="space-y-4">
223
+ <div className="flex items-center justify-end">
224
+ <Button type="button" onClick={() => setDialogOpen(true)}>
225
+ <Plus className="mr-2 h-4 w-4" aria-hidden />
226
+ {t('sales.returns.create', 'Create return')}
227
+ </Button>
228
+ </div>
229
+
230
+ <div className="overflow-hidden rounded-md border">
231
+ <div className="grid grid-cols-[1fr_auto_auto] gap-3 border-b bg-muted/30 px-4 py-2 text-xs font-medium text-muted-foreground">
232
+ <div>{t('sales.returns.returnNumber', 'Return')}</div>
233
+ <div className="text-right">{t('sales.returns.returnedAt', 'Returned at')}</div>
234
+ <div className="text-right">{t('sales.returns.total', 'Total')}</div>
235
+ </div>
236
+ <div className="divide-y">
237
+ {rows.map((ret) => (
238
+ <div key={ret.id} className="grid grid-cols-[1fr_auto_auto] items-center gap-3 px-4 py-3">
239
+ <div className="min-w-0">
240
+ <div className="flex items-center gap-2">
241
+ <Undo2 className="h-4 w-4 text-muted-foreground" aria-hidden />
242
+ <div className="truncate text-sm font-medium">{ret.returnNumber}</div>
243
+ {ret.status ? <Badge variant="secondary">{ret.status}</Badge> : null}
244
+ </div>
245
+ </div>
246
+ <div className="whitespace-nowrap text-right text-sm text-muted-foreground">
247
+ {formatDisplayDate(ret.returnedAt) ?? t('sales.returns.notSet', 'Not set')}
248
+ </div>
249
+ <div className="whitespace-nowrap text-right text-sm font-medium">
250
+ {formatMoney(ret.total, currencyCode ?? null)}
251
+ </div>
252
+ </div>
253
+ ))}
254
+ </div>
255
+ </div>
256
+
257
+ <ReturnDialog
258
+ open={dialogOpen}
259
+ orderId={orderId}
260
+ lines={lines}
261
+ onClose={() => setDialogOpen(false)}
262
+ onSaved={async () => {
263
+ emitSalesDocumentTotalsRefresh({ documentId: orderId, kind: 'order' })
264
+ await loadReturns()
265
+ }}
266
+ />
267
+ </div>
268
+ )
269
+ }
270
+
@@ -9,13 +9,13 @@ import {
9
9
  Property,
10
10
  Unique,
11
11
  } from '@mikro-orm/core'
12
- import { DEFAULT_ORDER_NUMBER_FORMAT, DEFAULT_QUOTE_NUMBER_FORMAT } from '../lib/documentNumberTokens'
12
+ import { DEFAULT_ORDER_NUMBER_FORMAT, DEFAULT_QUOTE_NUMBER_FORMAT, type SalesDocumentNumberKind } from '../lib/documentNumberTokens'
13
13
  import type { ShipmentItemSnapshot } from '../lib/shipments/types'
14
14
  import type { SalesLineUomSnapshot } from '../lib/types'
15
15
 
16
16
  export type SalesDocumentKind = 'order' | 'quote' | 'invoice' | 'credit_memo'
17
17
  export type SalesLineKind = 'product' | 'service' | 'shipping' | 'discount' | 'adjustment'
18
- export const DEFAULT_SALES_ADJUSTMENT_KINDS = ['discount', 'tax', 'shipping', 'surcharge', 'custom'] as const
18
+ export const DEFAULT_SALES_ADJUSTMENT_KINDS = ['discount', 'tax', 'shipping', 'surcharge', 'return', 'custom'] as const
19
19
  export type SalesAdjustmentKind = (typeof DEFAULT_SALES_ADJUSTMENT_KINDS)[number] | string
20
20
 
21
21
  @Entity({ tableName: 'sales_channels' })
@@ -813,7 +813,7 @@ export class SalesDocumentSequence {
813
813
  tenantId!: string
814
814
 
815
815
  @Property({ name: 'document_kind', type: 'text' })
816
- documentKind!: SalesDocumentKind
816
+ documentKind!: SalesDocumentNumberKind
817
817
 
818
818
  @Property({ name: 'current_value', type: 'integer', default: 0 })
819
819
  currentValue: number = 0
@@ -1289,6 +1289,102 @@ export class SalesShipmentItem {
1289
1289
  metadata?: Record<string, unknown> | null
1290
1290
  }
1291
1291
 
1292
+ @Entity({ tableName: 'sales_returns' })
1293
+ @Index({ name: 'sales_returns_scope_idx', properties: ['order', 'organizationId', 'tenantId'] })
1294
+ @Index({ name: 'sales_returns_status_idx', properties: ['organizationId', 'tenantId', 'status'] })
1295
+ @Unique({ name: 'sales_returns_number_unique', properties: ['organizationId', 'tenantId', 'returnNumber'] })
1296
+ export class SalesReturn {
1297
+ [OptionalProps]?: 'createdAt' | 'updatedAt'
1298
+
1299
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
1300
+ id!: string
1301
+
1302
+ @ManyToOne(() => SalesOrder, { fieldName: 'order_id' })
1303
+ order!: SalesOrder
1304
+
1305
+ @Property({ name: 'organization_id', type: 'uuid' })
1306
+ organizationId!: string
1307
+
1308
+ @Property({ name: 'tenant_id', type: 'uuid' })
1309
+ tenantId!: string
1310
+
1311
+ @Property({ name: 'return_number', type: 'text' })
1312
+ returnNumber!: string
1313
+
1314
+ @Property({ name: 'status_entry_id', type: 'uuid', nullable: true })
1315
+ statusEntryId?: string | null
1316
+
1317
+ @Property({ name: 'status', type: 'text', nullable: true })
1318
+ status?: string | null
1319
+
1320
+ @Property({ name: 'reason', type: 'text', nullable: true })
1321
+ reason?: string | null
1322
+
1323
+ @Property({ name: 'notes', type: 'text', nullable: true })
1324
+ notes?: string | null
1325
+
1326
+ @Property({ name: 'returned_at', type: Date, nullable: true })
1327
+ returnedAt?: Date | null
1328
+
1329
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
1330
+ createdAt: Date = new Date()
1331
+
1332
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
1333
+ updatedAt: Date = new Date()
1334
+
1335
+ @Property({ name: 'deleted_at', type: Date, nullable: true })
1336
+ deletedAt?: Date | null
1337
+
1338
+ @OneToMany(() => SalesReturnLine, (line) => line.salesReturn)
1339
+ lines = new Collection<SalesReturnLine>(this)
1340
+ }
1341
+
1342
+ @Entity({ tableName: 'sales_return_lines' })
1343
+ @Index({ name: 'sales_return_lines_return_idx', properties: ['salesReturn', 'organizationId', 'tenantId'] })
1344
+ @Index({ name: 'sales_return_lines_order_line_idx', properties: ['orderLine', 'organizationId', 'tenantId'] })
1345
+ export class SalesReturnLine {
1346
+ [OptionalProps]?: 'createdAt' | 'updatedAt'
1347
+
1348
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
1349
+ id!: string
1350
+
1351
+ @ManyToOne(() => SalesReturn, { fieldName: 'return_id' })
1352
+ salesReturn!: SalesReturn
1353
+
1354
+ @ManyToOne(() => SalesOrderLine, { fieldName: 'order_line_id' })
1355
+ orderLine!: SalesOrderLine
1356
+
1357
+ @Property({ name: 'organization_id', type: 'uuid' })
1358
+ organizationId!: string
1359
+
1360
+ @Property({ name: 'tenant_id', type: 'uuid' })
1361
+ tenantId!: string
1362
+
1363
+ @Property({ name: 'quantity_returned', type: 'numeric', precision: 18, scale: 4, default: '0' })
1364
+ quantityReturned: string = '0'
1365
+
1366
+ @Property({ name: 'unit_price_net', type: 'numeric', precision: 18, scale: 4, default: '0' })
1367
+ unitPriceNet: string = '0'
1368
+
1369
+ @Property({ name: 'unit_price_gross', type: 'numeric', precision: 18, scale: 4, default: '0' })
1370
+ unitPriceGross: string = '0'
1371
+
1372
+ @Property({ name: 'total_net_amount', type: 'numeric', precision: 18, scale: 4, default: '0' })
1373
+ totalNetAmount: string = '0'
1374
+
1375
+ @Property({ name: 'total_gross_amount', type: 'numeric', precision: 18, scale: 4, default: '0' })
1376
+ totalGrossAmount: string = '0'
1377
+
1378
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
1379
+ createdAt: Date = new Date()
1380
+
1381
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
1382
+ updatedAt: Date = new Date()
1383
+
1384
+ @Property({ name: 'deleted_at', type: Date, nullable: true })
1385
+ deletedAt?: Date | null
1386
+ }
1387
+
1292
1388
  @Entity({ tableName: 'sales_invoices' })
1293
1389
  @Index({ name: 'sales_invoices_scope_idx', properties: ['order', 'organizationId', 'tenantId'] })
1294
1390
  @Index({ name: 'sales_invoices_status_idx', properties: ['organizationId', 'tenantId', 'status'] })
@@ -618,6 +618,21 @@ export const shipmentUpdateSchema = z
618
618
  })
619
619
  .merge(shipmentCreateSchema.partial())
620
620
 
621
+ export const returnCreateSchema = scoped.extend({
622
+ orderId: uuid(),
623
+ reason: z.string().trim().max(4000).optional(),
624
+ notes: z.string().trim().max(4000).optional(),
625
+ returnedAt: z.coerce.date().optional(),
626
+ lines: z
627
+ .array(
628
+ z.object({
629
+ orderLineId: uuid(),
630
+ quantity: decimal({ min: 0 }),
631
+ })
632
+ )
633
+ .min(1),
634
+ })
635
+
621
636
  export const invoiceCreateSchema = scoped.extend({
622
637
  orderId: uuid().optional(),
623
638
  invoiceNumber: z.string().trim().min(1).max(191),
@@ -815,6 +830,7 @@ export type QuoteAdjustmentCreateInput = z.infer<typeof quoteAdjustmentCreateSch
815
830
  export type QuoteAdjustmentUpdateInput = z.infer<typeof quoteAdjustmentUpdateSchema>
816
831
  export type ShipmentCreateInput = z.infer<typeof shipmentCreateSchema>
817
832
  export type ShipmentUpdateInput = z.infer<typeof shipmentUpdateSchema>
833
+ export type ReturnCreateInput = z.infer<typeof returnCreateSchema>
818
834
  export type InvoiceCreateInput = z.infer<typeof invoiceCreateSchema>
819
835
  export type InvoiceUpdateInput = z.infer<typeof invoiceUpdateSchema>
820
836
  export type CreditMemoCreateInput = z.infer<typeof creditMemoCreateSchema>
@@ -36,6 +36,11 @@ const events = [
36
36
  { id: 'sales.shipment.updated', label: 'Shipment Updated', entity: 'shipment', category: 'crud' },
37
37
  { id: 'sales.shipment.deleted', label: 'Shipment Deleted', entity: 'shipment', category: 'crud' },
38
38
 
39
+ // Returns
40
+ { id: 'sales.return.created', label: 'Return Created', entity: 'return', category: 'crud' },
41
+ { id: 'sales.return.updated', label: 'Return Updated', entity: 'return', category: 'crud' },
42
+ { id: 'sales.return.deleted', label: 'Return Deleted', entity: 'return', category: 'crud' },
43
+
39
44
  // Notes
40
45
  { id: 'sales.note.created', label: 'Note Created', entity: 'note', category: 'crud' },
41
46
  { id: 'sales.note.updated', label: 'Note Updated', entity: 'note', category: 'crud' },