@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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/sales/widgets/injection/document-history/widget.client.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from \"react\"\nimport { Spinner } from \"@open-mercato/ui/primitives/spinner\"\nimport { useT } from \"@open-mercato/shared/lib/i18n/context\"\nimport { apiCall } from \"@open-mercato/ui/backend/utils/apiCall\"\nimport { formatRelativeTime, formatDateTime } from \"@open-mercato/shared/lib/time\"\nimport { cn } from \"@open-mercato/shared/lib/utils\"\nimport type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'\nimport { ArrowRightLeft, Zap, MessageSquare, User, Filter, ChevronDown, Check } from 'lucide-react'\n\nexport type TimelineEntry = {\n id: string\n occurredAt: string\n kind: \"status\" | \"action\" | \"comment\"\n action: string\n actor: { id: string | null; label: string }\n source: \"action_log\" | \"note\"\n metadata?: {\n statusFrom?: string | null\n statusTo?: string | null\n documentKind?: \"order\" | \"quote\"\n commandId?: string\n }\n}\n\ntype StatusOption = {\n value: string\n label: string\n color: string | null\n icon: string | null\n}\n\ntype TimelineContext = {\n kind: \"order\" | \"quote\"\n record: { id: string }\n}\n\nconst isValidContext = (ctx: unknown): ctx is TimelineContext =>\n ctx !== null &&\n typeof ctx === 'object' &&\n 'kind' in ctx &&\n 'record' in ctx &&\n ((ctx as TimelineContext).kind === 'order' || (ctx as TimelineContext).kind === 'quote') &&\n typeof (ctx as TimelineContext).record === 'object' &&\n (ctx as TimelineContext).record !== null &&\n 'id' in (ctx as TimelineContext).record &&\n typeof (ctx as TimelineContext).record.id === 'string'\n\nconst KIND_ICONS = {\n status: ArrowRightLeft,\n action: Zap,\n comment: MessageSquare,\n}\n\nconst KIND_ICON_COLORS = {\n status: 'text-foreground',\n action: 'text-foreground',\n comment: 'text-foreground',\n}\n\nconst KIND_BG_COLORS = {\n status: 'bg-muted',\n action: 'bg-muted',\n comment: 'bg-muted',\n}\n\nfunction StatusDot({ color, className }: { color: string | null | undefined; className?: string }) {\n if (!color) return <span className={cn('h-2.5 w-2.5 rounded-full bg-muted-foreground/40 border border-border inline-flex', className)} />\n return (\n <span\n className={cn('h-2.5 w-2.5 rounded-full border border-border/60 inline-flex', className)}\n style={{ backgroundColor: color }}\n aria-hidden\n />\n )\n}\n\nfunction StatusTransition({\n statusFrom,\n statusTo,\n statusMap,\n}: {\n statusFrom: string | null | undefined\n statusTo: string | null | undefined\n statusMap: Record<string, StatusOption>\n}) {\n const from = statusFrom ? (statusMap[statusFrom] ?? { value: statusFrom, label: statusFrom, color: null, icon: null }) : null\n const to = statusTo ? (statusMap[statusTo] ?? { value: statusTo, label: statusTo, color: null, icon: null }) : null\n\n return (\n <div className=\"flex items-center gap-1.5 flex-wrap\">\n {from ? (\n <span className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\">\n <StatusDot color={from.color} />\n <span>{from.label}</span>\n </span>\n ) : null}\n {from && to ? (\n <ArrowRightLeft className=\"h-3 w-3 text-muted-foreground/60 shrink-0\" />\n ) : null}\n {to ? (\n <span className=\"inline-flex items-center gap-1 text-xs font-medium text-foreground\">\n <StatusDot color={to.color} />\n <span>{to.label}</span>\n </span>\n ) : null}\n </div>\n )\n}\n\nfunction TimelineItem({\n entry,\n statusMap,\n isLast,\n}: {\n entry: TimelineEntry\n statusMap: Record<string, StatusOption>\n isLast: boolean\n}) {\n const KindIcon = KIND_ICONS[entry.kind]\n const relativeTime = formatRelativeTime(entry.occurredAt)\n const absoluteTime = formatDateTime(entry.occurredAt)\n\n const isStatusChange = entry.kind === 'status' && entry.metadata?.statusTo\n\n return (\n <div data-testid=\"timeline-entry\" className=\"relative flex gap-3\">\n {/* Vertical connector line */}\n {!isLast && (\n <div className=\"absolute left-[11px] top-6 bottom-0 w-px bg-border\" aria-hidden />\n )}\n\n {/* Icon circle */}\n <div\n className={cn(\n 'relative z-10 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border',\n KIND_BG_COLORS[entry.kind],\n )}\n >\n <KindIcon className={cn('h-3 w-3', KIND_ICON_COLORS[entry.kind])} aria-hidden />\n </div>\n\n {/* Content card */}\n <div className=\"flex-1 pb-4\">\n <div className=\"group rounded-lg border bg-card p-3 space-y-1.5\">\n {/* Header: actor + time */}\n <div className=\"flex items-center justify-between gap-2 flex-wrap\">\n <span className=\"inline-flex items-center gap-1.5 text-xs font-medium text-foreground\">\n <User className=\"h-3 w-3 text-muted-foreground\" aria-hidden />\n {entry.actor.label}\n </span>\n <span\n className=\"text-xs text-muted-foreground\"\n title={absoluteTime ?? undefined}\n >\n {relativeTime ?? absoluteTime}\n </span>\n </div>\n\n {/* Body */}\n {isStatusChange ? (\n <StatusTransition\n statusFrom={entry.metadata?.statusFrom}\n statusTo={entry.metadata?.statusTo}\n statusMap={statusMap}\n />\n ) : (\n <div className=\"text-sm text-foreground\">{entry.action}</div>\n )}\n </div>\n </div>\n </div>\n )\n}\n\ntype FilterKind = 'all' | 'status' | 'action' | 'comment'\n\ntype FilterOption = { value: FilterKind; label: string }\n\nfunction FilterDropdown({ filter, onChange }: { filter: FilterKind; onChange: (kind: FilterKind) => void }) {\n const t = useT()\n const [open, setOpen] = React.useState(false)\n const ref = React.useRef<HTMLDivElement>(null)\n\n React.useEffect(() => {\n if (!open) return\n function onDocClick(e: MouseEvent) {\n if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)\n }\n function onKey(e: KeyboardEvent) {\n if (e.key === 'Escape') setOpen(false)\n }\n document.addEventListener('mousedown', onDocClick)\n document.addEventListener('keydown', onKey)\n return () => {\n document.removeEventListener('mousedown', onDocClick)\n document.removeEventListener('keydown', onKey)\n }\n }, [open])\n\n const options: FilterOption[] = [\n { value: 'all', label: t('sales.documents.history.filter.all', 'All') },\n { value: 'status', label: t('sales.documents.history.filter.status', 'Status changes') },\n { value: 'action', label: t('sales.documents.history.filter.actions', 'Actions') },\n { value: 'comment', label: t('sales.documents.history.filter.comments', 'Comments') },\n ]\n\n const activeLabel = options.find(o => o.value === filter)?.label\n\n return (\n <div className=\"relative\" ref={ref}>\n <button\n type=\"button\"\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n onClick={() => setOpen(prev => !prev)}\n className=\"inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1 text-xs font-medium shadow-sm hover:bg-accent hover:text-accent-foreground transition-colors select-none\"\n >\n <Filter className=\"h-3 w-3\" aria-hidden />\n {t('sales.documents.history.filter.label', 'Filters')}\n {filter !== 'all' && (\n <span className=\"text-muted-foreground\">: {activeLabel}</span>\n )}\n <ChevronDown className={cn('h-3 w-3 transition-transform duration-150', open && 'rotate-180')} aria-hidden />\n </button>\n {open && (\n <div\n role=\"listbox\"\n aria-label={t('sales.documents.history.filter.label', 'Filters')}\n className=\"absolute left-0 top-full mt-1 z-50 w-48 rounded-md border bg-background p-1 shadow-md\"\n >\n {options.map(opt => (\n <button\n key={opt.value}\n type=\"button\"\n role=\"option\"\n aria-selected={filter === opt.value}\n onClick={() => { onChange(opt.value); setOpen(false) }}\n className=\"flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent\"\n >\n <Check className={cn('h-3.5 w-3.5 shrink-0', filter === opt.value ? 'opacity-100' : 'opacity-0')} aria-hidden />\n {opt.label}\n </button>\n ))}\n </div>\n )}\n </div>\n )\n}\n\nexport const DocumentHistoryWidget: React.FC<InjectionWidgetComponentProps<unknown, unknown>> = ({ context }) => {\n const t = useT()\n const [entries, setEntries] = React.useState<TimelineEntry[]>([])\n const [loading, setLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n const [statusMap, setStatusMap] = React.useState<Record<string, StatusOption>>({})\n const [filter, setFilter] = React.useState<FilterKind>('all')\n\n React.useEffect(() => {\n apiCall<{ items?: unknown[] }>('/api/sales/order-statuses?pageSize=100')\n .then((res) => {\n if (res.ok && Array.isArray(res.result?.items)) {\n const map: Record<string, StatusOption> = {}\n for (const item of res.result.items) {\n if (!item || typeof item !== 'object') continue\n const d = item as Record<string, unknown>\n const value = typeof d.value === 'string' ? d.value : null\n if (!value) continue\n map[value] = {\n value,\n label: typeof d.label === 'string' && d.label.length ? d.label : value,\n color: typeof d.color === 'string' && d.color.length ? d.color : null,\n icon: typeof d.icon === 'string' && d.icon.length ? d.icon : null,\n }\n }\n setStatusMap(map)\n }\n })\n .catch(() => {})\n }, [])\n\n React.useEffect(() => {\n if (!isValidContext(context)) {\n setLoading(false)\n setError(t(\"sales.documents.history.error\", \"Failed to load history.\"))\n return\n }\n\n setLoading(true)\n setError(null)\n apiCall<{ items: TimelineEntry[] }>(\n `/api/sales/document-history?kind=${context.kind}&id=${context.record.id}`\n )\n .then((res) => {\n if (res.ok && Array.isArray(res.result?.items)) {\n setEntries(res.result.items)\n } else {\n setError(t(\"sales.documents.history.error\", \"Failed to load history.\"))\n }\n })\n .catch(() => setError(t(\"sales.documents.history.error\", \"Failed to load history.\")))\n .finally(() => setLoading(false))\n }, [context, t])\n\n const filtered = React.useMemo(\n () => filter === 'all' ? entries : entries.filter(e => e.kind === filter),\n [entries, filter]\n )\n\n return (\n <div className=\"space-y-4\">\n {/* Filter dropdown */}\n <div>\n <FilterDropdown filter={filter} onChange={setFilter} />\n </div>\n\n {/* Content */}\n {loading ? (\n <div className=\"flex items-center justify-center h-24\">\n <Spinner />\n </div>\n ) : error ? (\n <div className=\"text-destructive text-sm\">{error}</div>\n ) : !filtered.length ? (\n <div className=\"text-muted-foreground text-sm py-6 text-center\">\n {t(\"sales.documents.history.empty\", \"No history entries yet.\")}\n </div>\n ) : (\n <div className=\"relative\">\n {filtered.map((entry, index) => (\n <TimelineItem\n key={entry.id}\n entry={entry}\n statusMap={statusMap}\n isLast={index === filtered.length - 1}\n />\n ))}\n </div>\n )}\n </div>\n )\n}\n\nexport default DocumentHistoryWidget\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from \"react\"\nimport { Spinner } from \"@open-mercato/ui/primitives/spinner\"\nimport { useT } from \"@open-mercato/shared/lib/i18n/context\"\nimport { apiCall } from \"@open-mercato/ui/backend/utils/apiCall\"\nimport { formatRelativeTime, formatDateTime } from \"@open-mercato/shared/lib/time\"\nimport { cn } from \"@open-mercato/shared/lib/utils\"\nimport type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'\nimport { ArrowRightLeft, Zap, MessageSquare, User, Filter, ChevronDown, Check } from 'lucide-react'\n\nexport type TimelineEntry = {\n id: string\n occurredAt: string\n kind: \"status\" | \"action\" | \"comment\"\n action: string\n actor: { id: string | null; label: string }\n source: \"action_log\" | \"note\"\n metadata?: {\n statusFrom?: string | null\n statusTo?: string | null\n documentKind?: \"order\" | \"quote\"\n commandId?: string\n }\n}\n\ntype StatusOption = {\n value: string\n label: string\n color: string | null\n icon: string | null\n}\n\ntype TimelineContext = {\n kind: \"order\" | \"quote\"\n record: { id: string }\n}\n\nconst isValidContext = (ctx: unknown): ctx is TimelineContext =>\n ctx !== null &&\n typeof ctx === 'object' &&\n 'kind' in ctx &&\n 'record' in ctx &&\n ((ctx as TimelineContext).kind === 'order' || (ctx as TimelineContext).kind === 'quote') &&\n typeof (ctx as TimelineContext).record === 'object' &&\n (ctx as TimelineContext).record !== null &&\n 'id' in (ctx as TimelineContext).record &&\n typeof (ctx as TimelineContext).record.id === 'string'\n\nconst KIND_ICONS = {\n status: ArrowRightLeft,\n action: Zap,\n comment: MessageSquare,\n}\n\nconst KIND_ICON_COLORS = {\n status: 'text-foreground',\n action: 'text-foreground',\n comment: 'text-foreground',\n}\n\nconst KIND_BG_COLORS = {\n status: 'bg-muted',\n action: 'bg-muted',\n comment: 'bg-muted',\n}\n\nfunction StatusDot({ color, className }: { color: string | null | undefined; className?: string }) {\n if (!color) return <span className={cn('h-2.5 w-2.5 rounded-full bg-muted-foreground/40 border border-border inline-flex', className)} />\n return (\n <span\n className={cn('h-2.5 w-2.5 rounded-full border border-border/60 inline-flex', className)}\n style={{ backgroundColor: color }}\n aria-hidden\n />\n )\n}\n\nfunction StatusTransition({\n statusFrom,\n statusTo,\n statusMap,\n}: {\n statusFrom: string | null | undefined\n statusTo: string | null | undefined\n statusMap: Record<string, StatusOption>\n}) {\n const from = statusFrom ? (statusMap[statusFrom] ?? { value: statusFrom, label: statusFrom, color: null, icon: null }) : null\n const to = statusTo ? (statusMap[statusTo] ?? { value: statusTo, label: statusTo, color: null, icon: null }) : null\n\n return (\n <div className=\"flex items-center gap-1.5 flex-wrap\">\n {from ? (\n <span className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\">\n <StatusDot color={from.color} />\n <span>{from.label}</span>\n </span>\n ) : null}\n {from && to ? (\n <ArrowRightLeft className=\"h-3 w-3 text-muted-foreground/60 shrink-0\" />\n ) : null}\n {to ? (\n <span className=\"inline-flex items-center gap-1 text-xs font-medium text-foreground\">\n <StatusDot color={to.color} />\n <span>{to.label}</span>\n </span>\n ) : null}\n </div>\n )\n}\n\nfunction TimelineItem({\n entry,\n statusMap,\n isLast,\n}: {\n entry: TimelineEntry\n statusMap: Record<string, StatusOption>\n isLast: boolean\n}) {\n const KindIcon = KIND_ICONS[entry.kind]\n const relativeTime = formatRelativeTime(entry.occurredAt)\n const absoluteTime = formatDateTime(entry.occurredAt)\n\n const isStatusChange = entry.kind === 'status' && entry.metadata?.statusTo\n\n return (\n <div data-testid=\"timeline-entry\" className=\"relative flex gap-3\">\n {/* Vertical connector line */}\n {!isLast && (\n <div className=\"absolute left-[11px] top-6 bottom-0 w-px bg-border\" aria-hidden />\n )}\n\n {/* Icon circle */}\n <div\n className={cn(\n 'relative z-10 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border',\n KIND_BG_COLORS[entry.kind],\n )}\n >\n <KindIcon className={cn('h-3 w-3', KIND_ICON_COLORS[entry.kind])} aria-hidden />\n </div>\n\n {/* Content card */}\n <div className=\"flex-1 pb-4\">\n <div className=\"group rounded-lg border bg-card p-3 space-y-1.5\">\n {/* Header: actor + time */}\n <div className=\"flex items-center justify-between gap-2 flex-wrap\">\n <span className=\"inline-flex items-center gap-1.5 text-xs font-medium text-foreground\">\n <User className=\"h-3 w-3 text-muted-foreground\" aria-hidden />\n {entry.actor.label}\n </span>\n <span\n className=\"text-xs text-muted-foreground\"\n title={absoluteTime ?? undefined}\n >\n {relativeTime ?? absoluteTime}\n </span>\n </div>\n\n {/* Body */}\n {isStatusChange ? (\n <StatusTransition\n statusFrom={entry.metadata?.statusFrom}\n statusTo={entry.metadata?.statusTo}\n statusMap={statusMap}\n />\n ) : (\n <div className=\"text-sm text-foreground\">{entry.action}</div>\n )}\n </div>\n </div>\n </div>\n )\n}\n\ntype FilterKind = 'all' | 'status' | 'action' | 'comment'\n\ntype FilterOption = { value: FilterKind; label: string }\n\nfunction FilterDropdown({ filter, onChange }: { filter: FilterKind; onChange: (kind: FilterKind) => void }) {\n const t = useT()\n const [open, setOpen] = React.useState(false)\n const ref = React.useRef<HTMLDivElement>(null)\n\n React.useEffect(() => {\n if (!open) return\n function onDocClick(e: MouseEvent) {\n if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)\n }\n function onKey(e: KeyboardEvent) {\n if (e.key === 'Escape') setOpen(false)\n }\n document.addEventListener('mousedown', onDocClick)\n document.addEventListener('keydown', onKey)\n return () => {\n document.removeEventListener('mousedown', onDocClick)\n document.removeEventListener('keydown', onKey)\n }\n }, [open])\n\n const options: FilterOption[] = [\n { value: 'all', label: t('sales.documents.history.filter.all', 'All') },\n { value: 'status', label: t('sales.documents.history.filter.status', 'Status changes') },\n { value: 'action', label: t('sales.documents.history.filter.actions', 'Actions') },\n { value: 'comment', label: t('sales.documents.history.filter.comments', 'Comments') },\n ]\n\n const activeLabel = options.find(o => o.value === filter)?.label\n\n return (\n <div className=\"relative\" ref={ref}>\n <button\n type=\"button\"\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n onClick={() => setOpen(prev => !prev)}\n className=\"inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1 text-xs font-medium shadow-sm hover:bg-accent hover:text-accent-foreground transition-colors select-none\"\n >\n <Filter className=\"h-3 w-3\" aria-hidden />\n {t('sales.documents.history.filter.label', 'Filters')}\n {filter !== 'all' && (\n <span className=\"text-muted-foreground\">: {activeLabel}</span>\n )}\n <ChevronDown className={cn('h-3 w-3 transition-transform duration-150', open && 'rotate-180')} aria-hidden />\n </button>\n {open && (\n <div\n role=\"listbox\"\n aria-label={t('sales.documents.history.filter.label', 'Filters')}\n className=\"absolute left-0 top-full mt-1 z-50 w-48 rounded-md border bg-background p-1 shadow-md\"\n >\n {options.map(opt => (\n <button\n key={opt.value}\n type=\"button\"\n role=\"option\"\n aria-selected={filter === opt.value}\n onClick={() => { onChange(opt.value); setOpen(false) }}\n className=\"flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent\"\n >\n <Check className={cn('h-3.5 w-3.5 shrink-0', filter === opt.value ? 'opacity-100' : 'opacity-0')} aria-hidden />\n {opt.label}\n </button>\n ))}\n </div>\n )}\n </div>\n )\n}\n\nexport const DocumentHistoryWidget: React.FC<InjectionWidgetComponentProps<unknown, unknown>> = ({ context }) => {\n const t = useT()\n const [entries, setEntries] = React.useState<TimelineEntry[]>([])\n const [loading, setLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n const [statusMap, setStatusMap] = React.useState<Record<string, StatusOption>>({})\n const [filter, setFilter] = React.useState<FilterKind>('all')\n\n React.useEffect(() => {\n const urls = [\n '/api/sales/order-statuses?pageSize=100',\n '/api/sales/shipment-statuses?pageSize=100',\n '/api/sales/payment-statuses?pageSize=100',\n ]\n const map: Record<string, StatusOption> = {}\n const merge = (items: unknown[]) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n if (!item || typeof item !== 'object') continue\n const d = item as Record<string, unknown>\n const value = typeof d.value === 'string' ? d.value : null\n if (!value) continue\n map[value] = {\n value,\n label: typeof d.label === 'string' && d.label.length ? d.label : value,\n color: typeof d.color === 'string' && d.color.length ? d.color : null,\n icon: typeof d.icon === 'string' && d.icon.length ? d.icon : null,\n }\n }\n }\n Promise.all(urls.map((url) => apiCall<{ items?: unknown[] }>(url)))\n .then((responses) => {\n for (const res of responses) {\n if (res.ok && Array.isArray(res.result?.items)) merge(res.result.items)\n }\n setStatusMap(map)\n })\n .catch(() => {})\n }, [])\n\n React.useEffect(() => {\n if (!isValidContext(context)) {\n setLoading(false)\n setError(t(\"sales.documents.history.error\", \"Failed to load history.\"))\n return\n }\n\n setLoading(true)\n setError(null)\n apiCall<{ items: TimelineEntry[] }>(\n `/api/sales/document-history?kind=${context.kind}&id=${context.record.id}`\n )\n .then((res) => {\n if (res.ok && Array.isArray(res.result?.items)) {\n setEntries(res.result.items)\n } else {\n setError(t(\"sales.documents.history.error\", \"Failed to load history.\"))\n }\n })\n .catch(() => setError(t(\"sales.documents.history.error\", \"Failed to load history.\")))\n .finally(() => setLoading(false))\n }, [context, t])\n\n const filtered = React.useMemo(\n () => filter === 'all' ? entries : entries.filter(e => e.kind === filter),\n [entries, filter]\n )\n\n return (\n <div className=\"space-y-4\">\n {/* Filter dropdown */}\n <div>\n <FilterDropdown filter={filter} onChange={setFilter} />\n </div>\n\n {/* Content */}\n {loading ? (\n <div className=\"flex items-center justify-center h-24\">\n <Spinner />\n </div>\n ) : error ? (\n <div className=\"text-destructive text-sm\">{error}</div>\n ) : !filtered.length ? (\n <div className=\"text-muted-foreground text-sm py-6 text-center\">\n {t(\"sales.documents.history.empty\", \"No history entries yet.\")}\n </div>\n ) : (\n <div className=\"relative\">\n {filtered.map((entry, index) => (\n <TimelineItem\n key={entry.id}\n entry={entry}\n statusMap={statusMap}\n isLast={index === filtered.length - 1}\n />\n ))}\n </div>\n )}\n </div>\n )\n}\n\nexport default DocumentHistoryWidget\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const id = 'id'
|
|
2
|
+
export const order = 'order'
|
|
3
|
+
export const organization_id = 'organization_id'
|
|
4
|
+
export const tenant_id = 'tenant_id'
|
|
5
|
+
export const return_number = 'return_number'
|
|
6
|
+
export const status_entry_id = 'status_entry_id'
|
|
7
|
+
export const status = 'status'
|
|
8
|
+
export const reason = 'reason'
|
|
9
|
+
export const notes = 'notes'
|
|
10
|
+
export const returned_at = 'returned_at'
|
|
11
|
+
export const created_at = 'created_at'
|
|
12
|
+
export const updated_at = 'updated_at'
|
|
13
|
+
export const deleted_at = 'deleted_at'
|
|
14
|
+
export const lines = 'lines'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const id = 'id'
|
|
2
|
+
export const sales_return = 'sales_return'
|
|
3
|
+
export const order_line = 'order_line'
|
|
4
|
+
export const organization_id = 'organization_id'
|
|
5
|
+
export const tenant_id = 'tenant_id'
|
|
6
|
+
export const quantity_returned = 'quantity_returned'
|
|
7
|
+
export const unit_price_net = 'unit_price_net'
|
|
8
|
+
export const unit_price_gross = 'unit_price_gross'
|
|
9
|
+
export const total_net_amount = 'total_net_amount'
|
|
10
|
+
export const total_gross_amount = 'total_gross_amount'
|
|
11
|
+
export const created_at = 'created_at'
|
|
12
|
+
export const updated_at = 'updated_at'
|
|
13
|
+
export const deleted_at = 'deleted_at'
|
|
@@ -135,6 +135,8 @@ export const E = {
|
|
|
135
135
|
"sales_quote_adjustment": "sales:sales_quote_adjustment",
|
|
136
136
|
"sales_shipment": "sales:sales_shipment",
|
|
137
137
|
"sales_shipment_item": "sales:sales_shipment_item",
|
|
138
|
+
"sales_return": "sales:sales_return",
|
|
139
|
+
"sales_return_line": "sales:sales_return_line",
|
|
138
140
|
"sales_invoice": "sales:sales_invoice",
|
|
139
141
|
"sales_invoice_line": "sales:sales_invoice_line",
|
|
140
142
|
"sales_credit_memo": "sales:sales_credit_memo",
|
|
@@ -121,6 +121,8 @@ import * as sales_payment_method from './entities/sales_payment_method/index'
|
|
|
121
121
|
import * as sales_quote from './entities/sales_quote/index'
|
|
122
122
|
import * as sales_quote_adjustment from './entities/sales_quote_adjustment/index'
|
|
123
123
|
import * as sales_quote_line from './entities/sales_quote_line/index'
|
|
124
|
+
import * as sales_return from './entities/sales_return/index'
|
|
125
|
+
import * as sales_return_line from './entities/sales_return_line/index'
|
|
124
126
|
import * as sales_settings from './entities/sales_settings/index'
|
|
125
127
|
import * as sales_shipment from './entities/sales_shipment/index'
|
|
126
128
|
import * as sales_shipment_item from './entities/sales_shipment_item/index'
|
|
@@ -277,6 +279,8 @@ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
|
|
|
277
279
|
sales_quote,
|
|
278
280
|
sales_quote_adjustment,
|
|
279
281
|
sales_quote_line,
|
|
282
|
+
sales_return,
|
|
283
|
+
sales_return_line,
|
|
280
284
|
sales_settings,
|
|
281
285
|
sales_shipment,
|
|
282
286
|
sales_shipment_item,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.4.8-develop-
|
|
3
|
+
"version": "0.4.8-develop-4b58cde65d",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -217,10 +217,10 @@
|
|
|
217
217
|
"semver": "^7.6.3"
|
|
218
218
|
},
|
|
219
219
|
"peerDependencies": {
|
|
220
|
-
"@open-mercato/shared": "0.4.8-develop-
|
|
220
|
+
"@open-mercato/shared": "0.4.8-develop-4b58cde65d"
|
|
221
221
|
},
|
|
222
222
|
"devDependencies": {
|
|
223
|
-
"@open-mercato/shared": "0.4.8-develop-
|
|
223
|
+
"@open-mercato/shared": "0.4.8-develop-4b58cde65d",
|
|
224
224
|
"@testing-library/dom": "^10.4.1",
|
|
225
225
|
"@testing-library/jest-dom": "^6.9.1",
|
|
226
226
|
"@testing-library/react": "^16.3.1",
|
|
@@ -45,6 +45,7 @@ const calcService = container.resolve('salesCalculationService')
|
|
|
45
45
|
### Fulfillment
|
|
46
46
|
- **Shipments** — delivery tracking. MUST follow status workflow
|
|
47
47
|
- **Payments** — payment recording. MUST follow status workflow
|
|
48
|
+
- **Returns** — order returns with line selection; create return generates line-level adjustments (kind `return`, negative amounts), updates `returned_quantity`, recalculates order totals. Use `sales.return.create` command; list via `GET /api/sales/returns?orderId=...`
|
|
48
49
|
|
|
49
50
|
### Configuration — MUST NOT Modify Directly
|
|
50
51
|
- **Channels** — sales channels (web, POS). Configure via admin UI
|
package/src/modules/sales/acl.ts
CHANGED
|
@@ -9,6 +9,8 @@ export const features = [
|
|
|
9
9
|
{ id: 'sales.documents.number.edit', title: 'Edit sales document numbers', module: 'sales' },
|
|
10
10
|
{ id: 'sales.shipments.manage', title: 'Manage order shipments', module: 'sales' },
|
|
11
11
|
{ id: 'sales.payments.manage', title: 'Manage order payments', module: 'sales' },
|
|
12
|
+
{ id: 'sales.returns.view', title: 'View order returns', module: 'sales' },
|
|
13
|
+
{ id: 'sales.returns.create', title: 'Create order returns', module: 'sales' },
|
|
12
14
|
{ id: 'sales.invoices.manage', title: 'Manage sales invoices', module: 'sales' },
|
|
13
15
|
{ id: 'sales.credit_memos.manage', title: 'Manage credit memos', module: 'sales' },
|
|
14
16
|
{ id: 'sales.channels.manage', title: 'Manage sales channels', module: 'sales' },
|
|
@@ -29,6 +29,26 @@ const querySchema = z.object({
|
|
|
29
29
|
types: z.string().optional(), // comma-separated: status,action,comment
|
|
30
30
|
})
|
|
31
31
|
|
|
32
|
+
export type DocumentHistoryEntryKind = 'status' | 'action' | 'comment'
|
|
33
|
+
|
|
34
|
+
const HISTORY_TYPES: ReadonlySet<DocumentHistoryEntryKind> = new Set(['status', 'action', 'comment'])
|
|
35
|
+
|
|
36
|
+
export function parseDocumentHistoryTypes(input: string | null | undefined): Set<DocumentHistoryEntryKind> {
|
|
37
|
+
const raw = typeof input === 'string' ? input.trim() : ''
|
|
38
|
+
if (!raw) return new Set()
|
|
39
|
+
const values = raw
|
|
40
|
+
.split(',')
|
|
41
|
+
.map((part) => part.trim().toLowerCase())
|
|
42
|
+
.filter((value) => value.length > 0)
|
|
43
|
+
const result = new Set<DocumentHistoryEntryKind>()
|
|
44
|
+
values.forEach((value) => {
|
|
45
|
+
if (HISTORY_TYPES.has(value as DocumentHistoryEntryKind)) {
|
|
46
|
+
result.add(value as DocumentHistoryEntryKind)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
return result
|
|
50
|
+
}
|
|
51
|
+
|
|
32
52
|
export async function GET(req: Request) {
|
|
33
53
|
try {
|
|
34
54
|
const url = new URL(req.url)
|
|
@@ -92,7 +112,11 @@ export async function GET(req: Request) {
|
|
|
92
112
|
organizationIds: [],
|
|
93
113
|
})
|
|
94
114
|
|
|
95
|
-
|
|
115
|
+
let items = buildHistoryEntries({ actionLogs: logs, notes, kind: query.kind, displayUsers: displayMaps.users })
|
|
116
|
+
const typesFilter = parseDocumentHistoryTypes(query.types)
|
|
117
|
+
if (typesFilter.size > 0) {
|
|
118
|
+
items = items.filter((entry) => typesFilter.has(entry.kind as DocumentHistoryEntryKind))
|
|
119
|
+
}
|
|
96
120
|
|
|
97
121
|
let nextCursor: string | undefined = undefined
|
|
98
122
|
if (logs.length === query.limit && items.length > 0) {
|
|
@@ -20,6 +20,7 @@ import { documentUpdateSchema } from '../../commands/documents'
|
|
|
20
20
|
import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
|
|
21
21
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
22
22
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
23
|
+
import { recalculateOrderTotalsForDisplay } from '../../commands/returns'
|
|
23
24
|
|
|
24
25
|
type DocumentKind = 'order' | 'quote'
|
|
25
26
|
|
|
@@ -467,6 +468,40 @@ export function buildDocumentCrudOptions(binding: DocumentBinding) {
|
|
|
467
468
|
hooks: {
|
|
468
469
|
afterList: async (payload: any, ctx: CrudCtx) => {
|
|
469
470
|
await attachTags(payload, { ...ctx, bindingKind: binding.kind })
|
|
471
|
+
if (binding.kind === 'order' && Array.isArray(payload?.items) && payload.items.length === 1) {
|
|
472
|
+
const item = payload.items[0] as Record<string, unknown>
|
|
473
|
+
const orderId = typeof item?.id === 'string' ? item.id : null
|
|
474
|
+
const tenantId = typeof item?.tenantId === 'string' ? item.tenantId : ctx?.auth?.tenantId ?? null
|
|
475
|
+
const organizationId =
|
|
476
|
+
typeof item?.organizationId === 'string' ? item.organizationId : ctx?.selectedOrganizationId ?? ctx?.auth?.orgId ?? null
|
|
477
|
+
if (orderId && tenantId && organizationId) {
|
|
478
|
+
const em = ctx?.container?.resolve?.('em') as import('@mikro-orm/postgresql').EntityManager | undefined
|
|
479
|
+
if (em) {
|
|
480
|
+
const totals = await recalculateOrderTotalsForDisplay(
|
|
481
|
+
em,
|
|
482
|
+
ctx.container,
|
|
483
|
+
orderId,
|
|
484
|
+
{ tenantId, organizationId },
|
|
485
|
+
)
|
|
486
|
+
if (totals) {
|
|
487
|
+
Object.assign(item, {
|
|
488
|
+
subtotalNetAmount: totals.subtotalNetAmount,
|
|
489
|
+
subtotalGrossAmount: totals.subtotalGrossAmount,
|
|
490
|
+
discountTotalAmount: totals.discountTotalAmount,
|
|
491
|
+
taxTotalAmount: totals.taxTotalAmount,
|
|
492
|
+
shippingNetAmount: totals.shippingNetAmount,
|
|
493
|
+
shippingGrossAmount: totals.shippingGrossAmount,
|
|
494
|
+
surchargeTotalAmount: totals.surchargeTotalAmount,
|
|
495
|
+
grandTotalNetAmount: totals.grandTotalNetAmount,
|
|
496
|
+
grandTotalGrossAmount: totals.grandTotalGrossAmount,
|
|
497
|
+
paidTotalAmount: totals.paidTotalAmount,
|
|
498
|
+
refundedTotalAmount: totals.refundedTotalAmount,
|
|
499
|
+
outstandingAmount: totals.outstandingAmount,
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
470
505
|
},
|
|
471
506
|
},
|
|
472
507
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
5
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
7
|
+
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
8
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
9
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
10
|
+
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
11
|
+
import { SalesReturn, SalesReturnLine } from '../../../data/entities'
|
|
12
|
+
|
|
13
|
+
export const metadata = {
|
|
14
|
+
GET: { requireAuth: true, requireFeatures: ['sales.returns.view'] },
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const paramsSchema = z.object({ id: z.string().uuid() })
|
|
18
|
+
|
|
19
|
+
const toNumber = (value: unknown): number => {
|
|
20
|
+
if (typeof value === 'number') return value
|
|
21
|
+
if (typeof value === 'string') {
|
|
22
|
+
const parsed = Number(value)
|
|
23
|
+
if (!Number.isNaN(parsed)) return parsed
|
|
24
|
+
}
|
|
25
|
+
return 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function GET(req: Request, ctx: { params: { id: string } }) {
|
|
29
|
+
try {
|
|
30
|
+
const { id } = paramsSchema.parse(ctx.params ?? {})
|
|
31
|
+
const container = await createRequestContainer()
|
|
32
|
+
const auth = await getAuthFromRequest(req)
|
|
33
|
+
const { translate } = await resolveTranslations()
|
|
34
|
+
|
|
35
|
+
if (!auth || !auth.tenantId) {
|
|
36
|
+
throw new CrudHttpError(401, { error: translate('sales.documents.errors.unauthorized', 'Unauthorized') })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
|
|
40
|
+
const organizationId = scope?.selectedId ?? auth.orgId ?? null
|
|
41
|
+
if (!organizationId) {
|
|
42
|
+
throw new CrudHttpError(400, {
|
|
43
|
+
error: translate('sales.documents.errors.organization_required', 'Organization context is required'),
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const em = (container.resolve('em') as EntityManager).fork()
|
|
48
|
+
const header = await findOneWithDecryption(
|
|
49
|
+
em,
|
|
50
|
+
SalesReturn,
|
|
51
|
+
{ id, deletedAt: null, tenantId: auth.tenantId, organizationId },
|
|
52
|
+
{ populate: ['order'] },
|
|
53
|
+
{ tenantId: auth.tenantId, organizationId },
|
|
54
|
+
)
|
|
55
|
+
if (!header || !header.order) {
|
|
56
|
+
throw new CrudHttpError(404, { error: translate('sales.returns.notFound', 'Return not found.') })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const lines = await findWithDecryption(
|
|
60
|
+
em,
|
|
61
|
+
SalesReturnLine,
|
|
62
|
+
{ salesReturn: header.id, deletedAt: null },
|
|
63
|
+
{ populate: ['orderLine'] },
|
|
64
|
+
{ tenantId: auth.tenantId, organizationId },
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const totals = lines.reduce(
|
|
68
|
+
(acc, line) => {
|
|
69
|
+
acc.net += toNumber(line.totalNetAmount)
|
|
70
|
+
acc.gross += toNumber(line.totalGrossAmount)
|
|
71
|
+
return acc
|
|
72
|
+
},
|
|
73
|
+
{ net: 0, gross: 0 },
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return NextResponse.json({
|
|
77
|
+
return: {
|
|
78
|
+
id: header.id,
|
|
79
|
+
orderId: typeof header.order === 'string' ? header.order : header.order.id,
|
|
80
|
+
returnNumber: header.returnNumber,
|
|
81
|
+
statusEntryId: header.statusEntryId ?? null,
|
|
82
|
+
status: header.status ?? null,
|
|
83
|
+
reason: header.reason ?? null,
|
|
84
|
+
notes: header.notes ?? null,
|
|
85
|
+
returnedAt: header.returnedAt ? header.returnedAt.toISOString() : null,
|
|
86
|
+
createdAt: header.createdAt ? header.createdAt.toISOString() : null,
|
|
87
|
+
updatedAt: header.updatedAt ? header.updatedAt.toISOString() : null,
|
|
88
|
+
totalNetAmount: totals.net,
|
|
89
|
+
totalGrossAmount: totals.gross,
|
|
90
|
+
},
|
|
91
|
+
lines: lines.map((line) => ({
|
|
92
|
+
id: line.id,
|
|
93
|
+
orderLineId: typeof line.orderLine === 'string' ? line.orderLine : line.orderLine?.id ?? null,
|
|
94
|
+
quantityReturned: line.quantityReturned,
|
|
95
|
+
unitPriceNet: line.unitPriceNet,
|
|
96
|
+
unitPriceGross: line.unitPriceGross,
|
|
97
|
+
totalNetAmount: line.totalNetAmount,
|
|
98
|
+
totalGrossAmount: line.totalGrossAmount,
|
|
99
|
+
})),
|
|
100
|
+
})
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err instanceof CrudHttpError) {
|
|
103
|
+
return NextResponse.json(err.body, { status: err.status })
|
|
104
|
+
}
|
|
105
|
+
console.error('sales.returns.get failed', err)
|
|
106
|
+
const { translate } = await resolveTranslations()
|
|
107
|
+
return NextResponse.json({ error: translate('sales.returns.error', 'Failed to load return.') }, { status: 400 })
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const returnLineSchema = z.object({
|
|
112
|
+
id: z.string().uuid(),
|
|
113
|
+
orderLineId: z.string().uuid().nullable(),
|
|
114
|
+
quantityReturned: z.string(),
|
|
115
|
+
unitPriceNet: z.string(),
|
|
116
|
+
unitPriceGross: z.string(),
|
|
117
|
+
totalNetAmount: z.string(),
|
|
118
|
+
totalGrossAmount: z.string(),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
export const openApi: OpenApiRouteDoc = {
|
|
122
|
+
tag: 'Sales',
|
|
123
|
+
summary: 'Get a return',
|
|
124
|
+
pathParams: paramsSchema,
|
|
125
|
+
methods: {
|
|
126
|
+
GET: {
|
|
127
|
+
summary: 'Get return details',
|
|
128
|
+
responses: [
|
|
129
|
+
{
|
|
130
|
+
status: 200,
|
|
131
|
+
description: 'Return details',
|
|
132
|
+
schema: z.object({
|
|
133
|
+
return: z.object({
|
|
134
|
+
id: z.string().uuid(),
|
|
135
|
+
orderId: z.string().uuid(),
|
|
136
|
+
returnNumber: z.string(),
|
|
137
|
+
statusEntryId: z.string().uuid().nullable(),
|
|
138
|
+
status: z.string().nullable(),
|
|
139
|
+
reason: z.string().nullable(),
|
|
140
|
+
notes: z.string().nullable(),
|
|
141
|
+
returnedAt: z.string().nullable(),
|
|
142
|
+
createdAt: z.string().nullable(),
|
|
143
|
+
updatedAt: z.string().nullable(),
|
|
144
|
+
totalNetAmount: z.number(),
|
|
145
|
+
totalGrossAmount: z.number(),
|
|
146
|
+
}),
|
|
147
|
+
lines: z.array(returnLineSchema),
|
|
148
|
+
}),
|
|
149
|
+
},
|
|
150
|
+
{ status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },
|
|
151
|
+
{ status: 404, description: 'Not found', schema: z.object({ error: z.string() }) },
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
4
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
5
|
+
import { splitCustomFieldPayload } from '@open-mercato/shared/lib/crud/custom-fields'
|
|
6
|
+
import { withScopedPayload } from '../utils'
|
|
7
|
+
import { SalesReturn, SalesReturnLine } from '../../data/entities'
|
|
8
|
+
import { returnCreateSchema } from '../../data/validators'
|
|
9
|
+
import { E } from '#generated/entities.ids.generated'
|
|
10
|
+
import * as F from '#generated/entities/sales_return'
|
|
11
|
+
import { createPagedListResponseSchema } from '../openapi'
|
|
12
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
13
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
14
|
+
|
|
15
|
+
const rawBodySchema = z.object({}).passthrough()
|
|
16
|
+
|
|
17
|
+
const listSchema = z
|
|
18
|
+
.object({
|
|
19
|
+
page: z.coerce.number().min(1).default(1),
|
|
20
|
+
pageSize: z.coerce.number().min(1).max(200).default(50),
|
|
21
|
+
orderId: z.string().uuid().optional(),
|
|
22
|
+
sortField: z.string().optional(),
|
|
23
|
+
sortDir: z.enum(['asc', 'desc']).optional(),
|
|
24
|
+
})
|
|
25
|
+
.passthrough()
|
|
26
|
+
|
|
27
|
+
const routeMetadata = {
|
|
28
|
+
GET: { requireAuth: true, requireFeatures: ['sales.returns.view'] },
|
|
29
|
+
POST: { requireAuth: true, requireFeatures: ['sales.returns.create'] },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const toNumber = (value: unknown): number => {
|
|
33
|
+
if (typeof value === 'number') return value
|
|
34
|
+
if (typeof value === 'string') {
|
|
35
|
+
const parsed = Number(value)
|
|
36
|
+
if (!Number.isNaN(parsed)) return parsed
|
|
37
|
+
}
|
|
38
|
+
return 0
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const crud = makeCrudRoute({
|
|
42
|
+
metadata: routeMetadata,
|
|
43
|
+
orm: {
|
|
44
|
+
entity: SalesReturn,
|
|
45
|
+
idField: 'id',
|
|
46
|
+
orgField: 'organizationId',
|
|
47
|
+
tenantField: 'tenantId',
|
|
48
|
+
softDeleteField: 'deletedAt',
|
|
49
|
+
},
|
|
50
|
+
indexer: {
|
|
51
|
+
entityType: E.sales.sales_return,
|
|
52
|
+
},
|
|
53
|
+
list: {
|
|
54
|
+
schema: listSchema,
|
|
55
|
+
entityId: E.sales.sales_return,
|
|
56
|
+
fields: [
|
|
57
|
+
F.id,
|
|
58
|
+
'order_id',
|
|
59
|
+
F.return_number,
|
|
60
|
+
F.status_entry_id,
|
|
61
|
+
F.status,
|
|
62
|
+
F.reason,
|
|
63
|
+
F.notes,
|
|
64
|
+
F.returned_at,
|
|
65
|
+
F.created_at,
|
|
66
|
+
F.updated_at,
|
|
67
|
+
],
|
|
68
|
+
sortFieldMap: {
|
|
69
|
+
createdAt: F.created_at,
|
|
70
|
+
updatedAt: F.updated_at,
|
|
71
|
+
returnedAt: F.returned_at,
|
|
72
|
+
},
|
|
73
|
+
buildFilters: async (query: Record<string, unknown>) => {
|
|
74
|
+
const filters: Record<string, unknown> = {}
|
|
75
|
+
if (typeof query.orderId === 'string' && query.orderId.length > 0) {
|
|
76
|
+
filters.order_id = { $eq: query.orderId }
|
|
77
|
+
}
|
|
78
|
+
return filters
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
actions: {
|
|
82
|
+
create: {
|
|
83
|
+
commandId: 'sales.returns.create',
|
|
84
|
+
schema: rawBodySchema,
|
|
85
|
+
mapInput: async ({ raw, ctx }) => {
|
|
86
|
+
const { translate } = await resolveTranslations()
|
|
87
|
+
const scoped = withScopedPayload(raw ?? {}, ctx, translate)
|
|
88
|
+
const { base } = splitCustomFieldPayload(scoped)
|
|
89
|
+
return returnCreateSchema.parse(base)
|
|
90
|
+
},
|
|
91
|
+
response: ({ result }) => ({ id: result?.returnId ?? null }),
|
|
92
|
+
status: 201,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
hooks: {
|
|
96
|
+
afterList: async (payload, ctx) => {
|
|
97
|
+
const items = Array.isArray(payload.items) ? payload.items : []
|
|
98
|
+
if (!items.length) return
|
|
99
|
+
const returnIds = items
|
|
100
|
+
.map((item: unknown) => (item && typeof item === 'object' ? (item as Record<string, unknown>).id : null))
|
|
101
|
+
.filter((value: string | null): value is string => typeof value === 'string')
|
|
102
|
+
if (!returnIds.length) return
|
|
103
|
+
const em = ctx.container.resolve('em') as EntityManager
|
|
104
|
+
const lines = await findWithDecryption(
|
|
105
|
+
em,
|
|
106
|
+
SalesReturnLine,
|
|
107
|
+
{ salesReturn: { $in: returnIds }, deletedAt: null },
|
|
108
|
+
{},
|
|
109
|
+
{ tenantId: ctx.auth?.tenantId ?? null, organizationId: ctx.auth?.orgId ?? null },
|
|
110
|
+
)
|
|
111
|
+
const totals = lines.reduce<Map<string, { net: number; gross: number }>>((acc, line) => {
|
|
112
|
+
const returnId = typeof line.salesReturn === 'string' ? line.salesReturn : line.salesReturn?.id ?? null
|
|
113
|
+
if (!returnId) return acc
|
|
114
|
+
const current = acc.get(returnId) ?? { net: 0, gross: 0 }
|
|
115
|
+
current.net += toNumber(line.totalNetAmount)
|
|
116
|
+
current.gross += toNumber(line.totalGrossAmount)
|
|
117
|
+
acc.set(returnId, current)
|
|
118
|
+
return acc
|
|
119
|
+
}, new Map())
|
|
120
|
+
items.forEach((item: unknown) => {
|
|
121
|
+
if (!item || typeof item !== 'object') return
|
|
122
|
+
const map = item as Record<string, unknown>
|
|
123
|
+
const id = map.id
|
|
124
|
+
if (typeof id !== 'string') return
|
|
125
|
+
const sum = totals.get(id)
|
|
126
|
+
if (!sum) return
|
|
127
|
+
map['total_net_amount'] = sum.net
|
|
128
|
+
map['total_gross_amount'] = sum.gross
|
|
129
|
+
})
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const { GET, POST } = crud
|
|
135
|
+
|
|
136
|
+
export { GET, POST }
|
|
137
|
+
|
|
138
|
+
const returnSchema = z
|
|
139
|
+
.object({
|
|
140
|
+
id: z.string().uuid(),
|
|
141
|
+
order_id: z.string().uuid(),
|
|
142
|
+
return_number: z.string(),
|
|
143
|
+
status_entry_id: z.string().uuid().nullable().optional(),
|
|
144
|
+
status: z.string().nullable().optional(),
|
|
145
|
+
reason: z.string().nullable().optional(),
|
|
146
|
+
notes: z.string().nullable().optional(),
|
|
147
|
+
returned_at: z.string().nullable().optional(),
|
|
148
|
+
created_at: z.string(),
|
|
149
|
+
updated_at: z.string(),
|
|
150
|
+
total_net_amount: z.number().optional(),
|
|
151
|
+
total_gross_amount: z.number().optional(),
|
|
152
|
+
})
|
|
153
|
+
.passthrough()
|
|
154
|
+
|
|
155
|
+
export const openApi: OpenApiRouteDoc = {
|
|
156
|
+
tag: 'Sales',
|
|
157
|
+
summary: 'Manage order returns',
|
|
158
|
+
methods: {
|
|
159
|
+
GET: {
|
|
160
|
+
summary: 'List returns',
|
|
161
|
+
query: listSchema,
|
|
162
|
+
responses: [{ status: 200, description: 'Returns list', schema: createPagedListResponseSchema(returnSchema) }],
|
|
163
|
+
},
|
|
164
|
+
POST: {
|
|
165
|
+
summary: 'Create return',
|
|
166
|
+
requestBody: { schema: returnCreateSchema },
|
|
167
|
+
responses: [{ status: 201, description: 'Return created', schema: z.object({ id: z.string().uuid().nullable() }) }],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
|