@open-mercato/core 0.6.8-develop.7072.1.19c2a8bbe0 → 0.6.8-develop.7077.1.94f1126c13

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 (43) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/login.js +4 -1
  3. package/dist/modules/auth/api/login.js.map +2 -2
  4. package/dist/modules/auth/api/session/refresh.js +25 -1
  5. package/dist/modules/auth/api/session/refresh.js.map +2 -2
  6. package/dist/modules/customers/ai-tools/deals-pack.js +48 -5
  7. package/dist/modules/customers/ai-tools/deals-pack.js.map +2 -2
  8. package/dist/modules/customers/api/deals/aggregate/route.js +11 -4
  9. package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
  10. package/dist/modules/customers/api/deals/route.js +38 -1
  11. package/dist/modules/customers/api/deals/route.js.map +2 -2
  12. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +4 -13
  13. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +2 -2
  14. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +85 -14
  15. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +2 -2
  16. package/dist/modules/customers/backend/customers/deals/pipeline/components/toneClasses.js +18 -0
  17. package/dist/modules/customers/backend/customers/deals/pipeline/components/toneClasses.js.map +7 -0
  18. package/dist/modules/customers/commands/deals.js +21 -34
  19. package/dist/modules/customers/commands/deals.js.map +2 -2
  20. package/dist/modules/customers/lib/closureStage.js +45 -0
  21. package/dist/modules/customers/lib/closureStage.js.map +7 -0
  22. package/dist/modules/customers/lib/dealStatus.js +30 -0
  23. package/dist/modules/customers/lib/dealStatus.js.map +2 -2
  24. package/dist/modules/query_index/lib/search-entity-policy.js +14 -0
  25. package/dist/modules/query_index/lib/search-entity-policy.js.map +7 -0
  26. package/package.json +7 -7
  27. package/src/modules/auth/api/login.ts +9 -1
  28. package/src/modules/auth/api/session/refresh.ts +32 -1
  29. package/src/modules/customers/ai-tools/deals-pack.ts +62 -0
  30. package/src/modules/customers/api/deals/aggregate/route.ts +14 -4
  31. package/src/modules/customers/api/deals/route.ts +50 -1
  32. package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +5 -14
  33. package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +109 -27
  34. package/src/modules/customers/backend/customers/deals/pipeline/components/toneClasses.ts +21 -0
  35. package/src/modules/customers/commands/deals.ts +34 -59
  36. package/src/modules/customers/i18n/de.json +3 -0
  37. package/src/modules/customers/i18n/en.json +3 -0
  38. package/src/modules/customers/i18n/es.json +3 -0
  39. package/src/modules/customers/i18n/ko.json +3 -0
  40. package/src/modules/customers/i18n/pl.json +3 -0
  41. package/src/modules/customers/lib/closureStage.ts +76 -0
  42. package/src/modules/customers/lib/dealStatus.ts +42 -0
  43. package/src/modules/query_index/lib/search-entity-policy.ts +46 -0
@@ -10,43 +10,107 @@ import {
10
10
  } from "@open-mercato/ui/primitives/popover";
11
11
  import { useT } from "@open-mercato/shared/lib/i18n/context";
12
12
  import { translateWithFallback } from "@open-mercato/shared/lib/i18n/translate";
13
+ import { useOrganizationScopeVersion } from "@open-mercato/shared/lib/frontend/useOrganizationScope";
14
+ import { mapDictionaryColorToTone } from "@open-mercato/shared/lib/query/advanced-filter";
15
+ import { Spinner } from "@open-mercato/ui/primitives/spinner";
16
+ import { useCustomerDictionary } from "../../../../../components/detail/hooks/useCustomerDictionary.js";
17
+ import { canonicalDealStatus } from "../../../../../lib/dealStatus.js";
18
+ import { toneToDotClass } from "./toneClasses.js";
13
19
  import { ChipButton } from "./ChipButton.js";
14
20
  import { FilterPopoverShell } from "./FilterPopoverShell.js";
15
- const STATUS_OPTIONS = [
21
+ const FALLBACK_STATUS_OPTIONS = [
16
22
  {
17
23
  value: "open",
18
24
  labelKey: "customers.deals.kanban.filter.status.open",
19
25
  labelFallback: "Open",
20
- dotClass: "bg-status-success-icon"
26
+ // Tones mirror mapDictionaryColorToTone over the seeded dictionary colors
27
+ // (#2563eb → info, #22c55e → success, #ef4444 → error) so the fallback pills
28
+ // render identically to the dictionary-driven ones.
29
+ dotClass: "bg-status-info-icon"
21
30
  },
22
31
  {
23
32
  value: "win",
24
33
  labelKey: "customers.deals.kanban.filter.status.won",
25
34
  labelFallback: "Won",
26
- dotClass: "bg-status-warning-icon"
35
+ dotClass: "bg-status-success-icon"
27
36
  },
28
37
  {
29
38
  value: "loose",
30
39
  labelKey: "customers.deals.kanban.filter.status.lost",
31
40
  labelFallback: "Lost",
41
+ dotClass: "bg-status-error-icon"
42
+ },
43
+ {
44
+ value: "closed",
45
+ labelKey: "customers.deals.kanban.filter.status.closed",
46
+ labelFallback: "Closed",
32
47
  dotClass: "bg-status-neutral-icon"
48
+ },
49
+ {
50
+ value: "in_progress",
51
+ labelKey: "customers.deals.kanban.filter.status.inProgress",
52
+ labelFallback: "In progress",
53
+ dotClass: "bg-status-warning-icon"
33
54
  }
34
55
  ];
35
56
  function StatusFilterPopover({ values, onApply }) {
36
57
  const t = useT();
58
+ const scopeVersion = useOrganizationScopeVersion();
59
+ const { data: dictionaryData, isLoading: dictionaryLoading } = useCustomerDictionary(
60
+ "deal-statuses",
61
+ scopeVersion
62
+ );
37
63
  const [open, setOpen] = React.useState(false);
38
- const [draft, setDraft] = React.useState(values);
64
+ const normalizedValues = React.useMemo(
65
+ () => Array.from(new Set(values.map(canonicalDealStatus))),
66
+ [values]
67
+ );
68
+ const [draft, setDraft] = React.useState(normalizedValues);
39
69
  React.useEffect(() => {
40
- if (open) setDraft(values);
41
- }, [open, values]);
70
+ if (open) setDraft(normalizedValues);
71
+ }, [open, normalizedValues]);
72
+ const statusOptions = React.useMemo(() => {
73
+ const entries = dictionaryData?.entries;
74
+ if (entries && entries.length > 0) {
75
+ const byCanonical = /* @__PURE__ */ new Map();
76
+ for (const entry of entries) {
77
+ const canonical = canonicalDealStatus(entry.value);
78
+ if (byCanonical.has(canonical)) continue;
79
+ const tone = mapDictionaryColorToTone(entry.color ?? null);
80
+ byCanonical.set(canonical, {
81
+ value: canonical,
82
+ label: entry.label,
83
+ dotClass: toneToDotClass(tone)
84
+ });
85
+ }
86
+ return Array.from(byCanonical.values()).sort(
87
+ (a, b) => a.label.localeCompare(b.label, void 0, { sensitivity: "base" })
88
+ );
89
+ }
90
+ return FALLBACK_STATUS_OPTIONS.map((entry) => ({
91
+ value: entry.value,
92
+ label: translateWithFallback(t, entry.labelKey, entry.labelFallback),
93
+ dotClass: entry.dotClass
94
+ }));
95
+ }, [dictionaryData, t]);
96
+ const labelByValue = React.useMemo(() => {
97
+ const map = /* @__PURE__ */ new Map();
98
+ for (const option of statusOptions) {
99
+ map.set(option.value, option.label);
100
+ }
101
+ for (const fallback of FALLBACK_STATUS_OPTIONS) {
102
+ if (!map.has(fallback.value)) {
103
+ map.set(fallback.value, translateWithFallback(t, fallback.labelKey, fallback.labelFallback));
104
+ }
105
+ }
106
+ return map;
107
+ }, [statusOptions, t]);
42
108
  const chipLabel = translateWithFallback(t, "customers.deals.kanban.filter.status", "Status");
43
- const chipValue = values.length === 0 ? translateWithFallback(t, "customers.deals.kanban.filter.all", "All") : values.map((value) => {
44
- const option = STATUS_OPTIONS.find((entry) => entry.value === value);
45
- return option ? translateWithFallback(t, option.labelKey, option.labelFallback) : value;
46
- }).join(", ");
109
+ const chipValue = normalizedValues.length === 0 ? translateWithFallback(t, "customers.deals.kanban.filter.all", "All") : normalizedValues.map((value) => labelByValue.get(value) ?? value).join(", ");
47
110
  const toggleDraft = (value) => {
111
+ const normalized = canonicalDealStatus(value);
48
112
  setDraft(
49
- (prev) => prev.includes(value) ? prev.filter((entry) => entry !== value) : [...prev, value]
113
+ (prev) => prev.includes(normalized) ? prev.filter((entry) => entry !== normalized) : [...prev, normalized]
50
114
  );
51
115
  };
52
116
  const handleApply = () => {
@@ -60,7 +124,7 @@ function StatusFilterPopover({ values, onApply }) {
60
124
  }
61
125
  };
62
126
  return /* @__PURE__ */ jsxs(Popover, { open, onOpenChange: setOpen, children: [
63
- /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx(ChipButton, { label: chipLabel, value: chipValue, active: values.length > 0 }) }),
127
+ /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx(ChipButton, { label: chipLabel, value: chipValue, active: normalizedValues.length > 0 }) }),
64
128
  /* @__PURE__ */ jsx(
65
129
  PopoverContent,
66
130
  {
@@ -84,9 +148,16 @@ function StatusFilterPopover({ values, onApply }) {
84
148
  ] }),
85
149
  children: [
86
150
  /* @__PURE__ */ jsx("span", { className: "text-xs font-semibold uppercase leading-normal tracking-wide text-muted-foreground", children: translateWithFallback(t, "customers.deals.kanban.filter.status", "Status") }),
87
- /* @__PURE__ */ jsx("div", { className: "flex flex-wrap items-center gap-x-1.5 gap-y-2", children: STATUS_OPTIONS.map((option) => {
151
+ /* @__PURE__ */ jsx("div", { className: "flex flex-wrap items-center gap-x-1.5 gap-y-2", children: dictionaryLoading ? /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2 text-xs text-muted-foreground", role: "status", "aria-live": "polite", children: [
152
+ /* @__PURE__ */ jsx(Spinner, { className: "size-3" }),
153
+ translateWithFallback(
154
+ t,
155
+ "customers.deals.kanban.filter.status.loading",
156
+ "Loading statuses\u2026"
157
+ )
158
+ ] }) : statusOptions.map((option) => {
88
159
  const isSelected = draft.includes(option.value);
89
- const label = translateWithFallback(t, option.labelKey, option.labelFallback);
160
+ const label = option.label;
90
161
  return /* @__PURE__ */ jsxs(
91
162
  Button,
92
163
  {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../../src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { X } from 'lucide-react'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from '@open-mercato/ui/primitives/popover'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'\nimport { ChipButton } from './ChipButton'\nimport { FilterPopoverShell } from './FilterPopoverShell'\n\n/**\n * Filter options exposed to the operator.\n *\n * The deal `status` column accepts the historical 4-value enum (`open` | `closed` | `win` |\n * `loose`), but the codebase only ever **writes** `loose` for a lost deal \u2014 `closed` was a\n * latent unused state from an earlier iteration. Exposing a separate \"Lost (closed)\" filter\n * option therefore filtered to nothing and confused operators. We display a single \"Lost\"\n * choice and intentionally map it to the canonical wire value `loose` so the filter matches\n * what the rest of the app actually persists. (Renaming the column / migrating the data to\n * `lost` is a deeper data-model change tracked separately.)\n */\nconst STATUS_OPTIONS: Array<{\n value: string\n labelKey: string\n labelFallback: string\n /** Tone selects the small \u25CF-dot color on the pill (per Figma: green / amber / gray) */\n dotClass: string\n}> = [\n {\n value: 'open',\n labelKey: 'customers.deals.kanban.filter.status.open',\n labelFallback: 'Open',\n dotClass: 'bg-status-success-icon',\n },\n {\n value: 'win',\n labelKey: 'customers.deals.kanban.filter.status.won',\n labelFallback: 'Won',\n dotClass: 'bg-status-warning-icon',\n },\n {\n value: 'loose',\n labelKey: 'customers.deals.kanban.filter.status.lost',\n labelFallback: 'Lost',\n dotClass: 'bg-status-neutral-icon',\n },\n]\n\ntype StatusFilterPopoverProps = {\n values: string[]\n onApply: (next: string[]) => void\n}\n\nexport function StatusFilterPopover({ values, onApply }: StatusFilterPopoverProps): React.ReactElement {\n const t = useT()\n const [open, setOpen] = React.useState(false)\n const [draft, setDraft] = React.useState<string[]>(values)\n\n React.useEffect(() => {\n if (open) setDraft(values)\n }, [open, values])\n\n const chipLabel = translateWithFallback(t, 'customers.deals.kanban.filter.status', 'Status')\n const chipValue =\n values.length === 0\n ? translateWithFallback(t, 'customers.deals.kanban.filter.all', 'All')\n : values\n .map((value) => {\n const option = STATUS_OPTIONS.find((entry) => entry.value === value)\n return option\n ? translateWithFallback(t, option.labelKey, option.labelFallback)\n : value\n })\n .join(', ')\n\n const toggleDraft = (value: string) => {\n setDraft((prev) =>\n prev.includes(value) ? prev.filter((entry) => entry !== value) : [...prev, value],\n )\n }\n\n const handleApply = () => {\n onApply(draft)\n setOpen(false)\n }\n\n // Cmd/Ctrl+Enter from anywhere inside the popover confirms \u2014 parity with the dialog\n // primary-action shortcut (`AGENTS.md` UI Interaction rules).\n const handleKeyDown = (event: React.KeyboardEvent) => {\n if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {\n event.preventDefault()\n handleApply()\n }\n }\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <ChipButton label={chipLabel} value={chipValue} active={values.length > 0} />\n </PopoverTrigger>\n <PopoverContent\n className=\"w-96 rounded-2xl border-border bg-transparent p-0 shadow-xl\"\n align=\"start\"\n onKeyDown={handleKeyDown}\n >\n <FilterPopoverShell\n title={\n <>\n <span className=\"font-bold\">\n {translateWithFallback(t, 'customers.deals.kanban.filter.status.title.label', 'Filter : ')}\n </span>\n <span className=\"font-normal\">\n {translateWithFallback(t, 'customers.deals.kanban.filter.status', 'Status')}\n </span>\n </>\n }\n onClose={() => setOpen(false)}\n onCancel={() => setOpen(false)}\n onApply={handleApply}\n footerLeft={\n <span>\n {draft.length}{' '}\n {translateWithFallback(t, 'customers.deals.kanban.filter.selected', 'selected')}\n </span>\n }\n >\n <span className=\"text-xs font-semibold uppercase leading-normal tracking-wide text-muted-foreground\">\n {translateWithFallback(t, 'customers.deals.kanban.filter.status', 'Status')}\n </span>\n <div className=\"flex flex-wrap items-center gap-x-1.5 gap-y-2\">\n {STATUS_OPTIONS.map((option) => {\n const isSelected = draft.includes(option.value)\n const label = translateWithFallback(t, option.labelKey, option.labelFallback)\n return (\n <Button\n key={option.value}\n type=\"button\"\n variant=\"ghost\"\n size=\"2xs\"\n onClick={() => toggleDraft(option.value)}\n aria-pressed={isSelected}\n className={`gap-1.5 rounded-full px-2.5 py-1.5 text-xs leading-normal ${\n isSelected\n ? 'bg-muted font-semibold text-foreground'\n : 'border border-border bg-card font-normal text-muted-foreground hover:bg-muted'\n }`}\n >\n <span\n className={`inline-block size-2 shrink-0 rounded-full ${option.dotClass}`}\n aria-hidden=\"true\"\n />\n <span>{label}</span>\n {isSelected ? (\n <X className=\"size-2.5 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n ) : null}\n </Button>\n )\n })}\n </div>\n </FilterPopoverShell>\n </PopoverContent>\n </Popover>\n )\n}\n\nexport default StatusFilterPopover\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { X } from 'lucide-react'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from '@open-mercato/ui/primitives/popover'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { mapDictionaryColorToTone } from '@open-mercato/shared/lib/query/advanced-filter'\nimport { Spinner } from '@open-mercato/ui/primitives/spinner'\nimport { useCustomerDictionary } from '../../../../../components/detail/hooks/useCustomerDictionary'\nimport { canonicalDealStatus } from '../../../../../lib/dealStatus'\nimport { toneToDotClass } from './toneClasses'\nimport { ChipButton } from './ChipButton'\nimport { FilterPopoverShell } from './FilterPopoverShell'\n\n/**\n * Filter options exposed to the operator.\n *\n * The deal `status` column is dictionary-driven (`deal-statuses`); the seeded dictionary\n * carries five values (`open`, `closed`, `win`, `loose`, `in_progress`) plus any\n * tenant-custom entries. We render every dictionary entry de-duplicated by canonical\n * spelling so the kanban Status pill stays aligned with the list page's advanced filter\n * (which also uses the dictionary). A hard-coded fallback keeps the popover usable while\n * the dictionary is loading or when a tenant has no entries.\n *\n * `won` / `lost` are accepted as aliases for `win` / `loose` at the API layer (see\n * `lib/dealStatus.ts:expandDealStatusAliases`, shared by the deals list route and the\n * kanban aggregate route). The UI only exposes the canonical values to avoid duplicate\n * pills, but `canonicalDealStatus` normalizes any alias passed in through `values` so\n * the chip and draft selection render the correct label.\n */\nconst FALLBACK_STATUS_OPTIONS: Array<{\n value: string\n labelKey: string\n labelFallback: string\n dotClass: string\n}> = [\n {\n value: 'open',\n labelKey: 'customers.deals.kanban.filter.status.open',\n labelFallback: 'Open',\n // Tones mirror mapDictionaryColorToTone over the seeded dictionary colors\n // (#2563eb \u2192 info, #22c55e \u2192 success, #ef4444 \u2192 error) so the fallback pills\n // render identically to the dictionary-driven ones.\n dotClass: 'bg-status-info-icon',\n },\n {\n value: 'win',\n labelKey: 'customers.deals.kanban.filter.status.won',\n labelFallback: 'Won',\n dotClass: 'bg-status-success-icon',\n },\n {\n value: 'loose',\n labelKey: 'customers.deals.kanban.filter.status.lost',\n labelFallback: 'Lost',\n dotClass: 'bg-status-error-icon',\n },\n {\n value: 'closed',\n labelKey: 'customers.deals.kanban.filter.status.closed',\n labelFallback: 'Closed',\n dotClass: 'bg-status-neutral-icon',\n },\n {\n value: 'in_progress',\n labelKey: 'customers.deals.kanban.filter.status.inProgress',\n labelFallback: 'In progress',\n dotClass: 'bg-status-warning-icon',\n },\n]\n\ntype StatusFilterPopoverProps = {\n values: string[]\n onApply: (next: string[]) => void\n}\n\nexport function StatusFilterPopover({ values, onApply }: StatusFilterPopoverProps): React.ReactElement {\n const t = useT()\n const scopeVersion = useOrganizationScopeVersion()\n const { data: dictionaryData, isLoading: dictionaryLoading } = useCustomerDictionary(\n 'deal-statuses',\n scopeVersion,\n )\n const [open, setOpen] = React.useState(false)\n const normalizedValues = React.useMemo(\n () => Array.from(new Set(values.map(canonicalDealStatus))),\n [values],\n )\n const [draft, setDraft] = React.useState<string[]>(normalizedValues)\n\n React.useEffect(() => {\n if (open) setDraft(normalizedValues)\n }, [open, normalizedValues])\n\n const statusOptions = React.useMemo(() => {\n const entries = dictionaryData?.entries\n if (entries && entries.length > 0) {\n const byCanonical = new Map<string, { value: string; label: string; dotClass: string }>()\n for (const entry of entries) {\n const canonical = canonicalDealStatus(entry.value)\n if (byCanonical.has(canonical)) continue\n const tone = mapDictionaryColorToTone(entry.color ?? null)\n byCanonical.set(canonical, {\n value: canonical,\n label: entry.label,\n dotClass: toneToDotClass(tone),\n })\n }\n // Sort by label exactly like the List page's advanced filter\n // (backend/customers/deals/page.tsx dictionaryOptions) so both surfaces render\n // the same pills in the same order.\n return Array.from(byCanonical.values()).sort((a, b) =>\n a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }),\n )\n }\n return FALLBACK_STATUS_OPTIONS.map((entry) => ({\n value: entry.value,\n label: translateWithFallback(t, entry.labelKey, entry.labelFallback),\n dotClass: entry.dotClass,\n }))\n }, [dictionaryData, t])\n\n const labelByValue = React.useMemo(() => {\n const map = new Map<string, string>()\n for (const option of statusOptions) {\n map.set(option.value, option.label)\n }\n for (const fallback of FALLBACK_STATUS_OPTIONS) {\n if (!map.has(fallback.value)) {\n map.set(fallback.value, translateWithFallback(t, fallback.labelKey, fallback.labelFallback))\n }\n }\n return map\n }, [statusOptions, t])\n\n const chipLabel = translateWithFallback(t, 'customers.deals.kanban.filter.status', 'Status')\n const chipValue =\n normalizedValues.length === 0\n ? translateWithFallback(t, 'customers.deals.kanban.filter.all', 'All')\n : normalizedValues\n .map((value) => labelByValue.get(value) ?? value)\n .join(', ')\n\n const toggleDraft = (value: string) => {\n const normalized = canonicalDealStatus(value)\n setDraft((prev) =>\n prev.includes(normalized) ? prev.filter((entry) => entry !== normalized) : [...prev, normalized],\n )\n }\n\n const handleApply = () => {\n onApply(draft)\n setOpen(false)\n }\n\n // Cmd/Ctrl+Enter from anywhere inside the popover confirms \u2014 parity with the dialog\n // primary-action shortcut (`AGENTS.md` UI Interaction rules).\n const handleKeyDown = (event: React.KeyboardEvent) => {\n if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {\n event.preventDefault()\n handleApply()\n }\n }\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <ChipButton label={chipLabel} value={chipValue} active={normalizedValues.length > 0} />\n </PopoverTrigger>\n <PopoverContent\n className=\"w-96 rounded-2xl border-border bg-transparent p-0 shadow-xl\"\n align=\"start\"\n onKeyDown={handleKeyDown}\n >\n <FilterPopoverShell\n title={\n <>\n <span className=\"font-bold\">\n {translateWithFallback(t, 'customers.deals.kanban.filter.status.title.label', 'Filter : ')}\n </span>\n <span className=\"font-normal\">\n {translateWithFallback(t, 'customers.deals.kanban.filter.status', 'Status')}\n </span>\n </>\n }\n onClose={() => setOpen(false)}\n onCancel={() => setOpen(false)}\n onApply={handleApply}\n footerLeft={\n <span>\n {draft.length}{' '}\n {translateWithFallback(t, 'customers.deals.kanban.filter.selected', 'selected')}\n </span>\n }\n >\n <span className=\"text-xs font-semibold uppercase leading-normal tracking-wide text-muted-foreground\">\n {translateWithFallback(t, 'customers.deals.kanban.filter.status', 'Status')}\n </span>\n <div className=\"flex flex-wrap items-center gap-x-1.5 gap-y-2\">\n {dictionaryLoading ? (\n <span className=\"flex items-center gap-2 text-xs text-muted-foreground\" role=\"status\" aria-live=\"polite\">\n <Spinner className=\"size-3\" />\n {translateWithFallback(\n t,\n 'customers.deals.kanban.filter.status.loading',\n 'Loading statuses\u2026',\n )}\n </span>\n ) : (\n statusOptions.map((option) => {\n const isSelected = draft.includes(option.value)\n const label = option.label\n return (\n <Button\n key={option.value}\n type=\"button\"\n variant=\"ghost\"\n size=\"2xs\"\n onClick={() => toggleDraft(option.value)}\n aria-pressed={isSelected}\n className={`gap-1.5 rounded-full px-2.5 py-1.5 text-xs leading-normal ${\n isSelected\n ? 'bg-muted font-semibold text-foreground'\n : 'border border-border bg-card font-normal text-muted-foreground hover:bg-muted'\n }`}\n >\n <span\n className={`inline-block size-2 shrink-0 rounded-full ${option.dotClass}`}\n aria-hidden=\"true\"\n />\n <span>{label}</span>\n {isSelected ? (\n <X className=\"size-2.5 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n ) : null}\n </Button>\n )\n })\n )}\n </div>\n </FilterPopoverShell>\n </PopoverContent>\n </Popover>\n )\n}\n\nexport default StatusFilterPopover\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,18 @@
1
+ const TONE_DOT_CLASS = {
2
+ success: "bg-status-success-icon",
3
+ error: "bg-status-error-icon",
4
+ warning: "bg-status-warning-icon",
5
+ info: "bg-status-info-icon",
6
+ neutral: "bg-status-neutral-icon",
7
+ brand: "bg-brand-violet",
8
+ pink: "bg-status-pink-icon"
9
+ };
10
+ function toneToDotClass(tone, fallback = "bg-status-neutral-icon") {
11
+ if (tone && tone in TONE_DOT_CLASS) return TONE_DOT_CLASS[tone];
12
+ return fallback;
13
+ }
14
+ export {
15
+ TONE_DOT_CLASS,
16
+ toneToDotClass
17
+ };
18
+ //# sourceMappingURL=toneClasses.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../../../../src/modules/customers/backend/customers/deals/pipeline/components/toneClasses.ts"],
4
+ "sourcesContent": ["import type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'\n\n// Saturated icon tokens shared by the kanban lane accent bar and the status filter\n// pills, so both surfaces render the same tone identically (#5107 review).\nexport const TONE_DOT_CLASS: Record<FilterOptionTone, string> = {\n success: 'bg-status-success-icon',\n error: 'bg-status-error-icon',\n warning: 'bg-status-warning-icon',\n info: 'bg-status-info-icon',\n neutral: 'bg-status-neutral-icon',\n brand: 'bg-brand-violet',\n pink: 'bg-status-pink-icon',\n}\n\nexport function toneToDotClass(\n tone: FilterOptionTone | null | undefined,\n fallback = 'bg-status-neutral-icon',\n): string {\n if (tone && tone in TONE_DOT_CLASS) return TONE_DOT_CLASS[tone]\n return fallback\n}\n"],
5
+ "mappings": "AAIO,MAAM,iBAAmD;AAAA,EAC9D,SAAS;AAAA,EACT,OAAO;AAAA,EACP,SAAS;AAAA,EACT,MAAM;AAAA,EACN,SAAS;AAAA,EACT,OAAO;AAAA,EACP,MAAM;AACR;AAEO,SAAS,eACd,MACA,WAAW,0BACH;AACR,MAAI,QAAQ,QAAQ,eAAgB,QAAO,eAAe,IAAI;AAC9D,SAAO;AACT;",
6
+ "names": []
7
+ }
@@ -38,6 +38,11 @@ import { CrudHttpError, notFound } from "@open-mercato/shared/lib/crud/errors";
38
38
  import { E } from "../../../generated/entities.ids.generated.js";
39
39
  import { findWithDecryption, findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
40
40
  import { isMissingDealStageTransitionTable, warnMissingDealStageTransitionTable } from "../lib/dealStageTransitionTable.js";
41
+ import {
42
+ dealClosureOutcomeFromStatus,
43
+ loadClosurePipelineStageSnapshot
44
+ } from "../lib/closureStage.js";
45
+ import { canonicalDealStatus, isClosedDealStatus } from "../lib/dealStatus.js";
41
46
  import { createLogger } from "@open-mercato/shared/lib/logger";
42
47
  const logger = createLogger("customers");
43
48
  const DEAL_ENTITY_ID = "customers:customer_deal";
@@ -54,10 +59,6 @@ const dealCrudEvents = {
54
59
  tenantId: ctx.identifiers.tenantId
55
60
  })
56
61
  };
57
- const TERMINAL_PIPELINE_STAGE_LABELS = {
58
- won: /* @__PURE__ */ new Set(["won", "win", "closed won", "closed win"]),
59
- lost: /* @__PURE__ */ new Set(["lost", "loose", "closed lost", "closed loose"])
60
- };
61
62
  function coerceSnapshotDate(value, fieldName) {
62
63
  if (value === void 0 || value === null) return null;
63
64
  if (value instanceof Date) return value;
@@ -84,39 +85,11 @@ async function loadPipelineStageSnapshot(em, pipelineStageId, tenantId, organiza
84
85
  order: stage.order
85
86
  };
86
87
  }
87
- function normalizePipelineStageLabel(value) {
88
- return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
89
- }
90
88
  function resolveRequestedClosureOutcome(input) {
91
89
  if (input.closureOutcome === "won" || input.closureOutcome === "lost") {
92
90
  return input.closureOutcome;
93
91
  }
94
- if (input.status === "win") return "won";
95
- if (input.status === "loose") return "lost";
96
- return null;
97
- }
98
- async function loadClosurePipelineStageSnapshot(em, input) {
99
- if (!input.pipelineId) return null;
100
- const stages = await findWithDecryption(
101
- em,
102
- CustomerPipelineStage,
103
- {
104
- pipelineId: input.pipelineId,
105
- tenantId: input.tenantId,
106
- organizationId: input.organizationId
107
- },
108
- { orderBy: { order: "ASC" } },
109
- { tenantId: input.tenantId, organizationId: input.organizationId }
110
- );
111
- const aliases = TERMINAL_PIPELINE_STAGE_LABELS[input.closureOutcome];
112
- const stage = stages.find((candidate) => aliases.has(normalizePipelineStageLabel(candidate.label)));
113
- if (!stage) return null;
114
- return {
115
- id: stage.id,
116
- pipelineId: stage.pipelineId,
117
- label: stage.label,
118
- order: stage.order
119
- };
92
+ return dealClosureOutcomeFromStatus(input.status);
120
93
  }
121
94
  async function resolvePipelineStageValue(em, pipelineStageId, tenantId, organizationId) {
122
95
  const stage = await loadPipelineStageSnapshot(em, pipelineStageId, tenantId, organizationId);
@@ -582,6 +555,7 @@ const updateDealCommand = {
582
555
  let nextPipelineStageLabel = null;
583
556
  let resolvedCurrentPipelineStageLabel = null;
584
557
  let pipelineStageAssignmentChanged = false;
558
+ let requestedClosureOutcome = null;
585
559
  await runCrudCommandWrite({
586
560
  ctx,
587
561
  em,
@@ -601,7 +575,7 @@ const updateDealCommand = {
601
575
  }),
602
576
  phases: [
603
577
  async () => {
604
- const requestedClosureOutcome = resolveRequestedClosureOutcome(parsed);
578
+ requestedClosureOutcome = resolveRequestedClosureOutcome(parsed);
605
579
  const requestedPipelineId = parsed.pipelineId !== void 0 ? parsed.pipelineId ?? null : record.pipelineId ?? null;
606
580
  const closureStageSnapshot = parsed.pipelineStageId === void 0 && requestedClosureOutcome ? await loadClosurePipelineStageSnapshot(em, {
607
581
  pipelineId: requestedPipelineId,
@@ -649,6 +623,19 @@ const updateDealCommand = {
649
623
  if (parsed.ownerUserId !== void 0) record.ownerUserId = parsed.ownerUserId ?? null;
650
624
  if (parsed.source !== void 0) record.source = parsed.source ?? null;
651
625
  if (parsed.closureOutcome !== void 0) record.closureOutcome = parsed.closureOutcome ?? null;
626
+ else if (requestedClosureOutcome && parsed.pipelineStageId === void 0) {
627
+ record.closureOutcome = requestedClosureOutcome;
628
+ } else if (parsed.status !== void 0 && !requestedClosureOutcome && // `closed` is a seeded dictionary status: saving it is not a reopen, so only
629
+ // a genuinely non-closed status clears stored closure state — and only when
630
+ // the deal actually carries any (a no-op status echo must not wipe loss data).
631
+ // Canonicalize first: `dealClosureOutcomeFromStatus` above already matches
632
+ // case-insensitively, so matching `closed` exactly would let `Closed` through
633
+ // this guard and null the operator's loss notes.
634
+ !isClosedDealStatus(canonicalDealStatus(parsed.status)) && record.closureOutcome !== null) {
635
+ record.closureOutcome = null;
636
+ if (parsed.lossReasonId === void 0) record.lossReasonId = null;
637
+ if (parsed.lossNotes === void 0) record.lossNotes = null;
638
+ }
652
639
  if (parsed.lossReasonId !== void 0) record.lossReasonId = parsed.lossReasonId ?? null;
653
640
  if (parsed.lossNotes !== void 0) record.lossNotes = parsed.lossNotes ?? null;
654
641
  },