@ketvietlab/ketsuite 0.1.22 → 0.1.25

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 (237) hide show
  1. package/dist/cli.js +9 -3
  2. package/dist/cli.js.map +1 -1
  3. package/dist/demo/crm.d.ts +3 -0
  4. package/dist/demo/crm.d.ts.map +1 -0
  5. package/dist/demo/crm.js +418 -0
  6. package/dist/demo/crm.js.map +1 -0
  7. package/dist/demo/partner.d.ts +8 -0
  8. package/dist/demo/partner.d.ts.map +1 -0
  9. package/dist/demo/partner.js +179 -0
  10. package/dist/demo/partner.js.map +1 -0
  11. package/dist/demo/product.d.ts +25 -0
  12. package/dist/demo/product.d.ts.map +1 -0
  13. package/dist/demo/product.js +579 -0
  14. package/dist/demo/product.js.map +1 -0
  15. package/dist/demo/purchase.d.ts +4 -0
  16. package/dist/demo/purchase.d.ts.map +1 -0
  17. package/dist/demo/purchase.js +58 -0
  18. package/dist/demo/purchase.js.map +1 -0
  19. package/dist/demo/sale.d.ts +4 -0
  20. package/dist/demo/sale.d.ts.map +1 -0
  21. package/dist/demo/sale.js +52 -0
  22. package/dist/demo/sale.js.map +1 -0
  23. package/dist/demo/seed.d.ts +14 -0
  24. package/dist/demo/seed.d.ts.map +1 -0
  25. package/dist/demo/seed.js +88 -0
  26. package/dist/demo/seed.js.map +1 -0
  27. package/dist/demo/stock.d.ts +7 -0
  28. package/dist/demo/stock.d.ts.map +1 -0
  29. package/dist/demo/stock.js +26 -0
  30. package/dist/demo/stock.js.map +1 -0
  31. package/dist/demo/user.d.ts +4 -0
  32. package/dist/demo/user.d.ts.map +1 -0
  33. package/dist/demo/user.js +41 -0
  34. package/dist/demo/user.js.map +1 -0
  35. package/dist/demo/util.d.ts +7 -0
  36. package/dist/demo/util.d.ts.map +1 -0
  37. package/dist/demo/util.js +19 -0
  38. package/dist/demo/util.js.map +1 -0
  39. package/dist/modules/backend/design/client/backend-shell.d.mts +2 -2
  40. package/dist/modules/backend/design/client/backend-shell.d.mts.map +1 -1
  41. package/dist/modules/backend/design/client/backend-shell.mjs +1 -1
  42. package/dist/modules/backend/design/client/backend-shell.mjs.map +3 -3
  43. package/dist/modules/backend/design/forms.css +7 -0
  44. package/dist/modules/backend/screen.d.ts.map +1 -1
  45. package/dist/modules/backend/screen.js +11 -2
  46. package/dist/modules/backend/screen.js.map +1 -1
  47. package/dist/modules/crm/functions/access.d.ts +3 -0
  48. package/dist/modules/crm/functions/access.d.ts.map +1 -0
  49. package/dist/modules/crm/functions/access.js +89 -0
  50. package/dist/modules/crm/functions/access.js.map +1 -0
  51. package/dist/modules/crm/functions/activities.d.ts +3 -0
  52. package/dist/modules/crm/functions/activities.d.ts.map +1 -0
  53. package/dist/modules/crm/functions/activities.js +174 -0
  54. package/dist/modules/crm/functions/activities.js.map +1 -0
  55. package/dist/modules/crm/functions/cases.d.ts +3 -0
  56. package/dist/modules/crm/functions/cases.d.ts.map +1 -0
  57. package/dist/modules/crm/functions/cases.js +585 -0
  58. package/dist/modules/crm/functions/cases.js.map +1 -0
  59. package/dist/modules/crm/functions/configuration.d.ts +3 -0
  60. package/dist/modules/crm/functions/configuration.d.ts.map +1 -0
  61. package/dist/modules/crm/functions/configuration.js +331 -0
  62. package/dist/modules/crm/functions/configuration.js.map +1 -0
  63. package/dist/modules/crm/functions/gamification.d.ts +3 -0
  64. package/dist/modules/crm/functions/gamification.d.ts.map +1 -0
  65. package/dist/modules/crm/functions/gamification.js +58 -0
  66. package/dist/modules/crm/functions/gamification.js.map +1 -0
  67. package/dist/modules/crm/functions/index.d.ts +4 -0
  68. package/dist/modules/crm/functions/index.d.ts.map +1 -0
  69. package/dist/modules/crm/functions/index.js +33 -0
  70. package/dist/modules/crm/functions/index.js.map +1 -0
  71. package/dist/modules/crm/functions/modal-context.d.ts +3 -0
  72. package/dist/modules/crm/functions/modal-context.d.ts.map +1 -0
  73. package/dist/modules/crm/functions/modal-context.js +248 -0
  74. package/dist/modules/crm/functions/modal-context.js.map +1 -0
  75. package/dist/modules/crm/functions/previews.d.ts +3 -0
  76. package/dist/modules/crm/functions/previews.d.ts.map +1 -0
  77. package/dist/modules/crm/functions/previews.js +20 -0
  78. package/dist/modules/crm/functions/previews.js.map +1 -0
  79. package/dist/modules/crm/functions/shared.d.ts +31 -0
  80. package/dist/modules/crm/functions/shared.d.ts.map +1 -0
  81. package/dist/modules/crm/functions/shared.js +194 -0
  82. package/dist/modules/crm/functions/shared.js.map +1 -0
  83. package/dist/modules/crm/index.d.ts +1 -1
  84. package/dist/modules/crm/index.d.ts.map +1 -1
  85. package/dist/modules/crm/index.js +2 -2
  86. package/dist/modules/crm/index.js.map +1 -1
  87. package/dist/modules/crm/messages.d.ts +4 -1
  88. package/dist/modules/crm/messages.d.ts.map +1 -1
  89. package/dist/modules/crm/messages.js +6 -0
  90. package/dist/modules/crm/messages.js.map +1 -1
  91. package/dist/modules/crm/record-modal-labels.d.ts +9 -0
  92. package/dist/modules/crm/record-modal-labels.d.ts.map +1 -0
  93. package/dist/modules/crm/record-modal-labels.js +36 -0
  94. package/dist/modules/crm/record-modal-labels.js.map +1 -0
  95. package/dist/modules/crm/types.d.ts +5 -0
  96. package/dist/modules/crm/types.d.ts.map +1 -1
  97. package/dist/modules/crm/types.js +9 -0
  98. package/dist/modules/crm/types.js.map +1 -1
  99. package/dist/modules/crm_backend/client/crm-configuration-modal.d.mts +352 -0
  100. package/dist/modules/crm_backend/client/crm-configuration-modal.d.mts.map +1 -0
  101. package/dist/modules/crm_backend/client/crm-configuration-modal.mjs +2 -0
  102. package/dist/modules/crm_backend/client/crm-configuration-modal.mjs.map +7 -0
  103. package/dist/modules/crm_backend/index.d.ts.map +1 -1
  104. package/dist/modules/crm_backend/index.js +5 -1
  105. package/dist/modules/crm_backend/index.js.map +1 -1
  106. package/dist/modules/crm_backend/islands.d.ts +27 -0
  107. package/dist/modules/crm_backend/islands.d.ts.map +1 -1
  108. package/dist/modules/crm_backend/islands.js +22 -0
  109. package/dist/modules/crm_backend/islands.js.map +1 -1
  110. package/dist/modules/crm_backend/messages.d.ts +30 -0
  111. package/dist/modules/crm_backend/messages.d.ts.map +1 -1
  112. package/dist/modules/crm_backend/messages.js +30 -0
  113. package/dist/modules/crm_backend/messages.js.map +1 -1
  114. package/dist/modules/crm_backend/modal/configuration-modal-view.d.ts +55 -0
  115. package/dist/modules/crm_backend/modal/configuration-modal-view.d.ts.map +1 -0
  116. package/dist/modules/crm_backend/modal/configuration-modal-view.js +601 -0
  117. package/dist/modules/crm_backend/modal/configuration-modal-view.js.map +1 -0
  118. package/dist/modules/crm_backend/routes.d.ts.map +1 -1
  119. package/dist/modules/crm_backend/routes.js +69 -359
  120. package/dist/modules/crm_backend/routes.js.map +1 -1
  121. package/dist/modules/crm_backend/screens/configuration.d.ts +24 -32
  122. package/dist/modules/crm_backend/screens/configuration.d.ts.map +1 -1
  123. package/dist/modules/crm_backend/screens/configuration.js +183 -274
  124. package/dist/modules/crm_backend/screens/configuration.js.map +1 -1
  125. package/dist/modules/crm_backend/screens/index.d.ts +2 -2
  126. package/dist/modules/crm_backend/screens/index.d.ts.map +1 -1
  127. package/dist/modules/crm_backend/screens/index.js +1 -1
  128. package/dist/modules/crm_backend/screens/index.js.map +1 -1
  129. package/dist/modules/product/functions.d.ts +14 -1
  130. package/dist/modules/product/functions.d.ts.map +1 -1
  131. package/dist/modules/product/functions.js +36 -31
  132. package/dist/modules/product/functions.js.map +1 -1
  133. package/dist/modules/product/product-modal-context.d.ts +3 -0
  134. package/dist/modules/product/product-modal-context.d.ts.map +1 -0
  135. package/dist/modules/product/product-modal-context.js +173 -0
  136. package/dist/modules/product/product-modal-context.js.map +1 -0
  137. package/dist/modules/product/product-record-tabs.d.ts +14 -0
  138. package/dist/modules/product/product-record-tabs.d.ts.map +1 -0
  139. package/dist/modules/product/product-record-tabs.js +23 -0
  140. package/dist/modules/product/product-record-tabs.js.map +1 -0
  141. package/dist/modules/product_backend/client/product-modal.d.mts +144 -0
  142. package/dist/modules/product_backend/client/product-modal.d.mts.map +1 -0
  143. package/dist/modules/product_backend/client/product-modal.mjs +2 -0
  144. package/dist/modules/product_backend/client/product-modal.mjs.map +7 -0
  145. package/dist/modules/product_backend/index.d.ts.map +1 -1
  146. package/dist/modules/product_backend/index.js +25 -0
  147. package/dist/modules/product_backend/index.js.map +1 -1
  148. package/dist/modules/product_backend/islands.d.ts +1 -0
  149. package/dist/modules/product_backend/islands.d.ts.map +1 -1
  150. package/dist/modules/product_backend/islands.js +9 -0
  151. package/dist/modules/product_backend/islands.js.map +1 -1
  152. package/dist/modules/product_backend/modal/product-modal-view.d.ts +49 -0
  153. package/dist/modules/product_backend/modal/product-modal-view.d.ts.map +1 -0
  154. package/dist/modules/product_backend/modal/product-modal-view.js +609 -0
  155. package/dist/modules/product_backend/modal/product-modal-view.js.map +1 -0
  156. package/dist/modules/product_backend/routes.d.ts.map +1 -1
  157. package/dist/modules/product_backend/routes.js +44 -330
  158. package/dist/modules/product_backend/routes.js.map +1 -1
  159. package/dist/modules/product_backend/screens/detail.d.ts.map +1 -1
  160. package/dist/modules/product_backend/screens/detail.js +6 -108
  161. package/dist/modules/product_backend/screens/detail.js.map +1 -1
  162. package/dist/modules/product_backend/screens/index.d.ts +0 -1
  163. package/dist/modules/product_backend/screens/index.d.ts.map +1 -1
  164. package/dist/modules/product_backend/screens/index.js +0 -1
  165. package/dist/modules/product_backend/screens/index.js.map +1 -1
  166. package/dist/modules/product_backend/screens/list.d.ts +3 -1
  167. package/dist/modules/product_backend/screens/list.d.ts.map +1 -1
  168. package/dist/modules/product_backend/screens/list.js +6 -5
  169. package/dist/modules/product_backend/screens/list.js.map +1 -1
  170. package/dist/modules/user/authorization.d.ts.map +1 -1
  171. package/dist/modules/user/authorization.js +114 -0
  172. package/dist/modules/user/authorization.js.map +1 -1
  173. package/dist/modules/user/functions.d.ts.map +1 -1
  174. package/dist/modules/user/functions.js +2 -0
  175. package/dist/modules/user/functions.js.map +1 -1
  176. package/dist/modules/user/user-modal-context.d.ts +3 -0
  177. package/dist/modules/user/user-modal-context.d.ts.map +1 -0
  178. package/dist/modules/user/user-modal-context.js +151 -0
  179. package/dist/modules/user/user-modal-context.js.map +1 -0
  180. package/dist/modules/user_backend/client/user-modal.d.mts +72 -0
  181. package/dist/modules/user_backend/client/user-modal.d.mts.map +1 -0
  182. package/dist/modules/user_backend/client/user-modal.mjs +2 -0
  183. package/dist/modules/user_backend/client/user-modal.mjs.map +7 -0
  184. package/dist/modules/user_backend/index.d.ts.map +1 -1
  185. package/dist/modules/user_backend/index.js +9 -0
  186. package/dist/modules/user_backend/index.js.map +1 -1
  187. package/dist/modules/user_backend/islands.d.ts +15 -0
  188. package/dist/modules/user_backend/islands.d.ts.map +1 -0
  189. package/dist/modules/user_backend/islands.js +14 -0
  190. package/dist/modules/user_backend/islands.js.map +1 -0
  191. package/dist/modules/user_backend/messages.d.ts +30 -0
  192. package/dist/modules/user_backend/messages.d.ts.map +1 -1
  193. package/dist/modules/user_backend/messages.js +30 -0
  194. package/dist/modules/user_backend/messages.js.map +1 -1
  195. package/dist/modules/user_backend/modal/user-modal-view.d.ts +26 -0
  196. package/dist/modules/user_backend/modal/user-modal-view.d.ts.map +1 -0
  197. package/dist/modules/user_backend/modal/user-modal-view.js +320 -0
  198. package/dist/modules/user_backend/modal/user-modal-view.js.map +1 -0
  199. package/dist/modules/user_backend/routes.d.ts.map +1 -1
  200. package/dist/modules/user_backend/routes.js +15 -3
  201. package/dist/modules/user_backend/routes.js.map +1 -1
  202. package/dist/permission-catalogue.d.ts.map +1 -1
  203. package/dist/permission-catalogue.js +8 -0
  204. package/dist/permission-catalogue.js.map +1 -1
  205. package/dist/scaffold/index.js +1 -1
  206. package/dist/ui/client/record-modal-form.d.ts +67 -0
  207. package/dist/ui/client/record-modal-form.d.ts.map +1 -0
  208. package/dist/ui/client/record-modal-form.js +45 -0
  209. package/dist/ui/client/record-modal-form.js.map +1 -0
  210. package/dist/ui/client/record-modal.d.ts +103 -6
  211. package/dist/ui/client/record-modal.d.ts.map +1 -1
  212. package/dist/ui/client/record-modal.js +333 -65
  213. package/dist/ui/client/record-modal.js.map +1 -1
  214. package/dist/ui/index.d.ts +2 -1
  215. package/dist/ui/index.d.ts.map +1 -1
  216. package/dist/ui/index.js +2 -1
  217. package/dist/ui/index.js.map +1 -1
  218. package/dist/ui/nav.d.ts +7 -0
  219. package/dist/ui/nav.d.ts.map +1 -1
  220. package/dist/ui/nav.js +1 -1
  221. package/dist/ui/nav.js.map +1 -1
  222. package/dist/ui/record-modal.d.ts +15 -0
  223. package/dist/ui/record-modal.d.ts.map +1 -1
  224. package/dist/ui/record-modal.js +12 -0
  225. package/dist/ui/record-modal.js.map +1 -1
  226. package/dist/ui/table.d.ts.map +1 -1
  227. package/dist/ui/table.js +1 -1
  228. package/dist/ui/table.js.map +1 -1
  229. package/package.json +4 -4
  230. package/dist/modules/crm/functions.d.ts +0 -10
  231. package/dist/modules/crm/functions.d.ts.map +0 -1
  232. package/dist/modules/crm/functions.js +0 -1394
  233. package/dist/modules/crm/functions.js.map +0 -1
  234. package/dist/modules/product_backend/screens/create.d.ts +0 -16
  235. package/dist/modules/product_backend/screens/create.d.ts.map +0 -1
  236. package/dist/modules/product_backend/screens/create.js +0 -86
  237. package/dist/modules/product_backend/screens/create.js.map +0 -1
@@ -1,9 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { NAVIGATION_TYPE, encodeListState, fragment, json, parseListState, table, text, validateListState, withHeaders, } from '@ketvietlab/ketjs';
3
- import { attributesScreen, favoriteModal, newProductScreen, productDetailScreen, productsScreen, VARIANT_DETAIL_TABS, variantScreen, VIEWS, } from './screens/index.js';
4
- import { attributeControl, attributeValuesControl, brandControl, categoryControl, uomControl, } from './relation-control.js';
3
+ import { attributesScreen, favoriteModal, productDetailScreen, productsScreen, VARIANT_DETAIL_TABS, variantScreen, VIEWS, } from './screens/index.js';
4
+ import { uomControl } from './relation-control.js';
5
5
  import { PAGE_SIZE, colsHref, colsOf, pager, withParam } from '../backend/paging.js';
6
6
  import { backendPage, modalWorkspace } from '../../ui/index.js';
7
+ import { recordModalCreateHref, recordModalHref } from '../../ui/record-modal.js';
7
8
  import { receiveAttachment } from '../storage/routes.js';
8
9
  import { errorsOf, readForm, seeOther } from '../backend/forms.js';
9
10
  import { productListSearch } from '../product/search.js';
@@ -34,7 +35,6 @@ const refusePost = (req, accepts = 'POST') => req.method !== 'POST'
34
35
  : null;
35
36
  const productTabOf = (url) => url.searchParams.get('tab') || 'general';
36
37
  const MEDIA_VARIANT_PAGE_SIZE = 25;
37
- const VARIANT_PAGE_SIZE = 10;
38
38
  const positivePage = (value) => {
39
39
  const page = Number.parseInt(value ?? '1', 10);
40
40
  return Number.isFinite(page) && page > 0 ? page : 1;
@@ -42,7 +42,6 @@ const positivePage = (value) => {
42
42
  const requestedVariantMediaPage = (url) => {
43
43
  return positivePage(url.searchParams.get('variantPage'));
44
44
  };
45
- const requestedVariantPage = (url) => positivePage(url.searchParams.get('page'));
46
45
  const variantTabOf = (url) => {
47
46
  const asked = url.searchParams.get('tab');
48
47
  return VARIANT_DETAIL_TABS.includes(asked ?? '')
@@ -106,17 +105,6 @@ const unitRootOf = (units, uomId) => {
106
105
  return unit ? (String(unit.parentPath).split('/').filter(Boolean)[0] ?? null) : null;
107
106
  };
108
107
  const invalidErrors = (url, _) => url.searchParams.has('invalid') ? [_('product_backend.error.invalid')] : undefined;
109
- const stockEnabled = async (ctx, req) => Boolean((await ctx.live(req)).functions['stock.configureProduct']);
110
- const TRACKING = ['none', 'lot', 'serial'];
111
- const validStockForm = (form) => {
112
- const tracking = form.tracking || 'none';
113
- return (TRACKING.includes(tracking) && (form.isStorable === '1' || tracking === 'none'));
114
- };
115
- const configureStock = (ctx, url, req, templateId, form) => ctx.call('stock.configureProduct', {
116
- templateId,
117
- isStorable: form.isStorable === '1',
118
- tracking: form.tracking || 'none',
119
- }, url, req);
120
108
  const templateRow = (row) => ({
121
109
  id: row.id,
122
110
  name: row.name,
@@ -532,12 +520,12 @@ export const routes = {
532
520
  title: 'KetSuite',
533
521
  translate: false,
534
522
  body: (_, frame) => {
535
- const workspace = productsScreen(_, decoratedRows, view, {
523
+ const workspace = productsScreen(_, decoratedRows, view, (id) => recordModalHref(url, { kind: 'product.template', id }), {
536
524
  ...frame,
537
525
  chrome: {
538
526
  create: {
539
527
  label: _('product_backend.create.title'),
540
- path: inLocale(url, '/admin/product/templates/new'),
528
+ path: recordModalCreateHref(url, { kind: 'product.template' }),
541
529
  },
542
530
  selection,
543
531
  search: {
@@ -557,7 +545,7 @@ export const routes = {
557
545
  active: v === view,
558
546
  })),
559
547
  },
560
- }, { shown: colsOf(url), colsHref: colsHref(url), groups, selection }, localeQuery(url), count, extensionActions);
548
+ }, { shown: colsOf(url), colsHref: colsHref(url), groups, selection }, count, extensionActions);
561
549
  if (url.searchParams.get('modal') !== 'favorite')
562
550
  return workspace;
563
551
  const returnUrl = new URL(url);
@@ -639,59 +627,6 @@ export const routes = {
639
627
  }
640
628
  return seeOther(modalHref());
641
629
  },
642
- '/admin/product/templates/new': (ctx) => async (url, req) => {
643
- const lang = ctx.localeOf(url, req);
644
- const _ = ctx.translate(lang);
645
- const hasStock = await stockEnabled(ctx, req);
646
- if (req.method === 'POST') {
647
- if (crossSite(req))
648
- return text('Forbidden', { status: 403 });
649
- const form = await readForm(req);
650
- if (hasStock && !validStockForm(form))
651
- return seeOther(inLocale(url, '/admin/product/templates/new?invalid=1&count=1'));
652
- const id = randomUUID();
653
- const result = await ctx.call('product.saveTemplate', {
654
- id,
655
- name: form.name ?? '',
656
- type: form.type || 'goods',
657
- // Sent as null rather than omitted: an absent key is skipped by the
658
- // changeset, so leaving it out would make the form's empty "—" option
659
- // a no-op and the field impossible to clear once set.
660
- uomId: form.uomId || null,
661
- categoryId: form.categoryId || null,
662
- brandId: form.brandId || null,
663
- origin: form.origin || null,
664
- description: form.description || null,
665
- listPrice: form.listPrice || '0',
666
- saleOk: form.saleOk === '1',
667
- purchaseOk: form.purchaseOk === '1',
668
- ...(Object.hasOwn(form, 'defaultCode') ? { defaultCode: form.defaultCode || null } : {}),
669
- ...(Object.hasOwn(form, 'barcode') ? { barcode: form.barcode || null } : {}),
670
- }, url, req);
671
- if (!result.ok)
672
- return seeOther(inLocale(url, `/admin/product/templates/new?invalid=1&count=${errorsOf(result).length}`));
673
- if (hasStock) {
674
- const stockResult = await configureStock(ctx, url, req, id, form);
675
- if (!stockResult.ok)
676
- return seeOther(inLocale(url, `/admin/product/templates/${id}?invalid=1&count=${errorsOf(stockResult).length}`));
677
- }
678
- return seeProduct(id, url);
679
- }
680
- if (req.method !== 'GET')
681
- return text('GET or POST', { status: 405 });
682
- const options = await optionsFor(ctx, url, req);
683
- const controls = {
684
- uom: await uomControl(ctx, url, req, _, { id: 'product-create-uom', units: options.uoms }),
685
- category: await categoryControl(ctx, url, req, _, {
686
- id: 'product-create-category',
687
- categories: options.categories,
688
- }),
689
- };
690
- return adminPage(ctx, url, req, {
691
- title: 'product_backend.create.title',
692
- body: (_, frame) => newProductScreen(_, { ...options, stockEnabled: hasStock, errors: invalidErrors(url, _), controls }, frame, localeQuery(url)),
693
- });
694
- },
695
630
  '/admin/product/attributes': (ctx) => async (url, req) => {
696
631
  const lang = ctx.localeOf(url, req);
697
632
  const _ = ctx.translate(lang);
@@ -741,117 +676,33 @@ export const routes = {
741
676
  : seeOther(inLocale(url, '/admin/product/attributes?invalid=1'));
742
677
  },
743
678
  '/admin/product/templates/{id}': (ctx) => async (url, req, params) => {
679
+ // General and Variants now open in the record-modal (`product.template`);
680
+ // this route only still renders a page for Media, which the modal does not
681
+ // cover yet (see product_backend/modal/product-modal-view.tsx).
682
+ const askedTab = productTabOf(url);
683
+ if (req.method !== 'GET' || askedTab !== 'media') {
684
+ if (req.method !== 'GET')
685
+ return text('GET', { status: 405 });
686
+ return seeOther(recordModalHref(inLocale(url, '/admin/product/templates'), {
687
+ kind: 'product.template',
688
+ id: params.id,
689
+ tab: askedTab === 'variants' ? 'variants' : 'general',
690
+ }));
691
+ }
744
692
  const lang = ctx.localeOf(url, req);
745
693
  const _ = ctx.translate(lang);
746
- const live = await ctx.live(req);
747
- const hasStock = await stockEnabled(ctx, req);
748
- const hasProductTax = Boolean(live.functions['account.getProductTax'] && live.functions['account.setProductTax']);
749
- const activeTab = productTabOf(url);
750
694
  const locale = localeQuery(url);
751
- const querySuffix = locale ? locale.replace(/^\?/, '&') : '';
752
- let savedPartial = false;
753
- if (req.method === 'POST') {
754
- if (crossSite(req))
755
- return text('Forbidden', { status: 403 });
756
- const partial = isProductPartial(req);
757
- const form = await readForm(req);
758
- if (hasStock && !validStockForm(form)) {
759
- if (partial)
760
- return json({ ok: false, message: _('product_backend.error.invalid'), errors: ['tracking'] }, { status: 422 });
761
- return seeOther(inLocale(url, `/admin/product/templates/${params.id}?invalid=1&count=1`));
762
- }
763
- const result = await ctx.call('product.saveTemplate', {
764
- id: params.id,
765
- name: form.name ?? '',
766
- type: form.type || 'goods',
767
- // Sent as null rather than omitted: an absent key is skipped by the
768
- // changeset, so leaving it out would make the form's empty "—" option
769
- // a no-op and the field impossible to clear once set.
770
- uomId: form.uomId || null,
771
- categoryId: form.categoryId || null,
772
- brandId: form.brandId || null,
773
- origin: form.origin || null,
774
- description: form.description || null,
775
- listPrice: form.listPrice || '0',
776
- saleOk: form.saleOk === '1',
777
- purchaseOk: form.purchaseOk === '1',
778
- ...(Object.hasOwn(form, 'defaultCode') ? { defaultCode: form.defaultCode || null } : {}),
779
- ...(Object.hasOwn(form, 'barcode') ? { barcode: form.barcode || null } : {}),
780
- }, url, req);
781
- if (!result.ok) {
782
- if (partial)
783
- return json({ ok: false, message: _('product_backend.error.invalid'), errors: errorsOf(result) }, { status: 422 });
784
- return seeOther(inLocale(url, `/admin/product/templates/${params.id}?invalid=1&count=${errorsOf(result).length}`));
785
- }
786
- if (hasStock) {
787
- const stockResult = await configureStock(ctx, url, req, params.id, form);
788
- if (!stockResult.ok) {
789
- if (partial)
790
- return json({
791
- ok: false,
792
- message: _('product_backend.error.invalid'),
793
- errors: errorsOf(stockResult),
794
- }, { status: 422 });
795
- return seeOther(inLocale(url, `/admin/product/templates/${params.id}?invalid=1&count=${errorsOf(stockResult).length}`));
796
- }
797
- }
798
- if (hasProductTax && Object.hasOwn(form, 'taxId')) {
799
- const taxResult = await ctx.call('account.setProductTax', { templateId: params.id, taxId: form.taxId || null }, url, req);
800
- if (!taxResult.ok) {
801
- if (partial)
802
- return json({
803
- ok: false,
804
- message: _('product_backend.error.invalid'),
805
- errors: errorsOf(taxResult),
806
- }, { status: 422 });
807
- return seeOther(inLocale(url, `/admin/product/templates/${params.id}?invalid=1&count=${errorsOf(taxResult).length}`));
808
- }
809
- }
810
- if (!partial)
811
- return seeProduct(params.id, url);
812
- savedPartial = true;
813
- }
814
- if (req.method !== 'GET' && !savedPartial)
815
- return text('GET or POST', { status: 405 });
816
695
  const row = (await ctx.call('product.getTemplate', { id: params.id }, url, req));
817
696
  if (!row)
818
697
  return text('Product not found', { status: 404 });
819
- const [mediaRows, listedVariants, options, stockConfig, attributeLines, currentTax] = await Promise.all([
820
- mediaFor(ctx, url, req, row.id),
821
- ctx.call('product.listVariants', { templateId: row.id }, url, req),
822
- optionsFor(ctx, url, req),
823
- hasStock
824
- ? ctx.call('stock.getProductConfig', { templateId: row.id }, url, req)
825
- : Promise.resolve(null),
826
- ctx.call('product.listAttributeLines', { templateId: row.id }, url, req),
827
- hasProductTax
828
- ? ctx.call('account.getProductTax', { templateId: row.id }, url, req)
829
- : Promise.resolve(null),
830
- ]);
831
- const defaultVariant = (row.variants ?? []).find((variant) => String(variant.combinationKey ?? '') === '');
698
+ const listedVariants = (await ctx.call('product.listVariants', { templateId: row.id }, url, req));
832
699
  const variants = listedVariants.filter((variant) => String(variant.combinationKey ?? '') !== '');
833
- const variantPageCount = Math.max(1, Math.ceil(variants.length / VARIANT_PAGE_SIZE));
834
- const variantPage = Math.min(requestedVariantPage(url), variantPageCount);
835
- const variantStart = (variantPage - 1) * VARIANT_PAGE_SIZE;
836
- const visibleVariants = variants.slice(variantStart, variantStart + VARIANT_PAGE_SIZE);
837
- const stockByVariant = new Map();
838
- if (activeTab === 'variants' && hasStock && live.functions['stock.forecast']) {
839
- const forecasts = await Promise.all(visibleVariants.map(async (variant) => ({
840
- id: variant.id,
841
- forecast: (await ctx.call('stock.forecast', { productId: variant.id }, url, req)),
842
- })));
843
- for (const entry of forecasts)
844
- stockByVariant.set(String(entry.id), String(entry.forecast.onHand ?? '0'));
845
- }
700
+ const [mediaRows] = await Promise.all([mediaFor(ctx, url, req, row.id)]);
846
701
  const variantMediaPageCount = Math.max(1, Math.ceil(variants.length / MEDIA_VARIANT_PAGE_SIZE));
847
702
  const variantMediaPage = Math.min(requestedVariantMediaPage(url), variantMediaPageCount);
848
703
  const variantMediaStart = (variantMediaPage - 1) * MEDIA_VARIANT_PAGE_SIZE;
849
- const visibleMediaVariants = activeTab === 'media'
850
- ? variants.slice(variantMediaStart, variantMediaStart + MEDIA_VARIANT_PAGE_SIZE)
851
- : variants;
852
- const variantMediaRows = activeTab === 'media'
853
- ? (await ctx.call('product_media.listMediaByProducts', { productIds: visibleMediaVariants.map((variant) => variant.id) }, url, req))
854
- : [];
704
+ const visibleMediaVariants = variants.slice(variantMediaStart, variantMediaStart + MEDIA_VARIANT_PAGE_SIZE);
705
+ const variantMediaRows = (await ctx.call('product_media.listMediaByProducts', { productIds: visibleMediaVariants.map((variant) => variant.id) }, url, req));
855
706
  const variantMedia = visibleMediaVariants.map((variant) => ({
856
707
  variantId: variant.id,
857
708
  images: variantMediaRows
@@ -866,22 +717,14 @@ export const routes = {
866
717
  },
867
718
  })),
868
719
  }));
869
- const body = productDetailScreen(_, {
870
- ...row,
871
- ...stockConfig,
872
- defaultCode: defaultVariant?.defaultCode ?? null,
873
- barcode: defaultVariant?.barcode ?? null,
874
- taxId: currentTax?.taxId ?? null,
875
- }, {
720
+ const body = productDetailScreen(_, { ...row, defaultCode: null, barcode: null, taxId: null }, {
876
721
  status: 'ready',
877
722
  uploadAction: inLocale(url, `/admin/product/templates/${row.id}/media?tab=media`),
878
- uploadControl: savedPartial
879
- ? ''
880
- : await ctx.joint(url, req, 'product_backend:media.upload', {
881
- identity: `template:${row.id}`,
882
- action: inLocale(url, `/admin/product/templates/${row.id}/media?tab=media`),
883
- label: _('product_backend.media.add'),
884
- }),
723
+ uploadControl: await ctx.joint(url, req, 'product_backend:media.upload', {
724
+ identity: `template:${row.id}`,
725
+ action: inLocale(url, `/admin/product/templates/${row.id}/media?tab=media`),
726
+ label: _('product_backend.media.add'),
727
+ }),
885
728
  images: mediaRows.map((image, index) => ({
886
729
  id: image.id,
887
730
  src: `/files/${image.attachmentId}`,
@@ -902,158 +745,29 @@ export const routes = {
902
745
  : {}),
903
746
  },
904
747
  })),
905
- extension: savedPartial
906
- ? ''
907
- : await ctx.joint(url, req, 'product_backend:template.media', {
908
- templateId: row.id,
909
- }),
748
+ extension: await ctx.joint(url, req, 'product_backend:template.media', { templateId: row.id }),
910
749
  }, {
911
- ...options,
912
- variants: (activeTab === 'media'
913
- ? visibleMediaVariants
914
- : activeTab === 'variants'
915
- ? visibleVariants
916
- : variants).map((variant) => ({
917
- ...variant,
918
- ...(stockByVariant.has(String(variant.id))
919
- ? { stock: stockByVariant.get(String(variant.id)) }
920
- : {}),
921
- })),
922
- variantPage: {
923
- page: variantPage,
924
- pageSize: VARIANT_PAGE_SIZE,
925
- total: variants.length,
926
- },
750
+ uoms: [],
751
+ categories: [],
752
+ brands: [],
753
+ taxes: [],
754
+ variantAttributes: [],
755
+ // The media panel renders exactly the `variants` it is given — the
756
+ // caller does the paging, unlike the variants tab's own table.
757
+ variants: visibleMediaVariants,
758
+ attributeLines: [],
927
759
  variantMedia,
928
760
  variantMediaPage: {
929
761
  page: variantMediaPage,
930
762
  pageSize: MEDIA_VARIANT_PAGE_SIZE,
931
763
  total: variants.length,
932
764
  },
933
- attributeLines: attributeLines.filter((line) => options.variantAttributes.some((attribute) => attribute.value === line.attributeId)),
934
- stockEnabled: hasStock,
935
- errors: invalidErrors(url, _),
936
- actions: savedPartial
937
- ? ''
938
- : await ctx.joint(url, req, 'product_backend:template.actions', {
939
- templateId: row.id,
940
- locale,
941
- }),
942
- tabs: savedPartial
943
- ? ''
944
- : await ctx.joint(url, req, 'product_backend:template.tabs', {
945
- templateId: row.id,
946
- activeTab,
947
- locale,
948
- querySuffix,
949
- }),
950
- panel: savedPartial
951
- ? ''
952
- : await ctx.joint(url, req, 'product_backend:template.panel', {
953
- templateId: row.id,
954
- activeTab,
955
- locale,
956
- querySuffix,
957
- }),
958
- controls: {
959
- uom: await uomControl(ctx, url, req, _, {
960
- id: `product-uom:${row.id}`,
961
- value: row.uomId,
962
- units: options.uoms,
963
- }),
964
- category: await categoryControl(ctx, url, req, _, {
965
- id: `product-category:${row.id}`,
966
- value: row.categoryId,
967
- categories: options.categories,
968
- }),
969
- brand: await brandControl(ctx, url, req, _, {
970
- id: `product-brand:${row.id}`,
971
- value: row.brandId,
972
- brands: options.brands,
973
- }),
974
- attribute: await attributeControl(ctx, url, req, _, {
975
- id: `product-attribute:${row.id}`,
976
- attributes: options.variantAttributes,
977
- required: true,
978
- }),
979
- // The value picker cannot be scoped to an attribute yet — the two are
980
- // separate fields and the attribute is only known once chosen — so it
981
- // lists every value, each labelled with the attribute it belongs to.
982
- attributeValues: await attributeValuesControl(ctx, url, req, _, {
983
- id: `product-attribute-values:${row.id}`,
984
- choices: options.attributeValues,
985
- required: true,
986
- }),
987
- },
988
- editor: savedPartial
989
- ? ''
990
- : await ctx.joint(url, req, 'product_backend:template.editor', {
991
- identity: `template:${row.id}`,
992
- templateId: row.id,
993
- lang,
994
- }),
995
- }, savedPartial
996
- ? ''
997
- : await ctx.joint(url, req, 'product_backend:template.collaboration', {
998
- resModel: 'product.Template',
999
- resId: row.id,
1000
- lang,
1001
- }), savedPartial ? {} : await frameOf(ctx, url, req), locale, activeTab, savedPartial);
1002
- if (savedPartial)
1003
- return withHeaders(fragment(body, { type: NAVIGATION_TYPE }), { vary: 'X-Ket-Partial' });
1004
- return backendPage(ctx, req, {
765
+ }, await ctx.joint(url, req, 'product_backend:template.collaboration', {
766
+ resModel: 'product.Template',
767
+ resId: row.id,
1005
768
  lang,
1006
- title: row.name,
1007
- body,
1008
- });
1009
- },
1010
- '/admin/product/templates/{id}/variants/generate': (ctx) => async (url, req, params) => {
1011
- const denied = refusePost(req);
1012
- if (denied)
1013
- return denied;
1014
- const result = await ctx.call('product.generateVariants', { templateId: params.id }, url, req);
1015
- return result.ok
1016
- ? seeProduct(params.id, url)
1017
- : seeOther(inLocale(url, `/admin/product/templates/${params.id}?invalid=1`));
1018
- },
1019
- '/admin/product/templates/{id}/attribute-lines': (ctx) => async (url, req, params) => {
1020
- const denied = refusePost(req);
1021
- if (denied)
1022
- return denied;
1023
- const form = await readForm(req);
1024
- const attributeId = form.attributeId ?? '';
1025
- const result = await ctx.call('product.saveAttributeLine', {
1026
- id: `${params.id}:${attributeId}`,
1027
- templateId: params.id,
1028
- attributeId,
1029
- valueIds: (form.valueIds ?? '')
1030
- .split(',')
1031
- .map((value) => value.trim())
1032
- .filter(Boolean),
1033
- }, url, req);
1034
- return result.ok
1035
- ? seeProduct(params.id, url)
1036
- : seeOther(inLocale(url, `/admin/product/templates/${params.id}?invalid=1`));
1037
- },
1038
- '/admin/product/templates/{id}/archive': (ctx) => async (url, req, params) => {
1039
- const denied = refusePost(req);
1040
- if (denied)
1041
- return denied;
1042
- const form = await readForm(req);
1043
- await ctx.call('product.archiveTemplate', { id: params.id, active: form.active === '1' }, url, req);
1044
- return seeProduct(params.id, url);
1045
- },
1046
- '/admin/product/templates/{id}/attribute-lines/{lineId}/remove': (ctx) => async (url, req, params) => {
1047
- const denied = refusePost(req);
1048
- if (denied)
1049
- return denied;
1050
- // The line has to belong to the template in the path, or a POST could take
1051
- // an attribute off a product the reader never opened.
1052
- const lines = (await ctx.call('product.listAttributeLines', { templateId: params.id }, url, req));
1053
- if (!lines.some((line) => line.id === params.lineId))
1054
- return text('Attribute line not found', { status: 404 });
1055
- await ctx.call('product.removeAttributeLine', { id: params.lineId }, url, req);
1056
- return seeProduct(params.id, url);
769
+ }), await frameOf(ctx, url, req), locale, 'media', false);
770
+ return backendPage(ctx, req, { lang, title: row.name, body });
1057
771
  },
1058
772
  '/admin/product/templates/{id}/variants/{variantId}': (ctx) => async (url, req, params) => {
1059
773
  const lang = ctx.localeOf(url, req);