@aglyn/plugins-crm 1.0.0-beta.244 → 1.0.0-beta.247

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 (50) hide show
  1. package/README.md +2 -0
  2. package/package.json +14 -14
  3. package/src/lib/components/contact-custom-columns.d.ts +15 -6
  4. package/src/lib/components/contact-custom-columns.js +27 -7
  5. package/src/lib/components/contact-custom-columns.js.map +1 -1
  6. package/src/lib/components/contact-field-drawer.d.ts +2 -0
  7. package/src/lib/components/contact-field-drawer.js +29 -15
  8. package/src/lib/components/contact-field-drawer.js.map +1 -1
  9. package/src/lib/components/contacts-section.js +9 -3
  10. package/src/lib/components/contacts-section.js.map +1 -1
  11. package/src/lib/components/crm-custom-field-control.d.ts +4 -2
  12. package/src/lib/components/crm-custom-field-control.js +53 -1
  13. package/src/lib/components/crm-custom-field-control.js.map +1 -1
  14. package/src/lib/components/fields-section.js +4 -2
  15. package/src/lib/components/fields-section.js.map +1 -1
  16. package/src/lib/components/form-contact-fields-card.js +2 -0
  17. package/src/lib/components/form-contact-fields-card.js.map +1 -1
  18. package/src/lib/constants/contact-filters.d.ts +7 -0
  19. package/src/lib/constants/contact-filters.js +8 -1
  20. package/src/lib/constants/contact-filters.js.map +1 -1
  21. package/src/lib/declarations.console-server.d.ts +7 -0
  22. package/src/lib/declarations.console-server.js +39 -0
  23. package/src/lib/declarations.console-server.js.map +1 -1
  24. package/src/lib/model/crm-picklist-setup-content.d.ts +121 -0
  25. package/src/lib/model/crm-picklist-setup-content.js +262 -0
  26. package/src/lib/model/crm-picklist-setup-content.js.map +1 -0
  27. package/src/lib/model/crm-record-routes.js +18 -0
  28. package/src/lib/model/crm-record-routes.js.map +1 -1
  29. package/src/lib/model/crm-setup-content.d.ts +199 -0
  30. package/src/lib/model/crm-setup-content.js +427 -0
  31. package/src/lib/model/crm-setup-content.js.map +1 -0
  32. package/src/lib/server/crm-setup-ai-capabilities.d.ts +68 -0
  33. package/src/lib/server/crm-setup-ai-capabilities.js +290 -0
  34. package/src/lib/server/crm-setup-ai-capabilities.js.map +1 -0
  35. package/src/lib/server/crm-setup-writers.server.d.ts +120 -0
  36. package/src/lib/server/crm-setup-writers.server.js +492 -0
  37. package/src/lib/server/crm-setup-writers.server.js.map +1 -0
  38. package/src/lib/server/picklist-regroup.d.ts +30 -0
  39. package/src/lib/server/picklist-regroup.js +61 -0
  40. package/src/lib/server/picklist-regroup.js.map +1 -0
  41. package/src/lib/server/picklist-values.d.ts +2 -14
  42. package/src/lib/server/picklist-values.js +3 -40
  43. package/src/lib/server/picklist-values.js.map +1 -1
  44. package/src/lib/transfer/common.d.ts +4 -2
  45. package/src/lib/transfer/common.js +14 -3
  46. package/src/lib/transfer/common.js.map +1 -1
  47. package/src/lib/transfer/fields.js +10 -1
  48. package/src/lib/transfer/fields.js.map +1 -1
  49. package/src/lib/transfer/pipelines.js +4 -2
  50. package/src/lib/transfer/pipelines.js.map +1 -1
package/README.md CHANGED
@@ -40,6 +40,8 @@ The CRM is console-only. Its records live in Firestore and it has no canvas comp
40
40
 
41
41
  `registerCrmConsoleApi()` from `@aglyn/plugins-crm/server` (the `consoleApi` surface) registers routes under the `crm` prefix for contact stage and field updates, contact creation and merge, lead conversion, deal stages, tasks, one-to-one email and a contact's email history, erasing a person, organization-level activity, recipes, and the workspace's inbound email capture address. Contacts, companies, leads, deals and tasks are imported and exported through the platform's transfer framework, and activities, pipelines and custom fields exported: `registerCrmConsoleServerDeclarations()` registers each resource's server half (`src/lib/transfer/`) and the console registrar its name in the import wizard and export dialog. It also registers the CRM on two core seams, so another plugin can read a record's facts or file an item on a record's timeline without importing this package.
42
42
 
43
+ At the console's boot it also registers the CRM's setup writers on the `plugin-resource-drafts` seam (`crm-pipeline`, `crm-field`, `crm-picklist-values`) and the build operations Aglyn AI plans with them (`plugin-ai-capabilities`): a pipeline with its stages, a custom field, and values added to a picklist or a Choice field (`src/lib/model/crm-picklist-setup-content.ts`), saved live under the console's rules (`src/lib/model/crm-setup-content.ts`, `src/lib/server/crm-setup-writers.server.ts`).
44
+
43
45
  At the console's boot (`registerCrmConsoleServerDeclarations`) it registers a membership detacher (`plugin-membership-detach`): when the plugin that keeps a container — a campaign — removes it, the container comes off every lead and every contact facet that names it, and the records stay.
44
46
 
45
47
  ### Entry points
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-crm",
3
- "version": "1.0.0-beta.244",
3
+ "version": "1.0.0-beta.247",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,19 +25,19 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.244",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.244",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.244",
31
- "@aglyn/shared-ui-jsx": "1.0.0-beta.244",
32
- "@aglyn/shared-ui-next": "1.0.0-beta.244",
33
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.244",
34
- "@aglyn/shared-util-email": "1.0.0-beta.244",
35
- "@aglyn/shared-util-http": "1.0.0-beta.244",
36
- "@aglyn/shared-util-timestamp": "1.0.0-beta.244",
37
- "@aglyn/shared-util-tools": "1.0.0-beta.244",
38
- "@aglyn/tenant-data-admin": "1.0.0-beta.244",
39
- "@aglyn/tenant-feature-instance": "1.0.0-beta.244",
40
- "@aglyn/tenant-runtime": "1.0.0-beta.244",
28
+ "@aglyn/aglyn": "1.0.0-beta.247",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.247",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.247",
31
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.247",
32
+ "@aglyn/shared-ui-next": "1.0.0-beta.247",
33
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.247",
34
+ "@aglyn/shared-util-email": "1.0.0-beta.247",
35
+ "@aglyn/shared-util-http": "1.0.0-beta.247",
36
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.247",
37
+ "@aglyn/shared-util-tools": "1.0.0-beta.247",
38
+ "@aglyn/tenant-data-admin": "1.0.0-beta.247",
39
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.247",
40
+ "@aglyn/tenant-runtime": "1.0.0-beta.247",
41
41
  "@dnd-kit/core": "^6.3.1",
42
42
  "@dnd-kit/utilities": "^3.2.2",
43
43
  "@swc/helpers": "0.5.23",
@@ -12,11 +12,18 @@ export declare const customFieldColumnId: (key: string) => string;
12
12
  *
13
13
  * A checkbox reads as Yes or an em dash rather than `true`/`false`; a date
14
14
  * as the local day rather than the ISO stamp it is stored as; a link as an
15
- * anchor. Everything else is the value itself, and nothing is `null` on
15
+ * anchor; an amount as money in the field's currency (AGL-3756). Everything
16
+ * else is the value itself, and nothing is `null` on
16
17
  * screen — a cleared value and an absent one look the same, because to the
17
18
  * reader of a list they are.
18
19
  */
19
- export declare function formatContactCustomValue(definition: Pick<ContactFieldDefinition, 'type'>, value: ContactCustomValue | undefined): string;
20
+ export declare function formatContactCustomValue(definition: Pick<ContactFieldDefinition, 'type' | 'currency'>, value: ContactCustomValue | undefined): string;
21
+ /**
22
+ * How each active currency field's filter reads a typed amount, by column
23
+ * (AGL-3756) — `ContactQueryReader.amounts`: major units in, the minor
24
+ * units its facet key holds out.
25
+ */
26
+ export declare function contactCustomAmountReaders(definitions: readonly Pick<ContactFieldDefinition, 'key' | 'type' | 'currency' | 'retiredAt'>[]): Record<string, (typed: string) => number | null>;
20
27
  /**
21
28
  * A CRM list's columns for the org's custom fields (AGL-2601), one per
22
29
  * ACTIVE definition in `order` — the contacts list's, and since AGL-2661
@@ -26,10 +33,12 @@ export declare function formatContactCustomValue(definition: Pick<ContactFieldDe
26
33
  * The value is read off `row.custom`: on a contact the list projects it
27
34
  * from THIS holder's facet — the same projection that puts `tags` and
28
35
  * `notes` on the row — so a column can never show another holder's value;
29
- * on a company or a deal it is the document's own map. Sorting and
30
- * filtering are off: the value lives under a map no index covers, and a
31
- * column that sorted the loaded window would look like it sorted the
32
- * collection.
36
+ * on a company or a deal it is the document's own map. The grid's own
37
+ * sorting and filtering are off: the value lives under a map no index
38
+ * covers, so a list's column sort sorts the PAGE through `valueGetter` and
39
+ * says so (AGL-3680), and its filters ask the query (`contact-filters.ts`).
40
+ * A currency column's `valueGetter` is the stored amount, so the page sorts
41
+ * by money rather than by the formatted text.
33
42
  *
34
43
  * Retired definitions get no column. Their values are still on the rows,
35
44
  * and an export can still read them; a column for a field the merchant
@@ -15,7 +15,7 @@
15
15
  * limitations under the License.
16
16
  */ 'use client';
17
17
  import { jsx as _jsx } from "react/jsx-runtime";
18
- import { crmContactCustomColumn } from "@aglyn/aglyn";
18
+ import { customFieldCurrency, crmContactCustomColumn, formatCurrencyFieldValue, parseCurrencyInput } from "@aglyn/aglyn";
19
19
  import { Link, Typography } from "@mui/material";
20
20
  /**
21
21
  * The column `field` a definition's key is listed under — namespaced so a
@@ -28,7 +28,8 @@ import { Link, Typography } from "@mui/material";
28
28
  *
29
29
  * A checkbox reads as Yes or an em dash rather than `true`/`false`; a date
30
30
  * as the local day rather than the ISO stamp it is stored as; a link as an
31
- * anchor. Everything else is the value itself, and nothing is `null` on
31
+ * anchor; an amount as money in the field's currency (AGL-3756). Everything
32
+ * else is the value itself, and nothing is `null` on
32
33
  * screen — a cleared value and an absent one look the same, because to the
33
34
  * reader of a list they are.
34
35
  */ export function formatContactCustomValue(definition, value) {
@@ -43,10 +44,25 @@ import { Link, Typography } from "@mui/material";
43
44
  }
44
45
  case 'number':
45
46
  return typeof value === 'number' ? value.toLocaleString() : String(value);
47
+ case 'currency':
48
+ return typeof value === 'number' ? formatCurrencyFieldValue(value, customFieldCurrency(definition)) : String(value);
46
49
  default:
47
50
  return String(value);
48
51
  }
49
52
  }
53
+ /**
54
+ * How each active currency field's filter reads a typed amount, by column
55
+ * (AGL-3756) — `ContactQueryReader.amounts`: major units in, the minor
56
+ * units its facet key holds out.
57
+ */ export function contactCustomAmountReaders(definitions) {
58
+ return Object.fromEntries(definitions.filter((definition)=>definition.type === 'currency' && !definition.retiredAt).map((definition)=>{
59
+ const code = customFieldCurrency(definition);
60
+ return [
61
+ customFieldColumnId(definition.key),
62
+ (typed)=>parseCurrencyInput(typed, code)
63
+ ];
64
+ }));
65
+ }
50
66
  /**
51
67
  * A CRM list's columns for the org's custom fields (AGL-2601), one per
52
68
  * ACTIVE definition in `order` — the contacts list's, and since AGL-2661
@@ -56,10 +72,12 @@ import { Link, Typography } from "@mui/material";
56
72
  * The value is read off `row.custom`: on a contact the list projects it
57
73
  * from THIS holder's facet — the same projection that puts `tags` and
58
74
  * `notes` on the row — so a column can never show another holder's value;
59
- * on a company or a deal it is the document's own map. Sorting and
60
- * filtering are off: the value lives under a map no index covers, and a
61
- * column that sorted the loaded window would look like it sorted the
62
- * collection.
75
+ * on a company or a deal it is the document's own map. The grid's own
76
+ * sorting and filtering are off: the value lives under a map no index
77
+ * covers, so a list's column sort sorts the PAGE through `valueGetter` and
78
+ * says so (AGL-3680), and its filters ask the query (`contact-filters.ts`).
79
+ * A currency column's `valueGetter` is the stored amount, so the page sorts
80
+ * by money rather than by the formatted text.
63
81
  *
64
82
  * Retired definitions get no column. Their values are still on the rows,
65
83
  * and an export can still read them; a column for a field the merchant
@@ -74,7 +92,9 @@ import { Link, Typography } from "@mui/material";
74
92
  filterable: false,
75
93
  valueGetter: (_value, row)=>{
76
94
  var _row_custom;
77
- return formatContactCustomValue(definition, (_row_custom = row.custom) == null ? void 0 : _row_custom[definition.key]);
95
+ const raw = (_row_custom = row.custom) == null ? void 0 : _row_custom[definition.key];
96
+ if (definition.type === 'currency') return typeof raw === 'number' ? raw : null;
97
+ return formatContactCustomValue(definition, raw);
78
98
  },
79
99
  renderCell: ({ row })=>{
80
100
  var _row_custom;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-custom-columns.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n type ContactCustomValue,\n type ContactFieldDefinition,\n crmContactCustomColumn,\n} from '@aglyn/aglyn'\nimport { Link, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\n\n/**\n * The column `field` a definition's key is listed under — namespaced so a\n * key can never collide with a built-in column, and the shared contract\n * since AGL-2617 so a saved view's filter clause and its column list name\n * the field the same way.\n */\nexport const customFieldColumnId = crmContactCustomColumn\n\n/**\n * One stored value, as the list shows it.\n *\n * A checkbox reads as Yes or an em dash rather than `true`/`false`; a date\n * as the local day rather than the ISO stamp it is stored as; a link as an\n * anchor. Everything else is the value itself, and nothing is `null` on\n * screen — a cleared value and an absent one look the same, because to the\n * reader of a list they are.\n */\nexport function formatContactCustomValue(\n definition: Pick<ContactFieldDefinition, 'type'>,\n value: ContactCustomValue | undefined,\n): string {\n if (value === undefined || value === null || value === '') return ''\n switch (definition.type) {\n case 'checkbox':\n return value === true ? 'Yes' : value === false ? 'No' : String(value)\n case 'date': {\n const ms = typeof value === 'string' ? Date.parse(value) : Number(value)\n return Number.isFinite(ms) ? new Date(ms).toLocaleDateString() : String(value)\n }\n case 'number':\n return typeof value === 'number' ? value.toLocaleString() : String(value)\n default:\n return String(value)\n }\n}\n\n/**\n * A CRM list's columns for the org's custom fields (AGL-2601), one per\n * ACTIVE definition in `order` — the contacts list's, and since AGL-2661\n * the companies list's and the deals table's, each handed the definitions\n * of its own object.\n *\n * The value is read off `row.custom`: on a contact the list projects it\n * from THIS holder's facet — the same projection that puts `tags` and\n * `notes` on the row — so a column can never show another holder's value;\n * on a company or a deal it is the document's own map. Sorting and\n * filtering are off: the value lives under a map no index covers, and a\n * column that sorted the loaded window would look like it sorted the\n * collection.\n *\n * Retired definitions get no column. Their values are still on the rows,\n * and an export can still read them; a column for a field the merchant\n * retired would be the field refusing to go away.\n */\nexport function customFieldColumns(\n definitions: readonly ContactFieldDefinition[],\n): GridColDef[] {\n return definitions\n .filter((definition) => !definition.retiredAt)\n .map((definition) => ({\n field: customFieldColumnId(definition.key),\n headerName: definition.label,\n flex: 0.8,\n minWidth: 140,\n sortable: false,\n filterable: false,\n valueGetter: (_value: unknown, row: { custom?: Record<string, ContactCustomValue> }) =>\n formatContactCustomValue(definition, row.custom?.[definition.key]),\n renderCell: ({ row }: { row: { custom?: Record<string, ContactCustomValue> } }) => {\n const raw = row.custom?.[definition.key]\n const text = formatContactCustomValue(definition, raw)\n if (!text) return null\n if (definition.type === 'url') {\n return (\n <Link\n href={text}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n variant=\"body2\"\n onClick={(event) => event.stopPropagation()}\n >\n {text}\n </Link>\n )\n }\n return <Typography variant=\"body2\">{text}</Typography>\n },\n }))\n}\n"],"names":["crmContactCustomColumn","Link","Typography","customFieldColumnId","formatContactCustomValue","definition","value","undefined","type","String","ms","Date","parse","Number","isFinite","toLocaleDateString","toLocaleString","customFieldColumns","definitions","filter","retiredAt","map","field","key","headerName","label","flex","minWidth","sortable","filterable","valueGetter","_value","row","custom","renderCell","raw","text","href","target","rel","variant","onClick","event","stopPropagation"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAGEA,sBAAsB,QACjB,eAAc;AACrB,SAASC,IAAI,EAAEC,UAAU,QAAQ,gBAAe;AAGhD;;;;;CAKC,GACD,OAAO,MAAMC,sBAAsBH,uBAAsB;AAEzD;;;;;;;;CAQC,GACD,OAAO,SAASI,yBACdC,UAAgD,EAChDC,KAAqC;IAErC,IAAIA,UAAUC,aAAaD,UAAU,QAAQA,UAAU,IAAI,OAAO;IAClE,OAAQD,WAAWG,IAAI;QACrB,KAAK;YACH,OAAOF,UAAU,OAAO,QAAQA,UAAU,QAAQ,OAAOG,OAAOH;QAClE,KAAK;YAAQ;gBACX,MAAMI,KAAK,OAAOJ,UAAU,WAAWK,KAAKC,KAAK,CAACN,SAASO,OAAOP;gBAClE,OAAOO,OAAOC,QAAQ,CAACJ,MAAM,IAAIC,KAAKD,IAAIK,kBAAkB,KAAKN,OAAOH;YAC1E;QACA,KAAK;YACH,OAAO,OAAOA,UAAU,WAAWA,MAAMU,cAAc,KAAKP,OAAOH;QACrE;YACE,OAAOG,OAAOH;IAClB;AACF;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASW,mBACdC,WAA8C;IAE9C,OAAOA,YACJC,MAAM,CAAC,CAACd,aAAe,CAACA,WAAWe,SAAS,EAC5CC,GAAG,CAAC,CAAChB,aAAgB,CAAA;YACpBiB,OAAOnB,oBAAoBE,WAAWkB,GAAG;YACzCC,YAAYnB,WAAWoB,KAAK;YAC5BC,MAAM;YACNC,UAAU;YACVC,UAAU;YACVC,YAAY;YACZC,aAAa,CAACC,QAAiBC;oBACQA;uBAArC5B,yBAAyBC,aAAY2B,cAAAA,IAAIC,MAAM,qBAAVD,WAAY,CAAC3B,WAAWkB,GAAG,CAAC;;YACnEW,YAAY,CAAC,EAAEF,GAAG,EAA4D;oBAChEA;gBAAZ,MAAMG,OAAMH,cAAAA,IAAIC,MAAM,qBAAVD,WAAY,CAAC3B,WAAWkB,GAAG,CAAC;gBACxC,MAAMa,OAAOhC,yBAAyBC,YAAY8B;gBAClD,IAAI,CAACC,MAAM,OAAO;gBAClB,IAAI/B,WAAWG,IAAI,KAAK,OAAO;oBAC7B,qBACE,KAACP;wBACCoC,MAAMD;wBACNE,QAAO;wBACPC,KAAI;wBACJC,SAAQ;wBACRC,SAAS,CAACC,QAAUA,MAAMC,eAAe;kCAExCP;;gBAGP;gBACA,qBAAO,KAAClC;oBAAWsC,SAAQ;8BAASJ;;YACtC;QACF,CAAA;AACJ"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-custom-columns.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n type ContactCustomValue,\n type ContactFieldDefinition,\n customFieldCurrency,\n crmContactCustomColumn,\n formatCurrencyFieldValue,\n parseCurrencyInput,\n} from '@aglyn/aglyn'\nimport { Link, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\n\n/**\n * The column `field` a definition's key is listed under — namespaced so a\n * key can never collide with a built-in column, and the shared contract\n * since AGL-2617 so a saved view's filter clause and its column list name\n * the field the same way.\n */\nexport const customFieldColumnId = crmContactCustomColumn\n\n/**\n * One stored value, as the list shows it.\n *\n * A checkbox reads as Yes or an em dash rather than `true`/`false`; a date\n * as the local day rather than the ISO stamp it is stored as; a link as an\n * anchor; an amount as money in the field's currency (AGL-3756). Everything\n * else is the value itself, and nothing is `null` on\n * screen — a cleared value and an absent one look the same, because to the\n * reader of a list they are.\n */\nexport function formatContactCustomValue(\n definition: Pick<ContactFieldDefinition, 'type' | 'currency'>,\n value: ContactCustomValue | undefined,\n): string {\n if (value === undefined || value === null || value === '') return ''\n switch (definition.type) {\n case 'checkbox':\n return value === true ? 'Yes' : value === false ? 'No' : String(value)\n case 'date': {\n const ms = typeof value === 'string' ? Date.parse(value) : Number(value)\n return Number.isFinite(ms) ? new Date(ms).toLocaleDateString() : String(value)\n }\n case 'number':\n return typeof value === 'number' ? value.toLocaleString() : String(value)\n case 'currency':\n return typeof value === 'number'\n ? formatCurrencyFieldValue(value, customFieldCurrency(definition))\n : String(value)\n default:\n return String(value)\n }\n}\n\n/**\n * How each active currency field's filter reads a typed amount, by column\n * (AGL-3756) — `ContactQueryReader.amounts`: major units in, the minor\n * units its facet key holds out.\n */\nexport function contactCustomAmountReaders(\n definitions: readonly Pick<ContactFieldDefinition, 'key' | 'type' | 'currency' | 'retiredAt'>[],\n): Record<string, (typed: string) => number | null> {\n return Object.fromEntries(\n definitions\n .filter((definition) => definition.type === 'currency' && !definition.retiredAt)\n .map((definition) => {\n const code = customFieldCurrency(definition)\n return [customFieldColumnId(definition.key), (typed: string) => parseCurrencyInput(typed, code)]\n }),\n )\n}\n\n/**\n * A CRM list's columns for the org's custom fields (AGL-2601), one per\n * ACTIVE definition in `order` — the contacts list's, and since AGL-2661\n * the companies list's and the deals table's, each handed the definitions\n * of its own object.\n *\n * The value is read off `row.custom`: on a contact the list projects it\n * from THIS holder's facet — the same projection that puts `tags` and\n * `notes` on the row — so a column can never show another holder's value;\n * on a company or a deal it is the document's own map. The grid's own\n * sorting and filtering are off: the value lives under a map no index\n * covers, so a list's column sort sorts the PAGE through `valueGetter` and\n * says so (AGL-3680), and its filters ask the query (`contact-filters.ts`).\n * A currency column's `valueGetter` is the stored amount, so the page sorts\n * by money rather than by the formatted text.\n *\n * Retired definitions get no column. Their values are still on the rows,\n * and an export can still read them; a column for a field the merchant\n * retired would be the field refusing to go away.\n */\nexport function customFieldColumns(\n definitions: readonly ContactFieldDefinition[],\n): GridColDef[] {\n return definitions\n .filter((definition) => !definition.retiredAt)\n .map((definition) => ({\n field: customFieldColumnId(definition.key),\n headerName: definition.label,\n flex: 0.8,\n minWidth: 140,\n sortable: false,\n filterable: false,\n valueGetter: (_value: unknown, row: { custom?: Record<string, ContactCustomValue> }) => {\n const raw = row.custom?.[definition.key]\n if (definition.type === 'currency') return typeof raw === 'number' ? raw : null\n return formatContactCustomValue(definition, raw)\n },\n renderCell: ({ row }: { row: { custom?: Record<string, ContactCustomValue> } }) => {\n const raw = row.custom?.[definition.key]\n const text = formatContactCustomValue(definition, raw)\n if (!text) return null\n if (definition.type === 'url') {\n return (\n <Link\n href={text}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n variant=\"body2\"\n onClick={(event) => event.stopPropagation()}\n >\n {text}\n </Link>\n )\n }\n return <Typography variant=\"body2\">{text}</Typography>\n },\n }))\n}\n"],"names":["customFieldCurrency","crmContactCustomColumn","formatCurrencyFieldValue","parseCurrencyInput","Link","Typography","customFieldColumnId","formatContactCustomValue","definition","value","undefined","type","String","ms","Date","parse","Number","isFinite","toLocaleDateString","toLocaleString","contactCustomAmountReaders","definitions","Object","fromEntries","filter","retiredAt","map","code","key","typed","customFieldColumns","field","headerName","label","flex","minWidth","sortable","filterable","valueGetter","_value","row","raw","custom","renderCell","text","href","target","rel","variant","onClick","event","stopPropagation"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAGEA,mBAAmB,EACnBC,sBAAsB,EACtBC,wBAAwB,EACxBC,kBAAkB,QACb,eAAc;AACrB,SAASC,IAAI,EAAEC,UAAU,QAAQ,gBAAe;AAGhD;;;;;CAKC,GACD,OAAO,MAAMC,sBAAsBL,uBAAsB;AAEzD;;;;;;;;;CASC,GACD,OAAO,SAASM,yBACdC,UAA6D,EAC7DC,KAAqC;IAErC,IAAIA,UAAUC,aAAaD,UAAU,QAAQA,UAAU,IAAI,OAAO;IAClE,OAAQD,WAAWG,IAAI;QACrB,KAAK;YACH,OAAOF,UAAU,OAAO,QAAQA,UAAU,QAAQ,OAAOG,OAAOH;QAClE,KAAK;YAAQ;gBACX,MAAMI,KAAK,OAAOJ,UAAU,WAAWK,KAAKC,KAAK,CAACN,SAASO,OAAOP;gBAClE,OAAOO,OAAOC,QAAQ,CAACJ,MAAM,IAAIC,KAAKD,IAAIK,kBAAkB,KAAKN,OAAOH;YAC1E;QACA,KAAK;YACH,OAAO,OAAOA,UAAU,WAAWA,MAAMU,cAAc,KAAKP,OAAOH;QACrE,KAAK;YACH,OAAO,OAAOA,UAAU,WACpBP,yBAAyBO,OAAOT,oBAAoBQ,eACpDI,OAAOH;QACb;YACE,OAAOG,OAAOH;IAClB;AACF;AAEA;;;;CAIC,GACD,OAAO,SAASW,2BACdC,WAA+F;IAE/F,OAAOC,OAAOC,WAAW,CACvBF,YACGG,MAAM,CAAC,CAAChB,aAAeA,WAAWG,IAAI,KAAK,cAAc,CAACH,WAAWiB,SAAS,EAC9EC,GAAG,CAAC,CAAClB;QACJ,MAAMmB,OAAO3B,oBAAoBQ;QACjC,OAAO;YAACF,oBAAoBE,WAAWoB,GAAG;YAAG,CAACC,QAAkB1B,mBAAmB0B,OAAOF;SAAM;IAClG;AAEN;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASG,mBACdT,WAA8C;IAE9C,OAAOA,YACJG,MAAM,CAAC,CAAChB,aAAe,CAACA,WAAWiB,SAAS,EAC5CC,GAAG,CAAC,CAAClB,aAAgB,CAAA;YACpBuB,OAAOzB,oBAAoBE,WAAWoB,GAAG;YACzCI,YAAYxB,WAAWyB,KAAK;YAC5BC,MAAM;YACNC,UAAU;YACVC,UAAU;YACVC,YAAY;YACZC,aAAa,CAACC,QAAiBC;oBACjBA;gBAAZ,MAAMC,OAAMD,cAAAA,IAAIE,MAAM,qBAAVF,WAAY,CAAChC,WAAWoB,GAAG,CAAC;gBACxC,IAAIpB,WAAWG,IAAI,KAAK,YAAY,OAAO,OAAO8B,QAAQ,WAAWA,MAAM;gBAC3E,OAAOlC,yBAAyBC,YAAYiC;YAC9C;YACAE,YAAY,CAAC,EAAEH,GAAG,EAA4D;oBAChEA;gBAAZ,MAAMC,OAAMD,cAAAA,IAAIE,MAAM,qBAAVF,WAAY,CAAChC,WAAWoB,GAAG,CAAC;gBACxC,MAAMgB,OAAOrC,yBAAyBC,YAAYiC;gBAClD,IAAI,CAACG,MAAM,OAAO;gBAClB,IAAIpC,WAAWG,IAAI,KAAK,OAAO;oBAC7B,qBACE,KAACP;wBACCyC,MAAMD;wBACNE,QAAO;wBACPC,KAAI;wBACJC,SAAQ;wBACRC,SAAS,CAACC,QAAUA,MAAMC,eAAe;kCAExCP;;gBAGP;gBACA,qBAAO,KAACvC;oBAAW2C,SAAQ;8BAASJ;;YACtC;QACF,CAAA;AACJ"}
@@ -7,6 +7,8 @@ export interface ContactFieldDraft {
7
7
  type: ContactFieldType;
8
8
  /** The choices, for `select`; empty for every other type. */
9
9
  options: string[];
10
+ /** For `currency` (AGL-3756): the lowercase ISO 4217 code; `null` for every other type. */
11
+ currency: string | null;
10
12
  required: boolean;
11
13
  }
12
14
  export interface ContactFieldDrawerProps {
@@ -15,9 +15,11 @@
15
15
  * limitations under the License.
16
16
  */ 'use client';
17
17
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
- import { CONTACT_FIELD_TYPE_LABELS, CONTACT_FIELD_TYPES, isContactFieldType, normalizeContactFieldKey } from "@aglyn/aglyn";
18
+ import { CONTACT_FIELD_TYPE_LABELS, CONTACT_FIELD_TYPES, customFieldCurrency, isContactFieldType, normalizeContactFieldKey } from "@aglyn/aglyn";
19
19
  import { Button, Drawer, FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material";
20
20
  import { useCallback, useEffect, useMemo, useState } from "react";
21
+ import { CRM_FIELD_LABEL_MAX_LENGTH, crmFieldOptions } from "../model/crm-setup-content.js";
22
+ import { DEAL_CURRENCIES } from "../model/deal-board-model.js";
21
23
  /** How the object reads in the drawer's own sentences — singular, lowercase. */ const OBJECT_NOUN = {
22
24
  contact: 'contact',
23
25
  company: 'company',
@@ -46,20 +48,9 @@ import { useCallback, useEffect, useMemo, useState } from "react";
46
48
  const filled = IMPORTABLE[object] ? 'is filled on the record, by CSV import or over the API.' : 'is filled on the record or over the API.';
47
49
  return `A website form field saves into contact fields only; a ${OBJECT_NOUN[object]} ` + `field ${filled}`;
48
50
  }
49
- /** How many choices a select may declare, and how long each may be. */ const OPTIONS_MAX = 50;
50
- const OPTION_MAX_LENGTH = 120;
51
- const LABEL_MAX_LENGTH = 80;
52
- /** One choice per line, trimmed, blanks dropped, duplicates collapsed. */ function parseOptions(text) {
53
- const seen = new Set();
54
- const options = [];
55
- for (const line of text.split('\n')){
56
- const option = line.trim().slice(0, OPTION_MAX_LENGTH);
57
- if (!option || seen.has(option)) continue;
58
- seen.add(option);
59
- options.push(option);
60
- if (options.length >= OPTIONS_MAX) break;
61
- }
62
- return options;
51
+ /** How long a label may be: the one bound an AI build's field is held to too (AGL-3746). */ const LABEL_MAX_LENGTH = CRM_FIELD_LABEL_MAX_LENGTH;
52
+ /** One choice per line, trimmed, blanks dropped, duplicates collapsed, under the shared bounds. */ function parseOptions(text) {
53
+ return crmFieldOptions(text.split('\n'));
63
54
  }
64
55
  /**
65
56
  * The one form a custom contact field is created and edited through
@@ -90,6 +81,7 @@ const LABEL_MAX_LENGTH = 80;
90
81
  const [keyTouched, setKeyTouched] = useState(false);
91
82
  const [type, setType] = useState('text');
92
83
  const [optionsText, setOptionsText] = useState('');
84
+ const [currency, setCurrency] = useState(customFieldCurrency(null));
93
85
  const [required, setRequired] = useState(false);
94
86
  const [busy, setBusy] = useState(false);
95
87
  const [error, setError] = useState(null);
@@ -103,6 +95,7 @@ const LABEL_MAX_LENGTH = 80;
103
95
  setKeyTouched(Boolean(definition));
104
96
  setType((_ref2 = definition == null ? void 0 : definition.type) != null ? _ref2 : 'text');
105
97
  setOptionsText(((_ref3 = definition == null ? void 0 : definition.options) != null ? _ref3 : []).join('\n'));
98
+ setCurrency(customFieldCurrency(definition));
106
99
  setRequired((definition == null ? void 0 : definition.required) === true);
107
100
  setBusy(false);
108
101
  setError(null);
@@ -132,6 +125,7 @@ const LABEL_MAX_LENGTH = 80;
132
125
  key: derivedKey,
133
126
  type,
134
127
  options: type === 'select' ? options : [],
128
+ currency: type === 'currency' ? currency : null,
135
129
  required
136
130
  });
137
131
  } catch (caught) {
@@ -147,6 +141,7 @@ const LABEL_MAX_LENGTH = 80;
147
141
  label,
148
142
  type,
149
143
  options,
144
+ currency,
150
145
  required
151
146
  ]);
152
147
  return /*#__PURE__*/ _jsx(Drawer, {
@@ -229,6 +224,25 @@ const LABEL_MAX_LENGTH = 80;
229
224
  minRows: 3,
230
225
  fullWidth: true
231
226
  }) : null,
227
+ type === 'currency' ? /*#__PURE__*/ _jsx(TextField, {
228
+ select: true,
229
+ size: "small",
230
+ label: "Currency",
231
+ value: currency,
232
+ onChange: (event)=>setCurrency(event.target.value),
233
+ disabled: editing,
234
+ helperText: editing ? 'Amounts are stored in this currency, so it is fixed once the field exists.' : 'Every amount in this field is in this currency.',
235
+ fullWidth: true,
236
+ children: [
237
+ ...new Set([
238
+ ...DEAL_CURRENCIES,
239
+ currency
240
+ ])
241
+ ].map((code)=>/*#__PURE__*/ _jsx(MenuItem, {
242
+ value: code,
243
+ children: code.toUpperCase()
244
+ }, code))
245
+ }) : null,
232
246
  /*#__PURE__*/ _jsx(FormControlLabel, {
233
247
  control: /*#__PURE__*/ _jsx(Switch, {
234
248
  checked: required,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-field-drawer.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n CONTACT_FIELD_TYPE_LABELS,\n CONTACT_FIELD_TYPES,\n type ContactFieldType,\n type CrmFieldObject,\n isContactFieldType,\n normalizeContactFieldKey,\n} from '@aglyn/aglyn'\nimport {\n Button,\n Drawer,\n FormControlLabel,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type { ContactFieldDefinitionDoc } from '../hooks/use-contact-field-definitions'\n\n/** What the drawer hands back — the definition's editable half. */\nexport interface ContactFieldDraft {\n label: string\n key: string\n type: ContactFieldType\n /** The choices, for `select`; empty for every other type. */\n options: string[]\n required: boolean\n}\n\nexport interface ContactFieldDrawerProps {\n open: boolean\n onClose: () => void\n /** The definition being edited, or `null` to create one. */\n definition: ContactFieldDefinitionDoc | null\n /**\n * Every key the org has already used, retired ones included. A key is\n * the map key values are stored under, so a retired field's key is still\n * taken: a new field reusing it would read the old values back as its own.\n */\n takenKeys: readonly string[]\n /** Receives the draft; closing on success is the caller's job. */\n onSubmit: (draft: ContactFieldDraft) => Promise<void> | void\n /**\n * The record the field describes (AGL-2661) — the Fields section's\n * current tab. Contacts when unsaid. The drawer's copy follows it, and it\n * is the one thing here a form-field mapping cares about: a website form\n * saves into a CONTACT field only, because a submission is a fact about\n * a person, so the other objects' drawers say so instead of offering it.\n */\n object?: CrmFieldObject\n}\n\n/** How the object reads in the drawer's own sentences — singular, lowercase. */\nconst OBJECT_NOUN: Record<CrmFieldObject, string> = {\n contact: 'contact',\n company: 'company',\n deal: 'deal',\n lead: 'lead',\n}\n\n/**\n * Which objects a CSV import can fill a custom field on — see\n * `CUSTOM_FIELD_OBJECT` in `record-facts.ts`, which decides what the\n * mapping menu offers. The deal and lead files carry the standard columns\n * only, so their drawers must not promise an import that would silently\n * drop the column.\n */\nconst IMPORTABLE: Record<CrmFieldObject, boolean> = {\n contact: true,\n company: true,\n deal: false,\n lead: false,\n}\n\n/**\n * What the drawer says about website forms, per object — see\n * {@link ContactFieldDrawerProps.object}.\n */\nexport function fieldDrawerMappingNote(object: CrmFieldObject): string {\n if (object === 'contact') {\n return 'A website form field can save its answer into this field.'\n }\n const filled = IMPORTABLE[object]\n ? 'is filled on the record, by CSV import or over the API.'\n : 'is filled on the record or over the API.'\n return (\n `A website form field saves into contact fields only; a ${OBJECT_NOUN[object]} ` +\n `field ${filled}`\n )\n}\n\n/** How many choices a select may declare, and how long each may be. */\nconst OPTIONS_MAX = 50\nconst OPTION_MAX_LENGTH = 120\nconst LABEL_MAX_LENGTH = 80\n\n/** One choice per line, trimmed, blanks dropped, duplicates collapsed. */\nfunction parseOptions(text: string): string[] {\n const seen = new Set<string>()\n const options: string[] = []\n for (const line of text.split('\\n')) {\n const option = line.trim().slice(0, OPTION_MAX_LENGTH)\n if (!option || seen.has(option)) continue\n seen.add(option)\n options.push(option)\n if (options.length >= OPTIONS_MAX) break\n }\n return options\n}\n\n/**\n * The one form a custom contact field is created and edited through\n * (AGL-2601).\n *\n * ## The key follows the label until the author takes it over\n *\n * A merchant types \"Annual revenue\" and should not have to know that the\n * value is stored under `annual_revenue`. So the key is derived from the\n * label as they type — `normalizeContactFieldKey` — and shown beside it, and\n * only becomes the author's own the moment they edit it. That is the whole\n * of the grammar they are asked to learn, and the field is still there for\n * the author who wants `arr` instead.\n *\n * ## Two things do not change after creation\n *\n * The KEY is the map key every value is stored under, so changing it would\n * orphan every value already written; the TYPE is what every reader coerces\n * those values by, so changing it would make them unreadable. Both are\n * disabled on an existing field and say why. A rename is a new field and a\n * retire, which the section's list offers.\n */\nexport function ContactFieldDrawer(props: ContactFieldDrawerProps) {\n const { open, onClose, definition, takenKeys, onSubmit, object = 'contact' } = props\n const editing = Boolean(definition)\n const noun = OBJECT_NOUN[object]\n\n const [label, setLabel] = useState('')\n const [key, setKey] = useState('')\n const [keyTouched, setKeyTouched] = useState(false)\n const [type, setType] = useState<ContactFieldType>('text')\n const [optionsText, setOptionsText] = useState('')\n const [required, setRequired] = useState(false)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n // Seeded on every open, from the definition when there is one: a drawer\n // that kept the previous field's draft would offer it as the next one.\n useEffect(() => {\n if (!open) return\n setLabel(definition?.label ?? '')\n setKey(definition?.key ?? '')\n setKeyTouched(Boolean(definition))\n setType(definition?.type ?? 'text')\n setOptionsText((definition?.options ?? []).join('\\n'))\n setRequired(definition?.required === true)\n setBusy(false)\n setError(null)\n }, [open, definition])\n\n const derivedKey = useMemo(\n () => (keyTouched ? normalizeContactFieldKey(key) : normalizeContactFieldKey(label)),\n [keyTouched, key, label],\n )\n const shownKey = keyTouched ? key : (derivedKey ?? '')\n const keyTaken =\n !editing && derivedKey != null && takenKeys.includes(derivedKey)\n const options = useMemo(() => parseOptions(optionsText), [optionsText])\n\n const keyHelp = editing\n ? 'Values are stored under this key, so it cannot change once the field exists.'\n : keyTaken\n ? 'That key is already in use — a retired field keeps its key.'\n : shownKey && !derivedKey\n ? 'A key starts with a letter and uses letters, digits and underscores.'\n : 'Derived from the label; edit it to choose your own.'\n\n const canSubmit =\n !busy &&\n label.trim().length > 0 &&\n derivedKey != null &&\n !keyTaken &&\n (type !== 'select' || options.length > 0)\n\n const handleSubmit = useCallback(async () => {\n if (!canSubmit || derivedKey == null) return\n setBusy(true)\n setError(null)\n try {\n await onSubmit({\n label: label.trim().slice(0, LABEL_MAX_LENGTH),\n key: derivedKey,\n type,\n options: type === 'select' ? options : [],\n required,\n })\n } catch (caught) {\n console.error(caught)\n setError('Could not save the field. Try again.')\n } finally {\n setBusy(false)\n }\n }, [canSubmit, derivedKey, onSubmit, label, type, options, required])\n\n return (\n <Drawer anchor=\"right\" open={open} onClose={onClose}>\n <Stack spacing={2} sx={{ width: 360, maxWidth: '100vw', p: 3 }}>\n <Typography variant=\"h6\">\n {editing ? `Edit ${noun} field` : `New ${noun} field`}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {fieldDrawerMappingNote(object)}\n </Typography>\n <TextField\n size=\"small\"\n label=\"Label\"\n value={label}\n onChange={(event) => setLabel(event.target.value)}\n slotProps={{ htmlInput: { maxLength: LABEL_MAX_LENGTH } }}\n autoFocus={!editing}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Key\"\n value={shownKey}\n onChange={(event) => {\n setKeyTouched(true)\n setKey(event.target.value)\n }}\n disabled={editing}\n error={!editing && (keyTaken || (shownKey.length > 0 && !derivedKey))}\n helperText={keyHelp}\n slotProps={{ htmlInput: { maxLength: 40, spellCheck: false } }}\n fullWidth\n />\n <TextField\n select\n size=\"small\"\n label=\"Type\"\n value={type}\n onChange={(event) => {\n const next = event.target.value\n if (isContactFieldType(next)) setType(next)\n }}\n disabled={editing}\n helperText={\n editing\n ? 'Values are read by type, so it is fixed once the field exists.'\n : undefined\n }\n fullWidth\n >\n {CONTACT_FIELD_TYPES.map((option) => (\n <MenuItem key={option} value={option}>\n {CONTACT_FIELD_TYPE_LABELS[option]}\n </MenuItem>\n ))}\n </TextField>\n {type === 'select' ? (\n <TextField\n size=\"small\"\n label=\"Choices\"\n value={optionsText}\n onChange={(event) => setOptionsText(event.target.value)}\n helperText=\"One per line. A stored value has to be one of these.\"\n multiline\n minRows={3}\n fullWidth\n />\n ) : null}\n <FormControlLabel\n control={\n <Switch\n checked={required}\n onChange={(event) => setRequired(event.target.checked)}\n />\n }\n label={`Required on the ${noun} form`}\n />\n {error ? (\n <Typography variant=\"body2\" color=\"error\">\n {error}\n </Typography>\n ) : null}\n <Stack direction=\"row\" spacing={1}>\n <Button\n variant=\"contained\"\n color=\"primary\"\n disabled={!canSubmit}\n onClick={handleSubmit}\n >\n {editing ? 'Save' : 'Create field'}\n </Button>\n <Button onClick={onClose} disabled={busy}>\n {'Cancel'}\n </Button>\n </Stack>\n </Stack>\n </Drawer>\n )\n}\nContactFieldDrawer.displayName = 'ContactFieldDrawer'\n\nexport default ContactFieldDrawer\n"],"names":["CONTACT_FIELD_TYPE_LABELS","CONTACT_FIELD_TYPES","isContactFieldType","normalizeContactFieldKey","Button","Drawer","FormControlLabel","MenuItem","Stack","Switch","TextField","Typography","useCallback","useEffect","useMemo","useState","OBJECT_NOUN","contact","company","deal","lead","IMPORTABLE","fieldDrawerMappingNote","object","filled","OPTIONS_MAX","OPTION_MAX_LENGTH","LABEL_MAX_LENGTH","parseOptions","text","seen","Set","options","line","split","option","trim","slice","has","add","push","length","ContactFieldDrawer","props","open","onClose","definition","takenKeys","onSubmit","editing","Boolean","noun","label","setLabel","key","setKey","keyTouched","setKeyTouched","type","setType","optionsText","setOptionsText","required","setRequired","busy","setBusy","error","setError","join","derivedKey","shownKey","keyTaken","includes","keyHelp","canSubmit","handleSubmit","caught","console","anchor","spacing","sx","width","maxWidth","p","variant","color","size","value","onChange","event","target","slotProps","htmlInput","maxLength","autoFocus","fullWidth","disabled","helperText","spellCheck","select","next","undefined","map","multiline","minRows","control","checked","direction","onClick","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-field-drawer.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n CONTACT_FIELD_TYPE_LABELS,\n CONTACT_FIELD_TYPES,\n customFieldCurrency,\n type ContactFieldType,\n type CrmFieldObject,\n isContactFieldType,\n normalizeContactFieldKey,\n} from '@aglyn/aglyn'\nimport {\n Button,\n Drawer,\n FormControlLabel,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type { ContactFieldDefinitionDoc } from '../hooks/use-contact-field-definitions'\nimport { CRM_FIELD_LABEL_MAX_LENGTH, crmFieldOptions } from '../model/crm-setup-content'\nimport { DEAL_CURRENCIES } from '../model/deal-board-model'\n\n/** What the drawer hands back — the definition's editable half. */\nexport interface ContactFieldDraft {\n label: string\n key: string\n type: ContactFieldType\n /** The choices, for `select`; empty for every other type. */\n options: string[]\n /** For `currency` (AGL-3756): the lowercase ISO 4217 code; `null` for every other type. */\n currency: string | null\n required: boolean\n}\n\nexport interface ContactFieldDrawerProps {\n open: boolean\n onClose: () => void\n /** The definition being edited, or `null` to create one. */\n definition: ContactFieldDefinitionDoc | null\n /**\n * Every key the org has already used, retired ones included. A key is\n * the map key values are stored under, so a retired field's key is still\n * taken: a new field reusing it would read the old values back as its own.\n */\n takenKeys: readonly string[]\n /** Receives the draft; closing on success is the caller's job. */\n onSubmit: (draft: ContactFieldDraft) => Promise<void> | void\n /**\n * The record the field describes (AGL-2661) — the Fields section's\n * current tab. Contacts when unsaid. The drawer's copy follows it, and it\n * is the one thing here a form-field mapping cares about: a website form\n * saves into a CONTACT field only, because a submission is a fact about\n * a person, so the other objects' drawers say so instead of offering it.\n */\n object?: CrmFieldObject\n}\n\n/** How the object reads in the drawer's own sentences — singular, lowercase. */\nconst OBJECT_NOUN: Record<CrmFieldObject, string> = {\n contact: 'contact',\n company: 'company',\n deal: 'deal',\n lead: 'lead',\n}\n\n/**\n * Which objects a CSV import can fill a custom field on — see\n * `CUSTOM_FIELD_OBJECT` in `record-facts.ts`, which decides what the\n * mapping menu offers. The deal and lead files carry the standard columns\n * only, so their drawers must not promise an import that would silently\n * drop the column.\n */\nconst IMPORTABLE: Record<CrmFieldObject, boolean> = {\n contact: true,\n company: true,\n deal: false,\n lead: false,\n}\n\n/**\n * What the drawer says about website forms, per object — see\n * {@link ContactFieldDrawerProps.object}.\n */\nexport function fieldDrawerMappingNote(object: CrmFieldObject): string {\n if (object === 'contact') {\n return 'A website form field can save its answer into this field.'\n }\n const filled = IMPORTABLE[object]\n ? 'is filled on the record, by CSV import or over the API.'\n : 'is filled on the record or over the API.'\n return (\n `A website form field saves into contact fields only; a ${OBJECT_NOUN[object]} ` +\n `field ${filled}`\n )\n}\n\n/** How long a label may be: the one bound an AI build's field is held to too (AGL-3746). */\nconst LABEL_MAX_LENGTH = CRM_FIELD_LABEL_MAX_LENGTH\n\n/** One choice per line, trimmed, blanks dropped, duplicates collapsed, under the shared bounds. */\nfunction parseOptions(text: string): string[] {\n return crmFieldOptions(text.split('\\n'))\n}\n\n/**\n * The one form a custom contact field is created and edited through\n * (AGL-2601).\n *\n * ## The key follows the label until the author takes it over\n *\n * A merchant types \"Annual revenue\" and should not have to know that the\n * value is stored under `annual_revenue`. So the key is derived from the\n * label as they type — `normalizeContactFieldKey` — and shown beside it, and\n * only becomes the author's own the moment they edit it. That is the whole\n * of the grammar they are asked to learn, and the field is still there for\n * the author who wants `arr` instead.\n *\n * ## Two things do not change after creation\n *\n * The KEY is the map key every value is stored under, so changing it would\n * orphan every value already written; the TYPE is what every reader coerces\n * those values by, so changing it would make them unreadable. Both are\n * disabled on an existing field and say why. A rename is a new field and a\n * retire, which the section's list offers.\n */\nexport function ContactFieldDrawer(props: ContactFieldDrawerProps) {\n const { open, onClose, definition, takenKeys, onSubmit, object = 'contact' } = props\n const editing = Boolean(definition)\n const noun = OBJECT_NOUN[object]\n\n const [label, setLabel] = useState('')\n const [key, setKey] = useState('')\n const [keyTouched, setKeyTouched] = useState(false)\n const [type, setType] = useState<ContactFieldType>('text')\n const [optionsText, setOptionsText] = useState('')\n const [currency, setCurrency] = useState(customFieldCurrency(null))\n const [required, setRequired] = useState(false)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n // Seeded on every open, from the definition when there is one: a drawer\n // that kept the previous field's draft would offer it as the next one.\n useEffect(() => {\n if (!open) return\n setLabel(definition?.label ?? '')\n setKey(definition?.key ?? '')\n setKeyTouched(Boolean(definition))\n setType(definition?.type ?? 'text')\n setOptionsText((definition?.options ?? []).join('\\n'))\n setCurrency(customFieldCurrency(definition))\n setRequired(definition?.required === true)\n setBusy(false)\n setError(null)\n }, [open, definition])\n\n const derivedKey = useMemo(\n () => (keyTouched ? normalizeContactFieldKey(key) : normalizeContactFieldKey(label)),\n [keyTouched, key, label],\n )\n const shownKey = keyTouched ? key : (derivedKey ?? '')\n const keyTaken =\n !editing && derivedKey != null && takenKeys.includes(derivedKey)\n const options = useMemo(() => parseOptions(optionsText), [optionsText])\n\n const keyHelp = editing\n ? 'Values are stored under this key, so it cannot change once the field exists.'\n : keyTaken\n ? 'That key is already in use — a retired field keeps its key.'\n : shownKey && !derivedKey\n ? 'A key starts with a letter and uses letters, digits and underscores.'\n : 'Derived from the label; edit it to choose your own.'\n\n const canSubmit =\n !busy &&\n label.trim().length > 0 &&\n derivedKey != null &&\n !keyTaken &&\n (type !== 'select' || options.length > 0)\n\n const handleSubmit = useCallback(async () => {\n if (!canSubmit || derivedKey == null) return\n setBusy(true)\n setError(null)\n try {\n await onSubmit({\n label: label.trim().slice(0, LABEL_MAX_LENGTH),\n key: derivedKey,\n type,\n options: type === 'select' ? options : [],\n currency: type === 'currency' ? currency : null,\n required,\n })\n } catch (caught) {\n console.error(caught)\n setError('Could not save the field. Try again.')\n } finally {\n setBusy(false)\n }\n }, [canSubmit, derivedKey, onSubmit, label, type, options, currency, required])\n\n return (\n <Drawer anchor=\"right\" open={open} onClose={onClose}>\n <Stack spacing={2} sx={{ width: 360, maxWidth: '100vw', p: 3 }}>\n <Typography variant=\"h6\">\n {editing ? `Edit ${noun} field` : `New ${noun} field`}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {fieldDrawerMappingNote(object)}\n </Typography>\n <TextField\n size=\"small\"\n label=\"Label\"\n value={label}\n onChange={(event) => setLabel(event.target.value)}\n slotProps={{ htmlInput: { maxLength: LABEL_MAX_LENGTH } }}\n autoFocus={!editing}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Key\"\n value={shownKey}\n onChange={(event) => {\n setKeyTouched(true)\n setKey(event.target.value)\n }}\n disabled={editing}\n error={!editing && (keyTaken || (shownKey.length > 0 && !derivedKey))}\n helperText={keyHelp}\n slotProps={{ htmlInput: { maxLength: 40, spellCheck: false } }}\n fullWidth\n />\n <TextField\n select\n size=\"small\"\n label=\"Type\"\n value={type}\n onChange={(event) => {\n const next = event.target.value\n if (isContactFieldType(next)) setType(next)\n }}\n disabled={editing}\n helperText={\n editing\n ? 'Values are read by type, so it is fixed once the field exists.'\n : undefined\n }\n fullWidth\n >\n {CONTACT_FIELD_TYPES.map((option) => (\n <MenuItem key={option} value={option}>\n {CONTACT_FIELD_TYPE_LABELS[option]}\n </MenuItem>\n ))}\n </TextField>\n {type === 'select' ? (\n <TextField\n size=\"small\"\n label=\"Choices\"\n value={optionsText}\n onChange={(event) => setOptionsText(event.target.value)}\n helperText=\"One per line. A stored value has to be one of these.\"\n multiline\n minRows={3}\n fullWidth\n />\n ) : null}\n {type === 'currency' ? (\n <TextField\n select\n size=\"small\"\n label=\"Currency\"\n value={currency}\n onChange={(event) => setCurrency(event.target.value)}\n disabled={editing}\n helperText={\n editing\n ? 'Amounts are stored in this currency, so it is fixed once the field exists.'\n : 'Every amount in this field is in this currency.'\n }\n fullWidth\n >\n {[...new Set<string>([...DEAL_CURRENCIES, currency])].map((code) => (\n <MenuItem key={code} value={code}>\n {code.toUpperCase()}\n </MenuItem>\n ))}\n </TextField>\n ) : null}\n <FormControlLabel\n control={\n <Switch\n checked={required}\n onChange={(event) => setRequired(event.target.checked)}\n />\n }\n label={`Required on the ${noun} form`}\n />\n {error ? (\n <Typography variant=\"body2\" color=\"error\">\n {error}\n </Typography>\n ) : null}\n <Stack direction=\"row\" spacing={1}>\n <Button\n variant=\"contained\"\n color=\"primary\"\n disabled={!canSubmit}\n onClick={handleSubmit}\n >\n {editing ? 'Save' : 'Create field'}\n </Button>\n <Button onClick={onClose} disabled={busy}>\n {'Cancel'}\n </Button>\n </Stack>\n </Stack>\n </Drawer>\n )\n}\nContactFieldDrawer.displayName = 'ContactFieldDrawer'\n\nexport default ContactFieldDrawer\n"],"names":["CONTACT_FIELD_TYPE_LABELS","CONTACT_FIELD_TYPES","customFieldCurrency","isContactFieldType","normalizeContactFieldKey","Button","Drawer","FormControlLabel","MenuItem","Stack","Switch","TextField","Typography","useCallback","useEffect","useMemo","useState","CRM_FIELD_LABEL_MAX_LENGTH","crmFieldOptions","DEAL_CURRENCIES","OBJECT_NOUN","contact","company","deal","lead","IMPORTABLE","fieldDrawerMappingNote","object","filled","LABEL_MAX_LENGTH","parseOptions","text","split","ContactFieldDrawer","props","open","onClose","definition","takenKeys","onSubmit","editing","Boolean","noun","label","setLabel","key","setKey","keyTouched","setKeyTouched","type","setType","optionsText","setOptionsText","currency","setCurrency","required","setRequired","busy","setBusy","error","setError","options","join","derivedKey","shownKey","keyTaken","includes","keyHelp","canSubmit","trim","length","handleSubmit","slice","caught","console","anchor","spacing","sx","width","maxWidth","p","variant","color","size","value","onChange","event","target","slotProps","htmlInput","maxLength","autoFocus","fullWidth","disabled","helperText","spellCheck","select","next","undefined","map","option","multiline","minRows","Set","code","toUpperCase","control","checked","direction","onClick","displayName"],"mappings":"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"}
@@ -53,7 +53,7 @@ import { useCrmApi } from "./use-crm-api.js";
53
53
  import { useOrgMembers } from "./use-org-members.js";
54
54
  import { useContactFieldDefinitions } from "../hooks/use-contact-field-definitions.js";
55
55
  import { useLeadSourcePicklist } from "../hooks/use-lead-source-picklist.js";
56
- import { customFieldColumns } from "./contact-custom-columns.js";
56
+ import { contactCustomAmountReaders, customFieldColumns } from "./contact-custom-columns.js";
57
57
  import { useCrmSavedView } from "../hooks/use-crm-saved-view.js";
58
58
  import { useCrmViewGrid } from "../hooks/use-crm-view-grid.js";
59
59
  import { CRM_LIST_SLOTS, CrmColumnOrderProvider } from "./crm-column-menu.js";
@@ -272,13 +272,19 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
272
272
  * particular sites.
273
273
  */ const foldsScope = useCrmFoldsScope(orgId, visibleToTokens);
274
274
  const groupId = (_ref3 = consentGroup == null ? void 0 : consentGroup.groupId) != null ? _ref3 : null;
275
+ // A currency field's `=` is typed in major units (AGL-3756).
276
+ const amounts = useMemo(()=>contactCustomAmountReaders(customFields.active), [
277
+ customFields.active
278
+ ]);
275
279
  const asked = useMemo(()=>crmAskClauses(views.state.filters, (clause)=>contactQueryClause(clause, {
276
280
  groupId,
277
- foldsScope
281
+ foldsScope,
282
+ amounts
278
283
  })), [
279
284
  views.state.filters,
280
285
  groupId,
281
- foldsScope
286
+ foldsScope,
287
+ amounts
282
288
  ]);
283
289
  const impliesScope = useMemo(()=>contactClauseImpliesScope(groupId), [
284
290
  groupId