@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.
- package/dist/generated/entities/sales_return/index.js +31 -0
- package/dist/generated/entities/sales_return/index.js.map +7 -0
- package/dist/generated/entities/sales_return_line/index.js +29 -0
- package/dist/generated/entities/sales_return_line/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +2 -0
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +4 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/sales/acl.js +2 -0
- package/dist/modules/sales/acl.js.map +2 -2
- package/dist/modules/sales/api/document-history/route.js +20 -2
- package/dist/modules/sales/api/document-history/route.js.map +2 -2
- package/dist/modules/sales/api/documents/factory.js +34 -0
- package/dist/modules/sales/api/documents/factory.js.map +2 -2
- package/dist/modules/sales/api/returns/[id]/route.js +147 -0
- package/dist/modules/sales/api/returns/[id]/route.js.map +7 -0
- package/dist/modules/sales/api/returns/route.js +158 -0
- package/dist/modules/sales/api/returns/route.js.map +7 -0
- package/dist/modules/sales/backend/sales/documents/[id]/page.js +20 -1
- package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
- package/dist/modules/sales/commands/index.js +1 -0
- package/dist/modules/sales/commands/index.js.map +2 -2
- package/dist/modules/sales/commands/returns.js +467 -0
- package/dist/modules/sales/commands/returns.js.map +7 -0
- package/dist/modules/sales/components/documents/ReturnDialog.js +176 -0
- package/dist/modules/sales/components/documents/ReturnDialog.js.map +7 -0
- package/dist/modules/sales/components/documents/ReturnsSection.js +188 -0
- package/dist/modules/sales/components/documents/ReturnsSection.js.map +7 -0
- package/dist/modules/sales/data/entities.js +115 -1
- package/dist/modules/sales/data/entities.js.map +2 -2
- package/dist/modules/sales/data/validators.js +13 -0
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/sales/events.js +4 -0
- package/dist/modules/sales/events.js.map +2 -2
- package/dist/modules/sales/lib/calculations.js +7 -0
- package/dist/modules/sales/lib/calculations.js.map +2 -2
- package/dist/modules/sales/lib/dictionaries.js +1 -0
- package/dist/modules/sales/lib/dictionaries.js.map +2 -2
- package/dist/modules/sales/lib/documentNumberTokens.js +2 -0
- package/dist/modules/sales/lib/documentNumberTokens.js.map +2 -2
- package/dist/modules/sales/lib/historyHelpers.js +15 -7
- package/dist/modules/sales/lib/historyHelpers.js.map +2 -2
- package/dist/modules/sales/lib/makeSalesLineRoute.js +42 -37
- package/dist/modules/sales/lib/makeSalesLineRoute.js.map +2 -2
- package/dist/modules/sales/migrations/Migration20260309073310.js +23 -0
- package/dist/modules/sales/migrations/Migration20260309073310.js.map +7 -0
- package/dist/modules/sales/services/salesDocumentNumberGenerator.js +8 -6
- package/dist/modules/sales/services/salesDocumentNumberGenerator.js.map +2 -2
- package/dist/modules/sales/setup.js +1 -1
- package/dist/modules/sales/setup.js.map +2 -2
- package/dist/modules/sales/widgets/injection/document-history/widget.client.js +25 -16
- package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
- package/generated/entities/sales_return/index.ts +14 -0
- package/generated/entities/sales_return_line/index.ts +13 -0
- package/generated/entities.ids.generated.ts +2 -0
- package/generated/entity-fields-registry.ts +4 -0
- package/package.json +3 -3
- package/src/modules/sales/AGENTS.md +1 -0
- package/src/modules/sales/acl.ts +2 -0
- package/src/modules/sales/api/document-history/route.ts +25 -1
- package/src/modules/sales/api/documents/factory.ts +35 -0
- package/src/modules/sales/api/returns/[id]/route.ts +156 -0
- package/src/modules/sales/api/returns/route.ts +171 -0
- package/src/modules/sales/backend/sales/documents/[id]/page.tsx +18 -0
- package/src/modules/sales/commands/index.ts +1 -0
- package/src/modules/sales/commands/returns.ts +540 -0
- package/src/modules/sales/components/documents/ReturnDialog.tsx +216 -0
- package/src/modules/sales/components/documents/ReturnsSection.tsx +270 -0
- package/src/modules/sales/data/entities.ts +99 -3
- package/src/modules/sales/data/validators.ts +16 -0
- package/src/modules/sales/events.ts +5 -0
- package/src/modules/sales/i18n/de.json +32 -0
- package/src/modules/sales/i18n/en.json +32 -0
- package/src/modules/sales/i18n/es.json +32 -0
- package/src/modules/sales/i18n/pl.json +32 -0
- package/src/modules/sales/lib/calculations.ts +9 -0
- package/src/modules/sales/lib/dictionaries.ts +1 -0
- package/src/modules/sales/lib/documentNumberTokens.ts +2 -1
- package/src/modules/sales/lib/historyHelpers.ts +20 -9
- package/src/modules/sales/lib/makeSalesLineRoute.ts +42 -37
- package/src/modules/sales/migrations/.snapshot-open-mercato.json +398 -0
- package/src/modules/sales/migrations/Migration20260309073310.ts +26 -0
- package/src/modules/sales/services/salesDocumentNumberGenerator.ts +15 -4
- package/src/modules/sales/setup.ts +1 -1
- 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!:
|
|
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' },
|