shutro-erp 0.2.1 → 0.3.1

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 (273) hide show
  1. package/README.md +74 -67
  2. package/dist/account_portal/.kosh/bindings.d.ts +22 -0
  3. package/dist/account_portal/.kosh/bindings.js +15 -0
  4. package/dist/account_portal/.kosh/schema.d.ts +1380 -0
  5. package/dist/account_portal/index.d.ts +3 -0
  6. package/dist/account_portal/index.js +73 -0
  7. package/dist/account_portal/module.d.ts +5983 -0
  8. package/dist/account_portal/module.js +18 -0
  9. package/dist/accounting/.kosh/bindings.js +1 -1
  10. package/dist/accounting/.kosh/schema.d.ts +172 -9
  11. package/dist/accounting/chart.d.ts +40 -8
  12. package/dist/accounting/chart.js +124 -30
  13. package/dist/accounting/closing.d.ts +6 -1
  14. package/dist/accounting/closing.js +5 -1
  15. package/dist/accounting/demo.js +6 -5
  16. package/dist/accounting/index.d.ts +3 -3
  17. package/dist/accounting/index.js +200 -23
  18. package/dist/accounting/module.d.ts +3326 -1086
  19. package/dist/accounting/module.js +408 -66
  20. package/dist/accounting/payments.d.ts +25 -0
  21. package/dist/accounting/payments.js +96 -4
  22. package/dist/accounting/web/RegisterPayment.d.ts +3 -2
  23. package/dist/accounting/web/RegisterPayment.js +39 -6
  24. package/dist/accounting/web/SettingsPage.js +5 -1
  25. package/dist/accounting/web/messages.js +10 -0
  26. package/dist/assistant/routes.js +1 -1
  27. package/dist/automation/engine.js +1 -1
  28. package/dist/automation/web/messages.js +2 -2
  29. package/dist/cli/apps.d.ts +1 -1
  30. package/dist/cli/apps.js +14 -14
  31. package/dist/cli/bin.js +3 -0
  32. package/dist/cli/index.js +11 -11
  33. package/dist/cli/init.d.ts +1 -1
  34. package/dist/cli/init.js +224 -78
  35. package/dist/cli/launch.d.ts +16 -0
  36. package/dist/cli/launch.js +138 -0
  37. package/dist/cli/project.d.ts +7 -7
  38. package/dist/cli/project.js +25 -25
  39. package/dist/cli/prompt.d.ts +35 -0
  40. package/dist/cli/prompt.js +48 -2
  41. package/dist/cli/templates.d.ts +14 -6
  42. package/dist/cli/templates.js +61 -34
  43. package/dist/cli/tui.d.ts +7 -0
  44. package/dist/cli/tui.js +254 -0
  45. package/dist/cli/ui.d.ts +21 -8
  46. package/dist/cli/ui.js +65 -19
  47. package/dist/contacts/.kosh/bindings.js +1 -1
  48. package/dist/contacts/.kosh/schema.d.ts +33 -1
  49. package/dist/contacts/index.js +24 -1
  50. package/dist/contacts/module.d.ts +42 -0
  51. package/dist/contacts/module.js +3 -1
  52. package/dist/import/convert.d.ts +55 -0
  53. package/dist/import/convert.js +224 -0
  54. package/dist/import/csv.d.ts +28 -0
  55. package/dist/import/csv.js +115 -0
  56. package/dist/import/index.d.ts +33 -0
  57. package/dist/import/index.js +60 -0
  58. package/dist/import/mapping.d.ts +15 -0
  59. package/dist/import/mapping.js +33 -0
  60. package/dist/import/match.d.ts +24 -0
  61. package/dist/import/match.js +60 -0
  62. package/dist/import/models.d.ts +2 -0
  63. package/dist/import/models.js +102 -0
  64. package/dist/import/routes.d.ts +13 -0
  65. package/dist/import/routes.js +392 -0
  66. package/dist/import/run.d.ts +131 -0
  67. package/dist/import/run.js +568 -0
  68. package/dist/import/service.d.ts +153 -0
  69. package/dist/import/service.js +367 -0
  70. package/dist/import/web/ImportButton.d.ts +7 -0
  71. package/dist/import/web/ImportButton.js +31 -0
  72. package/dist/import/web/ImportWizard.d.ts +75 -0
  73. package/dist/import/web/ImportWizard.js +217 -0
  74. package/dist/import/web/index.d.ts +13 -0
  75. package/dist/import/web/index.js +18 -0
  76. package/dist/import/web/messages.d.ts +2 -0
  77. package/dist/import/web/messages.js +139 -0
  78. package/dist/import/worker.d.ts +3 -0
  79. package/dist/import/worker.js +52 -0
  80. package/dist/import/xlsx.d.ts +20 -0
  81. package/dist/import/xlsx.js +192 -0
  82. package/dist/l10n_bd/.kosh/bindings.d.ts +22 -0
  83. package/dist/l10n_bd/.kosh/bindings.js +15 -0
  84. package/dist/l10n_bd/.kosh/schema.d.ts +1311 -0
  85. package/dist/l10n_bd/documents.d.ts +8 -0
  86. package/dist/l10n_bd/documents.js +183 -0
  87. package/dist/l10n_bd/index.d.ts +8 -0
  88. package/dist/l10n_bd/index.js +156 -0
  89. package/dist/l10n_bd/module.d.ts +5705 -0
  90. package/dist/l10n_bd/module.js +163 -0
  91. package/dist/l10n_bd/mushak.d.ts +67 -0
  92. package/dist/l10n_bd/mushak.js +139 -0
  93. package/dist/l10n_bd/routes.d.ts +3 -0
  94. package/dist/l10n_bd/routes.js +205 -0
  95. package/dist/l10n_bd/web/MushakPage.d.ts +7 -0
  96. package/dist/l10n_bd/web/MushakPage.js +53 -0
  97. package/dist/l10n_bd/web/index.d.ts +13 -0
  98. package/dist/l10n_bd/web/index.js +18 -0
  99. package/dist/l10n_bd/web/messages.d.ts +2 -0
  100. package/dist/l10n_bd/web/messages.js +101 -0
  101. package/dist/l10n_in/.kosh/bindings.d.ts +22 -0
  102. package/dist/l10n_in/.kosh/bindings.js +15 -0
  103. package/dist/l10n_in/.kosh/schema.d.ts +1373 -0
  104. package/dist/l10n_in/documents.d.ts +8 -0
  105. package/dist/l10n_in/documents.js +114 -0
  106. package/dist/l10n_in/einvoice.d.ts +253 -0
  107. package/dist/l10n_in/einvoice.js +485 -0
  108. package/dist/l10n_in/gstr.d.ts +109 -0
  109. package/dist/l10n_in/gstr.js +430 -0
  110. package/dist/l10n_in/index.d.ts +20 -0
  111. package/dist/l10n_in/index.js +232 -0
  112. package/dist/l10n_in/module.d.ts +7823 -0
  113. package/dist/l10n_in/module.js +427 -0
  114. package/dist/l10n_in/routes-einvoice.d.ts +15 -0
  115. package/dist/l10n_in/routes-einvoice.js +124 -0
  116. package/dist/l10n_in/routes.d.ts +17 -0
  117. package/dist/l10n_in/routes.js +282 -0
  118. package/dist/l10n_in/web/EInvoicePanel.d.ts +8 -0
  119. package/dist/l10n_in/web/EInvoicePanel.js +63 -0
  120. package/dist/l10n_in/web/GstPage.d.ts +7 -0
  121. package/dist/l10n_in/web/GstPage.js +57 -0
  122. package/dist/l10n_in/web/GstSettings.d.ts +10 -0
  123. package/dist/l10n_in/web/GstSettings.js +36 -0
  124. package/dist/l10n_in/web/index.d.ts +15 -0
  125. package/dist/l10n_in/web/index.js +22 -0
  126. package/dist/l10n_in/web/messages.d.ts +2 -0
  127. package/dist/l10n_in/web/messages.js +166 -0
  128. package/dist/mail/index.js +2 -2
  129. package/dist/modules-sdk/demo.d.ts +2 -0
  130. package/dist/modules-sdk/demo.js +7 -3
  131. package/dist/modules-sdk/index.d.ts +2 -2
  132. package/dist/modules-sdk/index.js +1 -1
  133. package/dist/modules-sdk/loader.js +2 -2
  134. package/dist/modules-sdk/manifest.d.ts +132 -0
  135. package/dist/modules-sdk/manifest.js +22 -0
  136. package/dist/notify/inbox.js +21 -0
  137. package/dist/notify/messages.js +7 -3
  138. package/dist/notify/models.d.ts +1 -1
  139. package/dist/notify/models.js +2 -0
  140. package/dist/notify/routes.js +1 -1
  141. package/dist/notify/web/messages.js +5 -1
  142. package/dist/portal/.kosh/bindings.d.ts +22 -0
  143. package/dist/portal/.kosh/bindings.js +15 -0
  144. package/dist/portal/.kosh/schema.d.ts +376 -0
  145. package/dist/portal/announce.d.ts +16 -0
  146. package/dist/portal/announce.js +117 -0
  147. package/dist/portal/documents.d.ts +17 -0
  148. package/dist/portal/documents.js +61 -0
  149. package/dist/portal/index.d.ts +20 -0
  150. package/dist/portal/index.js +65 -0
  151. package/dist/portal/module.d.ts +1405 -0
  152. package/dist/portal/module.js +84 -0
  153. package/dist/portal/routes-documents.d.ts +2 -0
  154. package/dist/portal/routes-documents.js +391 -0
  155. package/dist/portal/routes.d.ts +13 -0
  156. package/dist/portal/routes.js +313 -0
  157. package/dist/portal/rules.d.ts +11 -0
  158. package/dist/portal/rules.js +25 -0
  159. package/dist/portal/web/PortalAccess.d.ts +8 -0
  160. package/dist/portal/web/PortalAccess.js +66 -0
  161. package/dist/portal/web/index.d.ts +12 -0
  162. package/dist/portal/web/index.js +17 -0
  163. package/dist/portal/web/messages.d.ts +2 -0
  164. package/dist/portal/web/messages.js +33 -0
  165. package/dist/product/.kosh/bindings.js +1 -1
  166. package/dist/product/.kosh/schema.d.ts +42 -3
  167. package/dist/product/demo.js +6 -5
  168. package/dist/product/index.d.ts +1 -0
  169. package/dist/product/index.js +17 -4
  170. package/dist/product/module.d.ts +346 -14
  171. package/dist/product/module.js +48 -18
  172. package/dist/product/taxes.d.ts +59 -0
  173. package/dist/product/taxes.js +61 -0
  174. package/dist/project/.kosh/bindings.js +1 -1
  175. package/dist/project/.kosh/schema.d.ts +33 -1
  176. package/dist/project/module.d.ts +20 -0
  177. package/dist/project_portal/.kosh/bindings.d.ts +22 -0
  178. package/dist/project_portal/.kosh/bindings.js +15 -0
  179. package/dist/project_portal/.kosh/schema.d.ts +701 -0
  180. package/dist/project_portal/index.d.ts +9 -0
  181. package/dist/project_portal/index.js +42 -0
  182. package/dist/project_portal/module.d.ts +2142 -0
  183. package/dist/project_portal/module.js +22 -0
  184. package/dist/purchase/.kosh/bindings.js +1 -1
  185. package/dist/purchase/.kosh/schema.d.ts +45 -4
  186. package/dist/purchase/demo.js +3 -3
  187. package/dist/purchase/index.js +1 -1
  188. package/dist/purchase/module.d.ts +232 -8
  189. package/dist/purchase/module.js +51 -9
  190. package/dist/purchase_account/.kosh/bindings.js +1 -1
  191. package/dist/purchase_account/.kosh/schema.d.ts +174 -10
  192. package/dist/purchase_account/module.d.ts +2008 -641
  193. package/dist/purchase_account/module.js +2 -2
  194. package/dist/purchase_portal/.kosh/bindings.d.ts +22 -0
  195. package/dist/purchase_portal/.kosh/bindings.js +15 -0
  196. package/dist/purchase_portal/.kosh/schema.d.ts +852 -0
  197. package/dist/purchase_portal/index.d.ts +9 -0
  198. package/dist/purchase_portal/index.js +53 -0
  199. package/dist/purchase_portal/module.d.ts +2758 -0
  200. package/dist/purchase_portal/module.js +18 -0
  201. package/dist/reporting/documents.js +20 -3
  202. package/dist/reporting/index.d.ts +1 -0
  203. package/dist/reporting/index.js +1 -0
  204. package/dist/reporting/routes-documents.js +9 -2
  205. package/dist/reporting/words.d.ts +11 -0
  206. package/dist/reporting/words.js +229 -0
  207. package/dist/sale_account/.kosh/bindings.js +1 -1
  208. package/dist/sale_account/.kosh/schema.d.ts +174 -10
  209. package/dist/sale_account/module.d.ts +2008 -641
  210. package/dist/sale_account/module.js +3 -3
  211. package/dist/sale_portal/.kosh/bindings.d.ts +22 -0
  212. package/dist/sale_portal/.kosh/bindings.js +15 -0
  213. package/dist/sale_portal/.kosh/schema.d.ts +766 -0
  214. package/dist/sale_portal/index.d.ts +9 -0
  215. package/dist/sale_portal/index.js +71 -0
  216. package/dist/sale_portal/module.d.ts +2718 -0
  217. package/dist/sale_portal/module.js +19 -0
  218. package/dist/sale_project/.kosh/bindings.js +1 -1
  219. package/dist/sale_project/.kosh/schema.d.ts +45 -4
  220. package/dist/sale_project/module.d.ts +390 -10
  221. package/dist/sales/.kosh/bindings.js +1 -1
  222. package/dist/sales/.kosh/schema.d.ts +45 -4
  223. package/dist/sales/demo.js +3 -3
  224. package/dist/sales/index.js +67 -1
  225. package/dist/sales/module.d.ts +232 -8
  226. package/dist/sales/module.js +51 -12
  227. package/dist/server/api/client.js +1 -1
  228. package/dist/server/api/openapi.js +17 -1
  229. package/dist/server/apps.js +5 -5
  230. package/dist/server/auth/api-keys.d.ts +13 -0
  231. package/dist/server/auth/api-keys.js +65 -0
  232. package/dist/server/auth/base.js +26 -0
  233. package/dist/server/auth/crypto.js +1 -1
  234. package/dist/server/auth/scopes.d.ts +12 -0
  235. package/dist/server/auth/scopes.js +51 -0
  236. package/dist/server/auth/service.d.ts +59 -1
  237. package/dist/server/auth/service.js +236 -6
  238. package/dist/server/demo.d.ts +1 -1
  239. package/dist/server/demo.js +1 -1
  240. package/dist/server/env-names.d.ts +2 -0
  241. package/dist/server/env-names.js +16 -0
  242. package/dist/server/host.js +5 -1
  243. package/dist/server/identity.d.ts +7 -0
  244. package/dist/server/index.d.ts +2 -1
  245. package/dist/server/index.js +2 -1
  246. package/dist/server/registry.js +1 -1
  247. package/dist/server/server.d.ts +5 -0
  248. package/dist/server/server.js +71 -6
  249. package/dist/settings/routes.js +1 -1
  250. package/dist/settings/web/ApiKeysPage.d.ts +8 -0
  251. package/dist/settings/web/ApiKeysPage.js +70 -0
  252. package/dist/settings/web/index.d.ts +1 -0
  253. package/dist/settings/web/index.js +4 -0
  254. package/dist/settings/web/messages.js +62 -0
  255. package/dist/storage/routes.js +1 -1
  256. package/dist/studio/build.js +1 -1
  257. package/dist/studio/generate.js +4 -4
  258. package/dist/studio/module.js +5 -5
  259. package/dist/studio/web/messages.js +2 -2
  260. package/dist/ui/api.d.ts +2 -0
  261. package/dist/ui/app.js +27 -4
  262. package/dist/ui/context.d.ts +3 -1
  263. package/dist/ui/context.js +2 -2
  264. package/dist/ui/i18n/messages.js +2 -2
  265. package/dist/ui/index.d.ts +1 -1
  266. package/dist/ui/index.js +1 -1
  267. package/dist/ui/lib/format.d.ts +2 -0
  268. package/dist/ui/lib/format.js +3 -1
  269. package/dist/ui/pwa/worker.js +2 -2
  270. package/dist/ui/registry.d.ts +15 -0
  271. package/dist/ui/registry.js +20 -0
  272. package/dist/ui/views/FormView.js +19 -6
  273. package/package.json +52 -4
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Settings › API keys (RFC 0017 §5.1): your keys — and the service accounts' for administrators —, a new key with its
4
+ * scopes and expiry, its secret shown once, revocation. The server decides what each key may do; scopes only narrow
5
+ * the user's own rights.
6
+ */
7
+ import { Alert, ApiError, Button, Input, Label, Select, useKosh } from '../../ui/index.js';
8
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
9
+ import { useId, useState } from 'react';
10
+ import { useTranslation } from 'react-i18next';
11
+ export function ApiKeysPage({ api }) {
12
+ const { t } = useTranslation('settings');
13
+ const { format } = useKosh();
14
+ const id = useId();
15
+ const queryClient = useQueryClient();
16
+ const keys = useQuery({
17
+ queryKey: ['settings', 'api-keys'],
18
+ queryFn: () => api.call('GET', '/api/v1/auth/api-keys'),
19
+ });
20
+ const scopes = useQuery({
21
+ queryKey: ['settings', 'scopes'],
22
+ queryFn: () => api.call('GET', '/api/v1/auth/scopes'),
23
+ });
24
+ const apps = [
25
+ ...new Set((scopes.data?.scopes ?? [])
26
+ .map((s) => s.scope)
27
+ .filter((s) => s.startsWith('read:'))
28
+ .map((s) => s.slice(5))),
29
+ ];
30
+ const [name, setName] = useState('');
31
+ const [days, setDays] = useState('365');
32
+ const [everything, setEverything] = useState(false);
33
+ const [levels, setLevels] = useState({});
34
+ const [secret, setSecret] = useState(null);
35
+ const chosen = everything
36
+ ? ['*']
37
+ : Object.entries(levels).flatMap(([app, level]) => level === 'none' ? [] : [`${level}:${app}`]);
38
+ const refresh = () => queryClient.invalidateQueries({ queryKey: ['settings', 'api-keys'] });
39
+ const create = useMutation({
40
+ mutationFn: () => api.call('POST', '/api/v1/auth/api-keys', {
41
+ name,
42
+ scopes: chosen,
43
+ expiresInDays: Number(days),
44
+ }),
45
+ onSuccess: async (key) => {
46
+ setSecret(key.token);
47
+ setName('');
48
+ setLevels({});
49
+ setEverything(false);
50
+ await refresh();
51
+ },
52
+ });
53
+ const revoke = useMutation({
54
+ mutationFn: (key) => api.call('POST', `/api/v1/auth/api-keys/${key}/revoke`),
55
+ onSuccess: refresh,
56
+ });
57
+ const error = create.error ?? revoke.error;
58
+ const message = (e) => (e instanceof ApiError ? e.message : String(e));
59
+ return (_jsxs("div", { className: "mx-auto grid max-w-4xl gap-6 p-4", children: [_jsx("h1", { className: "text-xl font-semibold", children: t('apiKeys.title') }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('apiKeys.intro') }), error === null ? null : _jsx(Alert, { tone: "danger", children: message(error) }), secret === null ? null : (_jsx(Alert, { tone: "info", children: _jsxs("div", { className: "grid gap-2", children: [_jsx("p", { children: t('apiKeys.secretOnce') }), _jsx("code", { className: "break-all rounded bg-muted px-2 py-1 font-mono text-sm", children: secret }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { variant: "secondary", onClick: () => void navigator.clipboard?.writeText(secret), children: t('apiKeys.copy') }), _jsx(Button, { variant: "secondary", onClick: () => setSecret(null), children: t('apiKeys.done') })] })] }) })), _jsxs("section", { className: "grid gap-3 rounded-lg border p-4", "aria-labelledby": `${id}-new`, children: [_jsx("h2", { id: `${id}-new`, className: "font-medium", children: t('apiKeys.new') }), _jsxs("div", { className: "flex flex-wrap items-end gap-3", children: [_jsxs("div", { className: "grid gap-1", children: [_jsx(Label, { htmlFor: `${id}-name`, children: t('apiKeys.name') }), _jsx(Input, { id: `${id}-name`, className: "w-64", value: name, onChange: (e) => setName(e.target.value) })] }), _jsx(Select, { id: `${id}-days`, label: t('apiKeys.expires'), className: "w-40", value: days, options: ['30', '90', '365', '730'].map((d) => ({
60
+ value: d,
61
+ label: t('apiKeys.days', { count: Number(d) }),
62
+ })), onChange: setDays })] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: everything, onChange: (e) => setEverything(e.target.checked) }), t('apiKeys.everything')] }), everything ? null : (_jsxs("fieldset", { className: "grid gap-2 sm:grid-cols-2", children: [_jsx("legend", { className: "mb-1 text-sm text-muted-foreground", children: t('apiKeys.perApp') }), apps.map((app) => (_jsx(Select, { id: `${id}-app-${app}`, label: app, value: levels[app] ?? 'none', options: ['none', 'read', 'write'].map((l) => ({
63
+ value: l,
64
+ label: t(`apiKeys.levels.${l}`),
65
+ })), onChange: (v) => setLevels({ ...levels, [app]: (v ?? 'none') }) }, app)))] })), _jsx("div", { children: _jsx(Button, { variant: "primary", disabled: name.trim() === '' || chosen.length === 0, busy: create.isPending, onClick: () => create.mutate(), children: t('apiKeys.create') }) })] }), (keys.data?.keys ?? []).length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('apiKeys.none') })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { className: "bg-muted", children: _jsxs("tr", { children: [['name', 'user', 'key', 'scopes', 'expires', 'lastUsed', 'status'].map((c) => (_jsx("th", { className: "px-2 py-1 text-start", children: t(`apiKeys.columns.${c}`) }, c))), _jsx("th", { className: "px-2 py-1", children: _jsx("span", { className: "sr-only", children: t('apiKeys.revoke') }) })] }) }), _jsx("tbody", { children: (keys.data?.keys ?? []).map((k) => (_jsxs("tr", { className: "border-b", children: [_jsx("td", { className: "px-2 py-1", children: k.name }), _jsx("td", { className: "px-2 py-1", children: k.userName }), _jsxs("td", { className: "px-2 py-1 font-mono text-xs", children: [k.prefix, "\u2026"] }), _jsx("td", { className: "px-2 py-1", children: k.scopes.join(' ') }), _jsx("td", { className: "px-2 py-1", children: format.datetime(k.expiresAt) }), _jsx("td", { className: "px-2 py-1", children: k.lastUsedAt === null ? '—' : format.datetime(k.lastUsedAt) }), _jsx("td", { className: "px-2 py-1", children: k.revoked
66
+ ? t('apiKeys.revoked')
67
+ : new Date(k.expiresAt).getTime() < Date.now()
68
+ ? t('apiKeys.expired')
69
+ : t('apiKeys.active') }), _jsx("td", { className: "px-2 py-1 text-end", children: k.revoked ? null : (_jsx(Button, { variant: "secondary", "aria-label": t('apiKeys.revokeKey', { name: k.name }), busy: revoke.isPending && revoke.variables === k.id, onClick: () => revoke.mutate(k.id), children: t('apiKeys.revoke') })) })] }, k.id))) })] }) }))] }));
70
+ }
@@ -6,6 +6,7 @@
6
6
  import { type Registry } from '../../ui/index.js';
7
7
  import type { i18n } from 'i18next';
8
8
  import { messages } from './messages.js';
9
+ export { ApiKeysPage } from './ApiKeysPage.js';
9
10
  export { AppsPage } from './AppsPage.js';
10
11
  /** Registers the Apps page (and its messages in `i18n`, when given). */
11
12
  export declare function registerSettingsWeb(registry: Registry, i18n?: i18n): Registry;
@@ -4,12 +4,16 @@
4
4
  * client action `settings.action_apps`) and its messages.
5
5
  */
6
6
  import { addMessages } from '../../ui/index.js';
7
+ import { ApiKeysPage } from './ApiKeysPage.js';
7
8
  import { AppsPage } from './AppsPage.js';
8
9
  import { messages } from './messages.js';
10
+ export { ApiKeysPage } from './ApiKeysPage.js';
9
11
  export { AppsPage } from './AppsPage.js';
10
12
  /** Registers the Apps page (and its messages in `i18n`, when given). */
11
13
  export function registerSettingsWeb(registry, i18n) {
12
14
  registry.registerPage('settings.Apps', AppsPage);
15
+ // Every signed-in user's own keys (RFC 0017 §5.1): in the user menu, not in the Settings app.
16
+ registry.registerUserPage('settings.ApiKeys', ApiKeysPage, { label: 'settings:apiKeys.title' });
13
17
  if (i18n !== undefined)
14
18
  addMessages(i18n, 'settings', messages);
15
19
  return registry;
@@ -1,6 +1,37 @@
1
1
  /** Messages of the Settings app (namespace `settings`). */
2
2
  export const messages = {
3
3
  en: {
4
+ apiKeys: {
5
+ title: 'API keys',
6
+ intro: 'Keys let scripts and other systems call the API as you, within the scopes you give them. Your own rights still apply; a key can never do more than you.',
7
+ new: 'New key',
8
+ name: 'Name',
9
+ expires: 'Expires in',
10
+ days_one: '{{count}} day',
11
+ days_other: '{{count}} days',
12
+ everything: 'Everything I may do (*)',
13
+ perApp: 'Or per app:',
14
+ levels: { none: 'No access', read: 'Read', write: 'Read and change' },
15
+ create: 'Create key',
16
+ secretOnce: 'Copy the key now: it is shown only once.',
17
+ copy: 'Copy',
18
+ done: 'Done',
19
+ none: 'No API keys yet.',
20
+ columns: {
21
+ name: 'Name',
22
+ user: 'User',
23
+ key: 'Key',
24
+ scopes: 'Scopes',
25
+ expires: 'Expires',
26
+ lastUsed: 'Last used',
27
+ status: 'Status',
28
+ },
29
+ active: 'Active',
30
+ revoked: 'Revoked',
31
+ expired: 'Expired',
32
+ revoke: 'Revoke',
33
+ revokeKey: 'Revoke {{name}}',
34
+ },
4
35
  apps: 'Apps',
5
36
  appsIntro: 'Install the apps your business needs. Disabling keeps the data, so nothing is lost.',
6
37
  search: 'Search apps',
@@ -56,6 +87,37 @@ export const messages = {
56
87
  version: 'Version {{version}}',
57
88
  },
58
89
  bn: {
90
+ apiKeys: {
91
+ title: 'এপিআই কী',
92
+ intro: 'কী দিয়ে স্ক্রিপ্ট ও অন্য সিস্টেম আপনার হয়ে এপিআই ব্যবহার করে, আপনার দেওয়া পরিসরের মধ্যে। আপনার নিজের অধিকারই প্রযোজ্য; কোনো কী আপনার চেয়ে বেশি করতে পারে না।',
93
+ new: 'নতুন কী',
94
+ name: 'নাম',
95
+ expires: 'মেয়াদ',
96
+ days_one: '{{count}} দিন',
97
+ days_other: '{{count}} দিন',
98
+ everything: 'আমি যা করতে পারি সবকিছু (*)',
99
+ perApp: 'অথবা প্রতিটি অ্যাপের জন্য:',
100
+ levels: { none: 'প্রবেশাধিকার নেই', read: 'পড়া', write: 'পড়া ও পরিবর্তন' },
101
+ create: 'কী তৈরি করুন',
102
+ secretOnce: 'কী-টি এখনই কপি করুন: এটি একবারই দেখানো হয়।',
103
+ copy: 'কপি',
104
+ done: 'সম্পন্ন',
105
+ none: 'এখনো কোনো এপিআই কী নেই।',
106
+ columns: {
107
+ name: 'নাম',
108
+ user: 'ব্যবহারকারী',
109
+ key: 'কী',
110
+ scopes: 'পরিসর',
111
+ expires: 'মেয়াদ শেষ',
112
+ lastUsed: 'শেষ ব্যবহার',
113
+ status: 'অবস্থা',
114
+ },
115
+ active: 'সক্রিয়',
116
+ revoked: 'বাতিল',
117
+ expired: 'মেয়াদোত্তীর্ণ',
118
+ revoke: 'বাতিল করুন',
119
+ revokeKey: '{{name}} বাতিল করুন',
120
+ },
59
121
  apps: 'অ্যাপস',
60
122
  appsIntro: 'আপনার ব্যবসার প্রয়োজনীয় অ্যাপগুলো ইনস্টল করুন। নিষ্ক্রিয় করলে ডেটা থেকে যায়, কিছুই হারায় না।',
61
123
  search: 'অ্যাপ খুঁজুন',
@@ -25,7 +25,7 @@ export function storageOf(app) {
25
25
  if (storage === undefined) {
26
26
  const configured = storageFromEnv();
27
27
  if (configured === null)
28
- throw new HttpProblem(503, 'storage-unavailable', 'File storage is not configured', 'No object store is configured for attachments.', 'Set KOSH_STORAGE_DIR, or S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY and S3_SECRET_KEY.');
28
+ throw new HttpProblem(503, 'storage-unavailable', 'File storage is not configured', 'No object store is configured for attachments.', 'Set SHUTRO_ERP_STORAGE_DIR, or S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY and S3_SECRET_KEY.');
29
29
  storage = configured;
30
30
  stores.set(app, storage);
31
31
  }
@@ -42,7 +42,7 @@ export async function studioModules(cwd) {
42
42
  export async function runStudio(argv, write, cwd) {
43
43
  const [action, ...folders] = argv;
44
44
  if (action !== 'build') {
45
- write('Usage: kosh studio build [folder …] (default: every src/modules/*/studio.json)\n');
45
+ write('Usage: shutro-erp studio build [folder …] (default: every src/modules/*/studio.json)\n');
46
46
  return 2;
47
47
  }
48
48
  const dirs = folders.length > 0 ? folders.map((f) => resolve(cwd, f)) : await studioModules(cwd);
@@ -14,7 +14,7 @@ export class StudioSpecError extends Error {
14
14
  this.name = 'StudioSpecError';
15
15
  }
16
16
  }
17
- const HEADER = '// Generated by Kosh Studio from studio.json: do not edit (edit studio.json, then `kosh studio build`).';
17
+ const HEADER = '// Generated by Shutro ERP Studio from studio.json: do not edit (edit studio.json, then `shutro-erp studio build`).';
18
18
  /** A TypeScript literal with single quotes and stable key order (as given). */
19
19
  export function lit(value, indent = '') {
20
20
  if (value === null || value === undefined)
@@ -181,7 +181,7 @@ export function generateModule(spec, options = {}) {
181
181
  HEADER,
182
182
  '/**',
183
183
  ` * ${spec.label}${spec.description === undefined ? '' : `: ${spec.description}`}`,
184
- ' * A Kosh Studio module (RFC 0012): its fields, models, view changes, menus, approvals and rules, from studio.json.',
184
+ ' * A Shutro ERP Studio module (RFC 0012): its fields, models, view changes, menus, approvals and rules, from studio.json.',
185
185
  ' */',
186
186
  `import { base } from '${im.base}';`,
187
187
  ...deps.map((d) => `import { ${camel(d)} } from '${im.module(d)}';`),
@@ -222,8 +222,8 @@ export function generateModule(spec, options = {}) {
222
222
  const readme = [
223
223
  `# ${spec.label} (\`${name}\` ${spec.version})`,
224
224
  '',
225
- 'A Kosh Studio module (RFC 0012): `studio.json` is its source; `index.ts` and `models.ts` are generated from it',
226
- '(`kosh studio build`). To take it over as code, delete `studio.json` and edit the TypeScript.',
225
+ 'A Shutro ERP Studio module (RFC 0012): `studio.json` is its source; `index.ts` and `models.ts` are generated from it',
226
+ '(`shutro-erp studio build`). To take it over as code, delete `studio.json` and edit the TypeScript.',
227
227
  '',
228
228
  ...(spec.description === undefined ? [] : [spec.description, '']),
229
229
  ...((spec.fields ?? []).length === 0
@@ -368,7 +368,7 @@ export function registerStudioRoutes(context, options = {}) {
368
368
  await guard(request);
369
369
  const pullRequest = request.body?.pullRequest === true;
370
370
  if (pullRequest && git === null)
371
- throw new HttpProblem(409, 'studio-git-unconfigured', 'No repository for proposals', 'Set KOSH_STUDIO_GIT_URL and KOSH_STUDIO_GIT_TOKEN to open pull requests; download the patch meanwhile.');
371
+ throw new HttpProblem(409, 'studio-git-unconfigured', 'No repository for proposals', 'Set SHUTRO_ERP_STUDIO_GIT_URL and SHUTRO_ERP_STUDIO_GIT_TOKEN to open pull requests; download the patch meanwhile.');
372
372
  const { review, files, modulesTs } = await prepare(specOf(request.body));
373
373
  const patch = review.diff.map((d) => d.diff).join('');
374
374
  if (!pullRequest || git === null)
@@ -384,7 +384,7 @@ export function registerStudioRoutes(context, options = {}) {
384
384
  });
385
385
  const command = review.change === 'install' ? 'install' : 'upgrade';
386
386
  const body = [
387
- `A Kosh Studio change: **${review.spec.label}** (\`${name}\` ${review.spec.version}).`,
387
+ `A Shutro ERP Studio change: **${review.spec.label}** (\`${name}\` ${review.spec.version}).`,
388
388
  '',
389
389
  review.change === 'install'
390
390
  ? `Installs the module \`${name}\`.`
@@ -393,7 +393,7 @@ export function registerStudioRoutes(context, options = {}) {
393
393
  'After deploying:',
394
394
  '',
395
395
  '```sh',
396
- `npx kosh apps ${command} ${name}`,
396
+ `npx shutro-erp apps ${command} ${name}`,
397
397
  '```',
398
398
  '',
399
399
  ...(review.statements.length === 0
@@ -417,14 +417,14 @@ export function registerStudioRoutes(context, options = {}) {
417
417
  }
418
418
  catch (error) {
419
419
  if (error instanceof GitProposalError)
420
- throw new HttpProblem(502, 'studio-git-failed', 'The repository refused the proposal', error.message, 'Check KOSH_STUDIO_GIT_URL, the token and its rights (contents and pull requests); the patch can be downloaded meanwhile.');
420
+ throw new HttpProblem(502, 'studio-git-failed', 'The repository refused the proposal', error.message, 'Check SHUTRO_ERP_STUDIO_GIT_URL, the token and its rights (contents and pull requests); the patch can be downloaded meanwhile.');
421
421
  throw error;
422
422
  }
423
423
  });
424
424
  app.post('/api/v1/studio/apply', async (request) => {
425
425
  const uid = await guard(request);
426
426
  if (mode !== 'write')
427
- throw new HttpProblem(409, 'studio-propose-only', 'Studio proposes changes here', 'This server runs Studio in propose mode: it never writes code. Download the patch and deploy it.', 'KOSH_STUDIO=write is for development and single-server installations.');
427
+ throw new HttpProblem(409, 'studio-propose-only', 'Studio proposes changes here', 'This server runs Studio in propose mode: it never writes code. Download the patch and deploy it.', 'SHUTRO_ERP_STUDIO=write is for development and single-server installations.');
428
428
  const planId = request.body?.planId;
429
429
  if (typeof planId !== 'string')
430
430
  return invalid('planId (from /preview) is required.');
@@ -35,7 +35,7 @@ export const messages = {
35
35
  openPullRequest: 'Open a pull request',
36
36
  openMergeRequest: 'Open a merge request',
37
37
  proposalOpened: 'Opened on {{repository}}:',
38
- proposeNote: 'This server proposes changes: download the patch, commit it, and deploy it (then kosh apps upgrade).',
38
+ proposeNote: 'This server proposes changes: download the patch, commit it, and deploy it (then shutro-erp apps upgrade).',
39
39
  modulesTitle: 'Studio modules',
40
40
  modulesIntro: 'Each Studio change is saved in a module of your project: reviewed as code, versioned, and upgraded like any app.',
41
41
  modeWrite: 'Write mode: changes are applied on this server.',
@@ -109,7 +109,7 @@ export const messages = {
109
109
  openPullRequest: 'পুল রিকোয়েস্ট খুলুন',
110
110
  openMergeRequest: 'মার্জ রিকোয়েস্ট খুলুন',
111
111
  proposalOpened: '{{repository}}-এ খোলা হয়েছে:',
112
- proposeNote: 'এই সার্ভার পরিবর্তন প্রস্তাব করে: প্যাচ ডাউনলোড করে কমিট ও ডিপ্লয় করুন (তারপর kosh apps upgrade)।',
112
+ proposeNote: 'এই সার্ভার পরিবর্তন প্রস্তাব করে: প্যাচ ডাউনলোড করে কমিট ও ডিপ্লয় করুন (তারপর shutro-erp apps upgrade)।',
113
113
  modulesTitle: 'স্টুডিও মডিউল',
114
114
  modulesIntro: 'স্টুডিওর প্রতিটি পরিবর্তন আপনার প্রজেক্টের একটি মডিউলে সংরক্ষিত হয়: কোডের মতো পর্যালোচিত, সংস্করণযুক্ত এবং যেকোনো অ্যাপের মতো আপগ্রেডযোগ্য।',
115
115
  modeWrite: 'রাইট মোড: পরিবর্তন এই সার্ভারে প্রয়োগ হয়।',
package/dist/ui/api.d.ts CHANGED
@@ -65,6 +65,8 @@ export interface CurrentUser {
65
65
  readonly name: string;
66
66
  readonly company: number | null;
67
67
  readonly companies: readonly number[];
68
+ /** Number grouping per company (RFC 0016); older servers send none. */
69
+ readonly groupings?: Readonly<Record<string, 'international' | 'lakh'>>;
68
70
  readonly twoFactorEnabled: boolean;
69
71
  readonly administrator: boolean;
70
72
  readonly administratorNeedsSecondFactor: boolean;
package/dist/ui/app.js CHANGED
@@ -97,7 +97,7 @@ const ServicesContext = createContext(null);
97
97
  function useServices() {
98
98
  const value = useContext(ServicesContext);
99
99
  if (value === null)
100
- throw new Error('Kosh routes render inside <KoshApp>.');
100
+ throw new Error('Shutro ERP routes render inside <KoshApp>.');
101
101
  return value;
102
102
  }
103
103
  // ------------------------------------------------------------------------------------------------- shell
@@ -177,11 +177,11 @@ function Shell() {
177
177
  const user = me.data;
178
178
  const current = company ?? user.company ?? user.companies[0] ?? 0;
179
179
  const app = apps.data?.find((a) => a.code === params.app);
180
- return (_jsx(KoshProvider, { api: api, registry: registry, uid: user.id, companyId: current, lang: lang, tz: Intl.DateTimeFormat().resolvedOptions().timeZone, ...(realtime === undefined ? {} : { realtime }), ...(offline === undefined ? {} : { offline }), children: _jsx(ToastProvider, { children: _jsxs(DesignProvider, { children: [_jsx(OfflineAgent, { uid: user.id }), _jsxs(PaletteContext.Provider, { value: openPalette, children: [_jsx("a", { href: "#content", className: "sr-only focus:not-sr-only focus:absolute focus:start-2 focus:top-2 focus:z-50 focus:rounded focus:bg-surface focus:px-3 focus:py-2", children: t('shell.skip') }), _jsx("header", { className: "sticky top-0 z-30 border-b bg-surface", children: _jsxs("div", { className: "flex h-12 items-center gap-2 px-3", children: [_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": t('shell.apps'), children: _jsx(Grid3x3, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { align: "start", children: [_jsx(MenuItem, { onSelect: () => void navigate({ to: '/' }), children: t('shell.apps') }), _jsx(MenuSeparator, {}), (apps.data ?? []).map((a) => (_jsx(MenuItem, { onSelect: () => void navigate({ to: '/$app', params: { app: a.code } }), children: a.label }, a.code)))] })] }), _jsx(Link, { to: "/", className: "font-semibold", children: app?.label ?? t('app.name') }), _jsx("nav", { "aria-label": t('shell.menu'), className: "ms-2 flex-1", children: params.app === undefined ? null : (_jsx(MenuLinks, { app: params.app, menus: menus.data ?? [] })) }), _jsxs(Button, { variant: "secondary", size: "sm", className: "hidden h-8 w-56 justify-between text-muted-foreground sm:flex", onClick: () => setPalette(true), "aria-keyshortcuts": "Control+K Meta+K", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(Search, { className: "size-4", "aria-hidden": true }), t('palette.button')] }), _jsx("kbd", { className: "rounded border bg-surface px-1.5 text-xs", children: "Ctrl K" })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "sm:hidden", "aria-label": t('palette.button'), onClick: () => setPalette(true), children: _jsx(Search, { "aria-hidden": true }) }), _jsx(OfflineMenu, {}), registry.headerItems().map(({ name, component: Item }) => (_jsx(Item, { api: api }, name))), _jsx(CompanySwitcher, { me: user, company: current, onChange: (id) => {
180
+ return (_jsx(KoshProvider, { api: api, registry: registry, uid: user.id, companyId: current, lang: lang, tz: Intl.DateTimeFormat().resolvedOptions().timeZone, grouping: user.groupings?.[String(current)] ?? 'international', ...(realtime === undefined ? {} : { realtime }), ...(offline === undefined ? {} : { offline }), children: _jsx(ToastProvider, { children: _jsxs(DesignProvider, { children: [_jsx(OfflineAgent, { uid: user.id }), _jsxs(PaletteContext.Provider, { value: openPalette, children: [_jsx("a", { href: "#content", className: "sr-only focus:not-sr-only focus:absolute focus:start-2 focus:top-2 focus:z-50 focus:rounded focus:bg-surface focus:px-3 focus:py-2", children: t('shell.skip') }), _jsx("header", { className: "sticky top-0 z-30 border-b bg-surface", children: _jsxs("div", { className: "flex h-12 items-center gap-2 px-3", children: [_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": t('shell.apps'), children: _jsx(Grid3x3, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { align: "start", children: [_jsx(MenuItem, { onSelect: () => void navigate({ to: '/' }), children: t('shell.apps') }), _jsx(MenuSeparator, {}), (apps.data ?? []).map((a) => (_jsx(MenuItem, { onSelect: () => void navigate({ to: '/$app', params: { app: a.code } }), children: a.label }, a.code)))] })] }), _jsx(Link, { to: "/", className: "font-semibold", children: app?.label ?? t('app.name') }), _jsx("nav", { "aria-label": t('shell.menu'), className: "ms-2 flex-1", children: params.app === undefined ? null : (_jsx(MenuLinks, { app: params.app, menus: menus.data ?? [] })) }), _jsxs(Button, { variant: "secondary", size: "sm", className: "hidden h-8 w-56 justify-between text-muted-foreground sm:flex", onClick: () => setPalette(true), "aria-keyshortcuts": "Control+K Meta+K", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(Search, { className: "size-4", "aria-hidden": true }), t('palette.button')] }), _jsx("kbd", { className: "rounded border bg-surface px-1.5 text-xs", children: "Ctrl K" })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "sm:hidden", "aria-label": t('palette.button'), onClick: () => setPalette(true), children: _jsx(Search, { "aria-hidden": true }) }), _jsx(OfflineMenu, {}), registry.headerItems().map(({ name, component: Item }) => (_jsx(Item, { api: api }, name))), _jsx(CompanySwitcher, { me: user, company: current, onChange: (id) => {
181
181
  api.setCompany(id);
182
182
  setCompany(id);
183
183
  void queryClient.invalidateQueries();
184
- } }), _jsx(LanguageMenu, {}), _jsx(ThemeMenu, {}), _jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": user.name, children: _jsx(UserRound, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { children: [_jsx(MenuLabel, { children: user.name }), _jsxs(MenuItem, { onSelect: async () => {
184
+ } }), _jsx(LanguageMenu, {}), _jsx(ThemeMenu, {}), _jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": user.name, children: _jsx(UserRound, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { children: [_jsx(MenuLabel, { children: user.name }), registry.userPages().map((p) => (_jsx(MenuItem, { onSelect: () => void navigate({ to: '/me/$page', params: { page: p.name } }), children: t(p.label) }, p.name))), _jsxs(MenuItem, { onSelect: async () => {
185
185
  await api.logout();
186
186
  queryClient.clear();
187
187
  await dropOfflineCache(persister);
@@ -451,9 +451,32 @@ const openRoute = createRoute({
451
451
  path: '/open/$model/$id',
452
452
  component: OpenRecordPage,
453
453
  });
454
+ /** A personal page from the user menu (`registry.registerUserPage`), outside any app. */
455
+ function UserPage() {
456
+ const { page } = useParams({ strict: false });
457
+ const { api, registry } = useServices();
458
+ const { t } = useTranslation();
459
+ const entry = registry.userPages().find((p) => p.name === page);
460
+ if (entry === undefined)
461
+ return _jsx("p", { className: "p-4 text-sm", children: t('notFound') });
462
+ const Page = entry.component;
463
+ return _jsx(Page, { api: api, label: t(entry.label) });
464
+ }
465
+ const userPageRoute = createRoute({
466
+ getParentRoute: () => shellRoute,
467
+ path: '/me/$page',
468
+ component: UserPage,
469
+ });
454
470
  const routeTree = rootRoute.addChildren([
455
471
  loginRoute,
456
- shellRoute.addChildren([indexRoute, openRoute, appRoute, actionRoute, recordRoute]),
472
+ shellRoute.addChildren([
473
+ indexRoute,
474
+ openRoute,
475
+ userPageRoute,
476
+ appRoute,
477
+ actionRoute,
478
+ recordRoute,
479
+ ]),
457
480
  ]);
458
481
  const LANG_KEY = 'kosh.lang';
459
482
  function initialLanguage() {
@@ -26,13 +26,15 @@ export interface KoshContextValue {
26
26
  export declare function useKosh(): KoshContextValue;
27
27
  /** Applies the theme to `<html>` (the `dark` class) and follows the system when asked to. */
28
28
  declare function useTheme(): [ThemeChoice, (t: ThemeChoice) => void];
29
- export declare function KoshProvider({ api, registry, uid, companyId, lang, tz, realtime, offline, children, }: {
29
+ export declare function KoshProvider({ api, registry, uid, companyId, lang, tz, grouping, realtime, offline, children, }: {
30
30
  readonly api: KoshApi;
31
31
  readonly registry: Registry;
32
32
  readonly uid: number;
33
33
  readonly companyId: number;
34
34
  readonly lang: string;
35
35
  readonly tz: string;
36
+ /** The company's number grouping (RFC 0016). */
37
+ readonly grouping?: 'international' | 'lakh';
36
38
  readonly realtime?: RealtimeClient;
37
39
  readonly offline?: OfflineQueue;
38
40
  readonly children: ReactNode;
@@ -47,9 +47,9 @@ function useTheme() {
47
47
  }, [theme]);
48
48
  return [theme, setTheme];
49
49
  }
50
- export function KoshProvider({ api, registry, uid, companyId, lang, tz, realtime, offline, children, }) {
50
+ export function KoshProvider({ api, registry, uid, companyId, lang, tz, grouping, realtime, offline, children, }) {
51
51
  const [theme, setTheme] = useTheme();
52
- const format = useMemo(() => createFormatter(lang, tz), [lang, tz]);
52
+ const format = useMemo(() => createFormatter(lang, tz, grouping === undefined ? {} : { grouping }), [lang, tz, grouping]);
53
53
  const value = useMemo(() => ({
54
54
  api,
55
55
  registry,
@@ -4,7 +4,7 @@
4
4
  */
5
5
  export const en = {
6
6
  app: {
7
- name: 'Kosh',
7
+ name: 'Shutro ERP',
8
8
  loading: 'Loading…',
9
9
  error: 'Something went wrong.',
10
10
  retry: 'Retry',
@@ -169,7 +169,7 @@ export const en = {
169
169
  synced_other: '{{count}} changes saved on this device were sent',
170
170
  needsAttention_one: '{{count}} change could not be sent. Open the cloud in the header.',
171
171
  needsAttention_other: '{{count}} changes could not be sent. Open the cloud in the header.',
172
- newVersion: 'A new version of Kosh is ready.',
172
+ newVersion: 'A new version of Shutro ERP is ready.',
173
173
  reload: 'Reload',
174
174
  },
175
175
  home: {
@@ -11,7 +11,7 @@ export { ApiError, type ApiOptions, type AppEntry, type CurrentUser, createKoshA
11
11
  export { KoshApp, type KoshAppProps } from './app.js';
12
12
  export { AppIcon } from './components/AppIcon.js';
13
13
  export * from './components/primitives.js';
14
- export { type KoshContextValue, KoshProvider, type ThemeChoice, useKosh } from './context.js';
14
+ export { type KoshContextValue, KoshProvider, type ThemeChoice, useKosh, useTheme, } from './context.js';
15
15
  export { type DesignContextValue, DesignProvider, type DesignSpot, useDesign, } from './design.js';
16
16
  export { addMessages, createI18n, directionOf, NAMESPACE } from './i18n/index.js';
17
17
  export { LANGUAGES } from './i18n/messages.js';
package/dist/ui/index.js CHANGED
@@ -15,7 +15,7 @@ export { ApiError, createKoshApi, NetworkError, } from './api.js';
15
15
  export { KoshApp } from './app.js';
16
16
  export { AppIcon } from './components/AppIcon.js';
17
17
  export * from './components/primitives.js';
18
- export { KoshProvider, useKosh } from './context.js';
18
+ export { KoshProvider, useKosh, useTheme, } from './context.js';
19
19
  export { DesignProvider, useDesign, } from './design.js';
20
20
  export { addMessages, createI18n, directionOf, NAMESPACE } from './i18n/index.js';
21
21
  export { LANGUAGES } from './i18n/messages.js';
@@ -14,4 +14,6 @@ export interface Formatter {
14
14
  }
15
15
  export declare function createFormatter(lang: string, tz: string, options?: {
16
16
  readonly nativeDigits?: boolean;
17
+ /** `lakh`: 12,34,567 (RFC 0016). Bangla groups so already; English takes the Indian English pattern. */
18
+ readonly grouping?: 'international' | 'lakh';
17
19
  }): Formatter;
@@ -11,7 +11,9 @@ export function createFormatter(lang, tz, options = {}) {
11
11
  // Bangla defaults to Bengali digits in ICU; RFC 0007 makes them a choice (Latin digits unless asked).
12
12
  const locale = base.startsWith('bn')
13
13
  ? `${base}-u-nu-${options.nativeDigits === true ? 'beng' : 'latn'}`
14
- : base;
14
+ : options.grouping === 'lakh' && base.startsWith('en')
15
+ ? 'en-IN'
16
+ : base;
15
17
  const integer = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 });
16
18
  const digits = new Intl.NumberFormat(locale, { useGrouping: false });
17
19
  const separator = new Intl.NumberFormat(locale).formatToParts(1.5).find((p) => p.type === 'decimal')?.value ??
@@ -7,7 +7,7 @@
7
7
  */
8
8
  /** The worker's JavaScript. */
9
9
  export function serviceWorkerSource(config) {
10
- return `/* Kosh service worker ${config.version}: generated by shutro-erp/ui/vite, do not edit. */
10
+ return `/* Shutro ERP service worker ${config.version}: generated by shutro-erp/ui/vite, do not edit. */
11
11
  const VERSION = ${JSON.stringify(config.version)};
12
12
  const ASSETS = ${JSON.stringify(config.assets)};
13
13
  const CACHE = 'kosh-' + VERSION;
@@ -57,7 +57,7 @@ self.addEventListener('fetch', (event) => {
57
57
  });
58
58
 
59
59
  self.addEventListener('push', (event) => {
60
- let data = { title: 'Kosh', body: '', url: '/', id: 0 };
60
+ let data = { title: 'Shutro ERP', body: '', url: '/', id: 0 };
61
61
  try {
62
62
  data = { ...data, ...event.data.json() };
63
63
  } catch {
@@ -128,6 +128,21 @@ export declare class Registry {
128
128
  registerHeaderItem(name: string, component: ComponentType<HeaderItemProps>, options?: {
129
129
  readonly replace?: boolean;
130
130
  }): this;
131
+ /**
132
+ * A personal page every signed-in user reaches from the user menu, outside any app (`settings.ApiKeys`):
133
+ * served at `/me/<name>`; `label` is an i18n key (`settings:apiKeys.title`). The server still decides what the
134
+ * page may show and do.
135
+ */
136
+ registerUserPage(name: string, component: ComponentType<PageProps>, options: {
137
+ readonly label: string;
138
+ readonly replace?: boolean;
139
+ }): this;
140
+ /** The user pages, in registration order, with their label keys. */
141
+ userPages(): readonly {
142
+ readonly name: string;
143
+ readonly label: string;
144
+ readonly component: ComponentType<PageProps>;
145
+ }[];
131
146
  /** The header items, in registration order. */
132
147
  headerItems(): readonly {
133
148
  readonly name: string;
@@ -12,6 +12,7 @@ export class Registry {
12
12
  component: new Map(),
13
13
  page: new Map(),
14
14
  header: new Map(),
15
+ userPage: new Map(),
15
16
  searchAssist: new Map(),
16
17
  chatterAssist: new Map(),
17
18
  createAssist: new Map(),
@@ -58,6 +59,25 @@ export class Registry {
58
59
  registerHeaderItem(name, component, options = {}) {
59
60
  return this.#add('header', name, component, options.replace === true);
60
61
  }
62
+ #userPageLabels = new Map();
63
+ /**
64
+ * A personal page every signed-in user reaches from the user menu, outside any app (`settings.ApiKeys`):
65
+ * served at `/me/<name>`; `label` is an i18n key (`settings:apiKeys.title`). The server still decides what the
66
+ * page may show and do.
67
+ */
68
+ registerUserPage(name, component, options) {
69
+ this.#add('userPage', name, component, options.replace === true);
70
+ this.#userPageLabels.set(name, options.label);
71
+ return this;
72
+ }
73
+ /** The user pages, in registration order, with their label keys. */
74
+ userPages() {
75
+ return [...this.#entries.userPage].map(([name, component]) => ({
76
+ name,
77
+ label: this.#userPageLabels.get(name) ?? name,
78
+ component: component,
79
+ }));
80
+ }
61
81
  /** The header items, in registration order. */
62
82
  headerItems() {
63
83
  return [...this.#entries.header].map(([name, component]) => ({
@@ -19,23 +19,36 @@ import { cn } from '../lib/cn.js';
19
19
  import { useLiveRecord } from '../live.js';
20
20
  import { useToast } from '../shell/toast.js';
21
21
  import { PrefetchedNames, useRowNames } from './widgets.js';
22
- function commandsOf(lines) {
22
+ /** A line's values as the server takes them: many2many as `{ set }` (a line's taxes). */
23
+ function lineValues(values, fields) {
24
+ const out = {};
25
+ for (const [key, value] of Object.entries(values))
26
+ out[key] = fields[key]?.kind === 'many2many' && Array.isArray(value) ? { set: value } : value;
27
+ return out;
28
+ }
29
+ function commandsOf(lines, fields) {
23
30
  return {
24
31
  ...(lines.rows.some((r) => r.id === undefined)
25
- ? { create: lines.rows.filter((r) => r.id === undefined).map((r) => r.values) }
32
+ ? {
33
+ create: lines.rows
34
+ .filter((r) => r.id === undefined)
35
+ .map((r) => lineValues(r.values, fields)),
36
+ }
26
37
  : {}),
27
38
  ...(lines.rows.some((r) => r.id !== undefined && r.dirty)
28
39
  ? {
29
40
  update: lines.rows
30
41
  .filter((r) => r.id !== undefined && r.dirty)
31
- .map((r) => ({ id: r.id, values: r.values })),
42
+ .map((r) => ({ id: r.id, values: lineValues(r.values, fields) })),
32
43
  }
33
44
  : {}),
34
45
  ...(lines.deleted.length > 0 ? { delete: lines.deleted } : {}),
35
46
  };
36
47
  }
37
48
  /** The payload of a save: changed values, many2many as `{ set }`. */
38
- function payloadOf(changes, fields, lines) {
49
+ function payloadOf(changes, fields, lines,
50
+ /** The fields of a one2many's lines. */
51
+ lineFields) {
39
52
  const out = {};
40
53
  for (const [key, value] of Object.entries(changes)) {
41
54
  if (fields[key]?.kind === 'many2many')
@@ -44,7 +57,7 @@ function payloadOf(changes, fields, lines) {
44
57
  out[key] = value;
45
58
  }
46
59
  for (const [key, state] of lines) {
47
- const commands = commandsOf(state);
60
+ const commands = commandsOf(state, lineFields(key));
48
61
  if (Object.keys(commands).length > 0)
49
62
  out[key] = commands;
50
63
  }
@@ -177,7 +190,7 @@ export function FormView({ served, recordId, defaults, onSaved, onBack, crumbs,
177
190
  };
178
191
  const save = useMutation({
179
192
  mutationFn: async () => {
180
- const payload = payloadOf(changes, fields, lines);
193
+ const payload = payloadOf(changes, fields, lines, (key) => served.fields[fields[key]?.target ?? ''] ?? {});
181
194
  const etag = loaded.data?.etag ?? null;
182
195
  try {
183
196
  return isNew