@fayz-ai/saas 0.8.1 → 0.8.2

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 (233) hide show
  1. package/dist/access/context.d.ts +47 -0
  2. package/dist/access/context.d.ts.map +1 -0
  3. package/dist/access/headless.d.ts +21 -0
  4. package/dist/access/headless.d.ts.map +1 -0
  5. package/dist/access/index.d.ts +12 -0
  6. package/dist/access/index.d.ts.map +1 -0
  7. package/dist/access/limits-registry.d.ts +33 -0
  8. package/dist/access/limits-registry.d.ts.map +1 -0
  9. package/dist/access/module-nav.d.ts +15 -0
  10. package/dist/access/module-nav.d.ts.map +1 -0
  11. package/dist/access/resolver.d.ts +2 -0
  12. package/dist/access/resolver.d.ts.map +1 -0
  13. package/dist/access/types.d.ts +43 -0
  14. package/dist/access/types.d.ts.map +1 -0
  15. package/dist/access/upgrade-modal-store.d.ts +23 -0
  16. package/dist/access/upgrade-modal-store.d.ts.map +1 -0
  17. package/dist/app/AdminShell.d.ts +3 -0
  18. package/dist/app/AdminShell.d.ts.map +1 -1
  19. package/dist/app/admin-app.d.ts +3 -1
  20. package/dist/app/admin-app.d.ts.map +1 -1
  21. package/dist/app/config.d.ts +47 -2
  22. package/dist/app/config.d.ts.map +1 -1
  23. package/dist/app/core-entities.d.ts +25 -0
  24. package/dist/app/core-entities.d.ts.map +1 -0
  25. package/dist/app/derive-contract.d.ts +8 -0
  26. package/dist/app/derive-contract.d.ts.map +1 -0
  27. package/dist/app/scaffold.d.ts.map +1 -1
  28. package/dist/archetype-lookup.d.ts.map +1 -1
  29. package/dist/billing/index.cjs +6 -2
  30. package/dist/billing/index.d.ts +2 -0
  31. package/dist/billing/index.d.ts.map +1 -1
  32. package/dist/billing/index.js +1 -1
  33. package/dist/billing/plan-badge.d.ts +17 -0
  34. package/dist/billing/plan-badge.d.ts.map +1 -0
  35. package/dist/billing/store.d.ts +6 -1
  36. package/dist/billing/store.d.ts.map +1 -1
  37. package/dist/{chunk-3M25GRRU.js → chunk-I3DY47N5.js} +3 -4
  38. package/dist/chunk-I3DY47N5.js.map +1 -0
  39. package/dist/chunk-NNFGMUF2.cjs +39 -0
  40. package/dist/chunk-NNFGMUF2.cjs.map +1 -0
  41. package/dist/{chunk-4XTQ4V5L.cjs → chunk-SAZLW54Y.cjs} +16 -12
  42. package/dist/chunk-SAZLW54Y.cjs.map +1 -0
  43. package/dist/{chunk-IPWCHJPE.cjs → chunk-VKYZACG5.cjs} +5 -6
  44. package/dist/chunk-VKYZACG5.cjs.map +1 -0
  45. package/dist/chunk-VN5NTK26.js +36 -0
  46. package/dist/chunk-VN5NTK26.js.map +1 -0
  47. package/dist/{chunk-XNWCGJYH.js → chunk-VTEP2AQX.js} +15 -11
  48. package/dist/chunk-VTEP2AQX.js.map +1 -0
  49. package/dist/components/shared/ContactPicker.d.ts +92 -0
  50. package/dist/components/shared/ContactPicker.d.ts.map +1 -0
  51. package/dist/components/shared/PersonLink.d.ts +12 -1
  52. package/dist/components/shared/PersonLink.d.ts.map +1 -1
  53. package/dist/components/shared/create-person.d.ts +20 -0
  54. package/dist/components/shared/create-person.d.ts.map +1 -0
  55. package/dist/crud/CrudDetailPage.d.ts.map +1 -1
  56. package/dist/crud/CrudFormPage.d.ts.map +1 -1
  57. package/dist/crud/CrudListView.d.ts.map +1 -1
  58. package/dist/crud/CrudPage.d.ts.map +1 -1
  59. package/dist/crud/archetypes/AddressesTab.d.ts +21 -0
  60. package/dist/crud/archetypes/AddressesTab.d.ts.map +1 -0
  61. package/dist/crud/archetypes/PersonDetailTabs.d.ts +2 -1
  62. package/dist/crud/archetypes/PersonDetailTabs.d.ts.map +1 -1
  63. package/dist/crud/createCrudPage.d.ts.map +1 -1
  64. package/dist/crud/index.d.ts +1 -0
  65. package/dist/crud/index.d.ts.map +1 -1
  66. package/dist/index.cjs +4281 -716
  67. package/dist/index.cjs.map +1 -1
  68. package/dist/index.d.ts +17 -2
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +4067 -542
  71. package/dist/index.js.map +1 -1
  72. package/dist/org/index.cjs +11 -11
  73. package/dist/org/index.js +2 -2
  74. package/dist/org/store.d.ts.map +1 -1
  75. package/dist/permissions/context.d.ts +2 -0
  76. package/dist/permissions/context.d.ts.map +1 -1
  77. package/dist/permissions/index.cjs +7 -7
  78. package/dist/permissions/index.js +1 -1
  79. package/dist/shell/components/auth/ProtectedRoute.d.ts.map +1 -1
  80. package/dist/shell/components/billing/SoftLimitBanner.d.ts +5 -0
  81. package/dist/shell/components/billing/SoftLimitBanner.d.ts.map +1 -0
  82. package/dist/shell/components/billing/SubscriptionPage.d.ts +5 -0
  83. package/dist/shell/components/billing/SubscriptionPage.d.ts.map +1 -0
  84. package/dist/shell/components/billing/UpgradeModal.d.ts +3 -0
  85. package/dist/shell/components/billing/UpgradeModal.d.ts.map +1 -0
  86. package/dist/shell/components/billing/UpgradePrompt.d.ts +10 -0
  87. package/dist/shell/components/billing/UpgradePrompt.d.ts.map +1 -0
  88. package/dist/shell/components/billing/access-contract.d.ts +18 -0
  89. package/dist/shell/components/billing/access-contract.d.ts.map +1 -0
  90. package/dist/shell/components/billing/gates.d.ts +14 -0
  91. package/dist/shell/components/billing/gates.d.ts.map +1 -0
  92. package/dist/shell/components/billing/index.d.ts +9 -2
  93. package/dist/shell/components/billing/index.d.ts.map +1 -1
  94. package/dist/shell/components/chat/ChatFab.d.ts +3 -1
  95. package/dist/shell/components/chat/ChatFab.d.ts.map +1 -1
  96. package/dist/shell/components/chat/ChatPanel.d.ts +3 -1
  97. package/dist/shell/components/chat/ChatPanel.d.ts.map +1 -1
  98. package/dist/shell/components/chat/ConfirmActionCard.d.ts +13 -0
  99. package/dist/shell/components/chat/ConfirmActionCard.d.ts.map +1 -0
  100. package/dist/shell/components/chat/markdown.d.ts +5 -0
  101. package/dist/shell/components/chat/markdown.d.ts.map +1 -0
  102. package/dist/shell/components/organization/TeamTab.d.ts.map +1 -1
  103. package/dist/shell/components/organization/TenantOnboarding.d.ts.map +1 -1
  104. package/dist/shell/components/organization/index.d.ts +0 -1
  105. package/dist/shell/components/organization/index.d.ts.map +1 -1
  106. package/dist/shell/components/settings/ConnectedCompanySettings.d.ts.map +1 -1
  107. package/dist/shell/components/settings/ConnectedFieldRulesSettings.d.ts.map +1 -1
  108. package/dist/shell/components/settings/ConnectedSecuritySettings.d.ts.map +1 -1
  109. package/dist/shell/components/settings/LocationsCrudPage.d.ts +1 -1
  110. package/dist/shell/components/settings/LocationsCrudPage.d.ts.map +1 -1
  111. package/dist/shell/components/settings/SettingsPage.d.ts +23 -2
  112. package/dist/shell/components/settings/SettingsPage.d.ts.map +1 -1
  113. package/dist/shell/hooks/index.d.ts +3 -2
  114. package/dist/shell/hooks/index.d.ts.map +1 -1
  115. package/dist/shell/hooks/useAITools.d.ts +2 -0
  116. package/dist/shell/hooks/useAITools.d.ts.map +1 -1
  117. package/dist/shell/hooks/useChat.d.ts +22 -0
  118. package/dist/shell/hooks/useChat.d.ts.map +1 -1
  119. package/dist/shell/hooks/useSystemPermission.d.ts +3 -0
  120. package/dist/shell/hooks/useSystemPermission.d.ts.map +1 -0
  121. package/dist/shell/hooks/useTenantPluginSettings.d.ts +12 -0
  122. package/dist/shell/hooks/useTenantPluginSettings.d.ts.map +1 -0
  123. package/dist/shell/lib/ai-tool-handlers.d.ts +119 -0
  124. package/dist/shell/lib/ai-tool-handlers.d.ts.map +1 -0
  125. package/dist/shell/lib/core-ai-tools.d.ts +14 -0
  126. package/dist/shell/lib/core-ai-tools.d.ts.map +1 -1
  127. package/dist/shell/lib/create-client-orders-provider.d.ts.map +1 -1
  128. package/dist/shell/lib/fayz-agent.d.ts +23 -0
  129. package/dist/shell/lib/fayz-agent.d.ts.map +1 -0
  130. package/dist/shell/lib/i18n.d.ts.map +1 -1
  131. package/dist/shell/stores/chat.store.d.ts +69 -0
  132. package/dist/shell/stores/chat.store.d.ts.map +1 -1
  133. package/dist/shell/types/billing.d.ts +7 -0
  134. package/dist/shell/types/billing.d.ts.map +1 -1
  135. package/dist/shell/types/plugins.d.ts +2 -45
  136. package/dist/shell/types/plugins.d.ts.map +1 -1
  137. package/dist/stores/createCrudStore.d.ts.map +1 -1
  138. package/package.json +14 -11
  139. package/src/access/context.tsx +228 -0
  140. package/src/access/headless.ts +71 -0
  141. package/src/access/index.ts +27 -0
  142. package/src/access/limits-registry.ts +86 -0
  143. package/src/access/module-nav.test.ts +78 -0
  144. package/src/access/module-nav.ts +82 -0
  145. package/src/access/resolver.test.ts +143 -0
  146. package/src/access/resolver.ts +5 -0
  147. package/src/access/types.ts +47 -0
  148. package/src/access/upgrade-modal-store.ts +30 -0
  149. package/src/app/AdminShell.tsx +451 -68
  150. package/src/app/admin-app.tsx +46 -9
  151. package/src/app/config.ts +54 -1
  152. package/src/app/core-entities.ts +110 -0
  153. package/src/app/derive-contract.test.ts +155 -0
  154. package/src/app/derive-contract.ts +214 -0
  155. package/src/app/scaffold.tsx +4 -0
  156. package/src/archetype-lookup.ts +8 -2
  157. package/src/billing/index.ts +2 -0
  158. package/src/billing/plan-badge.ts +32 -0
  159. package/src/billing/store.ts +8 -1
  160. package/src/components/shared/ContactPicker.tsx +394 -0
  161. package/src/components/shared/PersonLink.tsx +11 -3
  162. package/src/components/shared/create-person.ts +74 -0
  163. package/src/crud/CrudDetailPage.tsx +5 -1
  164. package/src/crud/CrudFormPage.tsx +113 -6
  165. package/src/crud/CrudListView.tsx +16 -1
  166. package/src/crud/CrudPage.tsx +27 -5
  167. package/src/crud/DeleteConfirmDialog.tsx +1 -1
  168. package/src/crud/archetypes/AddressesTab.tsx +387 -0
  169. package/src/crud/archetypes/PersonDetailTabs.tsx +5 -0
  170. package/src/crud/createCrudPage.tsx +2 -0
  171. package/src/crud/index.ts +6 -0
  172. package/src/env.d.ts +7 -0
  173. package/src/index.ts +49 -1
  174. package/src/org/store.ts +9 -1
  175. package/src/permissions/context.tsx +29 -15
  176. package/src/shell/components/auth/ProtectedRoute.tsx +12 -2
  177. package/src/shell/components/billing/SoftLimitBanner.tsx +81 -0
  178. package/src/shell/components/billing/SubscriptionPage.tsx +372 -0
  179. package/src/shell/components/billing/UpgradeModal.tsx +102 -0
  180. package/src/shell/components/billing/UpgradePrompt.tsx +229 -0
  181. package/src/shell/components/billing/access-contract.ts +64 -0
  182. package/src/shell/components/billing/gates.tsx +62 -0
  183. package/src/shell/components/billing/index.ts +18 -2
  184. package/src/shell/components/chat/ChatFab.tsx +4 -2
  185. package/src/shell/components/chat/ChatPanel.tsx +174 -23
  186. package/src/shell/components/chat/ConfirmActionCard.tsx +57 -0
  187. package/src/shell/components/chat/markdown.tsx +102 -0
  188. package/src/shell/components/organization/TeamTab.tsx +21 -4
  189. package/src/shell/components/organization/TenantOnboarding.tsx +3 -1
  190. package/src/shell/components/organization/index.ts +0 -1
  191. package/src/shell/components/settings/ConnectedCompanySettings.tsx +4 -1
  192. package/src/shell/components/settings/ConnectedFieldRulesSettings.tsx +3 -1
  193. package/src/shell/components/settings/ConnectedSecuritySettings.tsx +3 -1
  194. package/src/shell/components/settings/LocationsCrudPage.ts +6 -1
  195. package/src/shell/components/settings/SettingsPage.tsx +63 -21
  196. package/src/shell/hooks/index.ts +3 -2
  197. package/src/shell/hooks/useAITools.ts +27 -23
  198. package/src/shell/hooks/useChat.ts +381 -16
  199. package/src/shell/hooks/useSystemPermission.ts +23 -0
  200. package/src/shell/hooks/useTenantPluginSettings.ts +127 -0
  201. package/src/shell/lib/ai-tool-handlers.ts +910 -0
  202. package/src/shell/lib/core-ai-tools.ts +234 -2
  203. package/src/shell/lib/create-client-orders-provider.ts +24 -2
  204. package/src/shell/lib/fayz-agent.ts +56 -0
  205. package/src/shell/lib/i18n.ts +71 -0
  206. package/src/shell/stores/chat.store.ts +118 -1
  207. package/src/shell/styles.css +30 -0
  208. package/src/shell/types/billing.ts +8 -0
  209. package/src/shell/types/plugins.ts +13 -50
  210. package/src/stores/createCrudStore.ts +9 -0
  211. package/dist/chunk-3M25GRRU.js.map +0 -1
  212. package/dist/chunk-4XTQ4V5L.cjs.map +0 -1
  213. package/dist/chunk-5GYQK5IW.js +0 -21
  214. package/dist/chunk-5GYQK5IW.js.map +0 -1
  215. package/dist/chunk-IPWCHJPE.cjs.map +0 -1
  216. package/dist/chunk-KYID2MR6.cjs +0 -23
  217. package/dist/chunk-KYID2MR6.cjs.map +0 -1
  218. package/dist/chunk-XNWCGJYH.js.map +0 -1
  219. package/dist/shell/components/billing/BillingPage.d.ts +0 -7
  220. package/dist/shell/components/billing/BillingPage.d.ts.map +0 -1
  221. package/dist/shell/components/billing/PaywallGate.d.ts +0 -11
  222. package/dist/shell/components/billing/PaywallGate.d.ts.map +0 -1
  223. package/dist/shell/components/organization/PermissionGate.d.ts +0 -11
  224. package/dist/shell/components/organization/PermissionGate.d.ts.map +0 -1
  225. package/dist/shell/hooks/useBilling.d.ts +0 -14
  226. package/dist/shell/hooks/useBilling.d.ts.map +0 -1
  227. package/dist/shell/hooks/usePermission.d.ts +0 -16
  228. package/dist/shell/hooks/usePermission.d.ts.map +0 -1
  229. package/src/shell/components/billing/BillingPage.tsx +0 -56
  230. package/src/shell/components/billing/PaywallGate.tsx +0 -80
  231. package/src/shell/components/organization/PermissionGate.tsx +0 -16
  232. package/src/shell/hooks/useBilling.ts +0 -151
  233. package/src/shell/hooks/usePermission.ts +0 -101
@@ -14,9 +14,11 @@ import { ServiceFormLayout } from './archetypes/ServiceFormLayout'
14
14
  import { LocationFormLayout } from './archetypes/LocationFormLayout'
15
15
  import { SubjectFormLayout } from './archetypes/SubjectFormLayout'
16
16
  import { useTranslation } from '@fayz-ai/core'
17
+ import type { EntityImageConfig } from '@fayz-ai/core'
17
18
  import type { FieldDef, FieldGroup, EntityDef } from '@fayz-ai/core'
18
19
  import type { FormLayout } from '@fayz-ai/core'
19
20
  import { RelationSelect } from './relation-field'
21
+ import { useLimitGuard, invalidateLimit } from '../access'
20
22
 
21
23
  interface CrudFormPageProps {
22
24
  entityDef: EntityDef
@@ -248,16 +250,104 @@ function FormFieldItem({ field, value, onChange, allValues }: { field: FieldDef;
248
250
  )
249
251
  }
250
252
 
253
+ /**
254
+ * The image slot, wired. Renders the record's current image and, when the entity
255
+ * supplies an `upload`, lets it be replaced. Upload attaches the file to an
256
+ * existing record id, so in create mode the slot explains itself instead of
257
+ * offering an action that cannot work yet.
258
+ */
259
+ function ImageSlot({
260
+ image,
261
+ row,
262
+ canEdit,
263
+ }: {
264
+ image: EntityImageConfig
265
+ row: Record<string, any>
266
+ canEdit: boolean
267
+ }) {
268
+ const t = useTranslation()
269
+ const inputRef = React.useRef<HTMLInputElement>(null)
270
+ const [url, setUrl] = React.useState<string | undefined>(() => image.get(row))
271
+ const [busy, setBusy] = React.useState(false)
272
+ const [error, setError] = React.useState<string | null>(null)
273
+
274
+ // The form mounts before the record is loaded in edit mode, so the initial
275
+ // value can be undefined and arrive later.
276
+ React.useEffect(() => { setUrl(image.get(row)) }, [image.get(row)])
277
+
278
+ const pick = () => { if (canEdit && !busy) inputRef.current?.click() }
279
+
280
+ const onFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
281
+ const file = e.target.files?.[0]
282
+ e.target.value = ''
283
+ if (!file || !image.upload) return
284
+ setBusy(true)
285
+ setError(null)
286
+ try {
287
+ setUrl(await image.upload(file, row))
288
+ } catch (err: unknown) {
289
+ setError(err instanceof Error ? err.message : String(err))
290
+ } finally {
291
+ setBusy(false)
292
+ }
293
+ }
294
+
295
+ const editable = canEdit && Boolean(image.upload)
296
+
297
+ return (
298
+ <div className="shrink-0">
299
+ <div
300
+ onClick={pick}
301
+ role={editable ? 'button' : undefined}
302
+ tabIndex={editable ? 0 : undefined}
303
+ onKeyDown={(e) => { if (editable && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); pick() } }}
304
+ title={editable ? t('crud.form.changeImage') : undefined}
305
+ className={`relative flex h-20 w-20 items-center justify-center overflow-hidden rounded-lg border-2 text-muted-foreground transition-colors ${
306
+ url ? 'border-solid border-border' : 'border-dashed border-muted'
307
+ } ${editable ? 'cursor-pointer hover:border-primary/30 hover:text-primary/50' : ''}`}
308
+ >
309
+ {url
310
+ ? <img src={url} alt="" className="h-full w-full object-cover" />
311
+ : <ImagePlus className="h-5 w-5" />}
312
+ {busy && (
313
+ <span className="absolute inset-0 flex items-center justify-center bg-background/70 text-[10px]">
314
+ ...
315
+ </span>
316
+ )}
317
+ </div>
318
+ {editable && (
319
+ <input
320
+ ref={inputRef}
321
+ type="file"
322
+ accept={image.accept ?? 'image/*'}
323
+ className="hidden"
324
+ onChange={onFile}
325
+ />
326
+ )}
327
+ {!canEdit && (
328
+ <p className="mt-1 w-20 text-[10px] leading-tight text-muted-foreground">
329
+ {t('crud.form.imageAfterSave')}
330
+ </p>
331
+ )}
332
+ {error && <p className="mt-1 w-20 text-[10px] leading-tight text-destructive">{error}</p>}
333
+ </div>
334
+ )
335
+ }
336
+
251
337
  function FormGroup({
252
338
  group,
253
339
  fields,
254
340
  values,
255
341
  onChange,
342
+ image,
343
+ canEditImage,
256
344
  }: {
257
345
  group: FieldGroup
258
346
  fields: FieldDef[]
259
347
  values: Record<string, any>
260
348
  onChange: (key: string, val: any) => void
349
+ image?: EntityImageConfig
350
+ canEditImage?: boolean
261
351
  }) {
262
352
  const cols = group.columns ?? 2
263
353
 
@@ -285,12 +375,16 @@ function FormGroup({
285
375
  <CardContent className="pt-4">
286
376
  {group.imageSlot ? (
287
377
  <div className="flex gap-4">
288
- {/* Decorative image slot — non-functional placeholder for now. */}
289
- <div className="shrink-0">
290
- <div className="flex h-20 w-20 cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-muted text-muted-foreground transition-colors hover:border-primary/30 hover:text-primary/50">
291
- <ImagePlus className="h-5 w-5" />
292
- </div>
293
- </div>
378
+ {image
379
+ ? <ImageSlot image={image} row={values} canEdit={Boolean(canEditImage)} />
380
+ : (
381
+ // No entity image config: keep the old decorative square.
382
+ <div className="shrink-0">
383
+ <div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-muted text-muted-foreground">
384
+ <ImagePlus className="h-5 w-5" />
385
+ </div>
386
+ </div>
387
+ )}
294
388
  <div className="flex-1">{grid}</div>
295
389
  </div>
296
390
  ) : (
@@ -320,7 +414,16 @@ export function CrudFormPage({ entityDef, mode, initialData, onSubmit, onCancel,
320
414
 
321
415
  const computedKeys = new Set(formFields.filter((f) => f.type === 'computed').map((f) => f.key))
322
416
 
417
+ // Plan quantity guard for CREATE. Hook is called unconditionally (rules of
418
+ // hooks) with a safe empty key when the entity declares none; the guard then
419
+ // resolves to 'ok'. Degrades to allow-all outside <AccessProvider>.
420
+ const limitKey = entityDef.limitKey
421
+ const guardLimit = useLimitGuard(limitKey ?? '')
422
+
323
423
  const submit = async () => {
424
+ // Client-side plan cap: block the save (the guard opens the UpgradeModal)
425
+ // before touching the store. Only CREATE consumes a new slot.
426
+ if (mode === 'create' && limitKey && (await guardLimit(1)) === 'blocked') return
324
427
  setSaving(true)
325
428
  try {
326
429
  // Sanitize: convert empty strings to null so the DB doesn't choke on e.g. empty date fields.
@@ -331,6 +434,8 @@ export function CrudFormPage({ entityDef, mode, initialData, onSubmit, onCancel,
331
434
  sanitized[key] = val === '' ? null : val
332
435
  }
333
436
  await onSubmit(sanitized)
437
+ // Refresh the live count so gates/banners reflect the new row immediately.
438
+ if (mode === 'create' && limitKey) invalidateLimit(limitKey)
334
439
  } catch (err: any) {
335
440
  const message = err?.message || 'Something went wrong'
336
441
  toast.error(t('crud.form.failedToSave', { entity: entityDef.name.toLowerCase() }), { description: message })
@@ -446,6 +551,8 @@ export function CrudFormPage({ entityDef, mode, initialData, onSubmit, onCancel,
446
551
  fields={fields}
447
552
  values={values}
448
553
  onChange={handleChange}
554
+ image={entityDef.image}
555
+ canEditImage={mode === 'edit'}
449
556
  />
450
557
  )
451
558
  })}
@@ -10,6 +10,7 @@ import { Dropdown, DropdownTrigger, DropdownContent, DropdownItem } from '@fayz-
10
10
  import { fieldToColumns } from './fieldToColumn'
11
11
  import { CrudCardGrid } from './CrudCardGrid'
12
12
  import { PermissionGate } from '../permissions/PermissionGate'
13
+ import { LimitGate } from '../shell/components/billing/gates'
13
14
 
14
15
  /** A resolved facet: a field plus the option pills to render. */
15
16
  export interface CrudFacet {
@@ -112,6 +113,20 @@ export function CrudListView<T extends { id: string }>({
112
113
  ? <Button onClick={onNew}>{addLabel ?? t('crud.list.addEntity', { entity: entityDef.name })}</Button>
113
114
  : null
114
115
 
116
+ // Compose the create action's gates: RBAC (PermissionGate, role axis) then the
117
+ // plan quantity cap (LimitGate, plan axis). At the cap, LimitGate dims the
118
+ // button and intercepts the click to open the global UpgradeModal.
119
+ const gatedNewButton = newButton
120
+ ? (() => {
121
+ const roleGated = feature
122
+ ? <PermissionGate feature={feature} action="create">{newButton}</PermissionGate>
123
+ : newButton
124
+ return entityDef.limitKey
125
+ ? <LimitGate limitKey={entityDef.limitKey}>{roleGated}</LimitGate>
126
+ : roleGated
127
+ })()
128
+ : null
129
+
115
130
  return (
116
131
  <div className="space-y-6">
117
132
  <div className="flex items-center justify-between">
@@ -124,7 +139,7 @@ export function CrudListView<T extends { id: string }>({
124
139
  )}
125
140
  </div>
126
141
  <div className="flex items-center gap-2">
127
- {newButton && (feature ? <PermissionGate feature={feature} action="create">{newButton}</PermissionGate> : newButton)}
142
+ {gatedNewButton}
128
143
  {hasImportExport && (
129
144
  <Dropdown>
130
145
  <DropdownTrigger asChild>
@@ -13,6 +13,7 @@ import { ImportWizard } from './ImportWizard'
13
13
  import { exportToCSV } from './csv-export'
14
14
  import { fieldToColumns } from './fieldToColumn'
15
15
  import { PermissionGate } from '../permissions/PermissionGate'
16
+ import { useLimitGuard, invalidateLimit } from '../access'
16
17
  import { useFieldRules } from '../hooks/useFieldRules'
17
18
  import { deriveEntityKey } from '@fayz-ai/core'
18
19
  import { toast } from '@fayz-ai/ui'
@@ -196,6 +197,7 @@ export function CrudPage<T extends { id: string }>({ entityDef: rawEntityDef, us
196
197
  }, [store])
197
198
  const tanColumns = useCrudColumns(entityDef, { basePath: normalizedBasePath, onDelete: readOnly ? undefined : (item) => setDeleteItem(item), onInlineUpdate: handleInlineUpdate, feature, readOnly })
198
199
  const currentOrgId = useOrganizationStore((s) => s.currentOrg?.id)
200
+ const guardImport = useLimitGuard(entityDef.limitKey ?? '')
199
201
 
200
202
  useEffect(() => {
201
203
  if (currentOrgId) store.fetch()
@@ -216,11 +218,25 @@ export function CrudPage<T extends { id: string }>({ entityDef: rawEntityDef, us
216
218
  }
217
219
  }
218
220
 
221
+ // Plan quantity guard BEFORE the batch insert: adding `rows.length` rows must
222
+ // stay within the plan cap. When it would exceed, the guard opens the global
223
+ // UpgradeModal; we also surface a clear toast + a wizard error row and abort.
224
+ if (entityDef.limitKey && (await guardImport(rows.length)) === 'blocked') {
225
+ toast.error(t('crud.import.limitExceeded'))
226
+ return {
227
+ success: 0,
228
+ errors: [{ row: 0, message: t('crud.import.limitExceeded') }],
229
+ }
230
+ }
231
+
219
232
  const { success, results } = await store.createMany(rows, {
220
233
  batchSize: 10,
221
234
  onProgress,
222
235
  })
223
236
 
237
+ // Refresh the live count so gates/banners reflect the imported rows.
238
+ if (success > 0 && entityDef.limitKey) invalidateLimit(entityDef.limitKey)
239
+
224
240
  const errors: ImportRowError[] = []
225
241
  for (let i = 0; i < results.length; i++) {
226
242
  if (results[i].error) {
@@ -232,7 +248,7 @@ export function CrudPage<T extends { id: string }>({ entityDef: rawEntityDef, us
232
248
  toast.success(t('crud.import.toastSuccess', { count: String(success) }))
233
249
  }
234
250
  return { success, errors }
235
- }, [store, t])
251
+ }, [store, t, guardImport, entityDef.limitKey])
236
252
 
237
253
  const handleExport = useCallback(() => {
238
254
  const items = store.items
@@ -460,10 +476,16 @@ export function CrudPage<T extends { id: string }>({ entityDef: rawEntityDef, us
460
476
  )
461
477
  }
462
478
 
463
- const handleDeleteConfirm = () => {
464
- if (deleteItem) {
465
- store.remove((deleteItem as any).id)
466
- setDeleteItem(null)
479
+ const handleDeleteConfirm = async () => {
480
+ if (!deleteItem) return
481
+ const id = (deleteItem as any).id
482
+ setDeleteItem(null)
483
+ // Await the removal so the (cache-invalidated) list refetch completes and the
484
+ // deleted row is gone before we settle on the list view — previously this was
485
+ // fire-and-forget and raced with navigation, leaving a ghost row behind.
486
+ try {
487
+ await store.remove(id)
488
+ } finally {
467
489
  navigateToList()
468
490
  }
469
491
  }
@@ -27,7 +27,7 @@ export function DeleteConfirmDialog({ open, onClose, onConfirm, entityName, disp
27
27
  <ModalTitle>{t('crud.delete.title', { entity: entityName })}</ModalTitle>
28
28
  <ModalDescription>
29
29
  {displayValue
30
- ? t('crud.delete.confirmNamed', { name: displayValue })
30
+ ? t('crud.delete.confirmNamed', { displayValue })
31
31
  : t('crud.delete.confirmGeneric', { entity: entityName.toLowerCase() })}
32
32
  </ModalDescription>
33
33
  </ModalHeader>
@@ -0,0 +1,387 @@
1
+ import React, { useCallback, useEffect, useState } from 'react'
2
+ import { MapPin, Star, Pencil, Trash2, Plus } from 'lucide-react'
3
+ import {
4
+ Card, CardContent, Badge, Button, Input, ConfirmDialog,
5
+ Modal, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalFooter,
6
+ Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
7
+ } from '@fayz-ai/ui'
8
+ import { getSupabaseClientOptional, getActiveTenantId, useTranslation } from '@fayz-ai/core'
9
+ import { toast } from '../../shell/components/notifications/ToastProvider'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Addresses tab — generic on purpose. public.addresses is a CORE table (spine
13
+ // migration @fayz-ai/db 017_core_addresses), so any app with a person record
14
+ // gets this: a client's delivery addresses, a supplier's pickup point, a staff
15
+ // member's home address. Nothing here is shop-specific.
16
+ //
17
+ // Rows are read by owner_id alone, without an owner_type filter, because a
18
+ // person can be reached from two sides today: a core `people` row, or a
19
+ // module's own customer row that mirrors it (the shop's plg_shop_customers
20
+ // keeps the same id). Both resolve to the same address book. Rows WRITTEN here
21
+ // are stamped 'person' — the spine's canonical owner kind.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface AddressRow {
25
+ id: string
26
+ label: string | null
27
+ recipient: string | null
28
+ phone: string | null
29
+ postal_code: string
30
+ street: string
31
+ number: string | null
32
+ complement: string | null
33
+ district: string | null
34
+ city: string
35
+ state: string
36
+ country: string
37
+ is_default: boolean
38
+ kind: string
39
+ }
40
+
41
+ /** The editable shape. Separate from AddressRow: no id, every field a string. */
42
+ interface AddressForm {
43
+ label: string
44
+ kind: string
45
+ recipient: string
46
+ phone: string
47
+ postal_code: string
48
+ street: string
49
+ number: string
50
+ complement: string
51
+ district: string
52
+ city: string
53
+ state: string
54
+ is_default: boolean
55
+ }
56
+
57
+ const EMPTY_FORM: AddressForm = {
58
+ label: '', kind: 'both', recipient: '', phone: '', postal_code: '', street: '',
59
+ number: '', complement: '', district: '', city: '', state: '', is_default: false,
60
+ }
61
+
62
+ function toForm(a: AddressRow): AddressForm {
63
+ return {
64
+ label: a.label ?? '', kind: a.kind, recipient: a.recipient ?? '', phone: a.phone ?? '',
65
+ postal_code: a.postal_code, street: a.street, number: a.number ?? '',
66
+ complement: a.complement ?? '', district: a.district ?? '', city: a.city,
67
+ state: a.state, is_default: a.is_default,
68
+ }
69
+ }
70
+
71
+ const SPAN_CLASS: Record<number, string> = {
72
+ 1: 'sm:col-span-1', 2: 'sm:col-span-2', 3: 'sm:col-span-3',
73
+ 4: 'sm:col-span-4', 6: 'sm:col-span-6',
74
+ }
75
+
76
+ /** Label + control, wired by htmlFor/id so the label is actually clickable and
77
+ * the control is reachable by its accessible name. */
78
+ function Field({ id, label, required, span, children }: {
79
+ id: string
80
+ label: string
81
+ required?: boolean
82
+ span: number
83
+ children: React.ReactNode
84
+ }) {
85
+ return (
86
+ <div className={SPAN_CLASS[span]}>
87
+ <label htmlFor={`address-${id}`} className="text-xs text-muted-foreground">
88
+ {label}{required ? ' *' : ''}
89
+ </label>
90
+ {children}
91
+ </div>
92
+ )
93
+ }
94
+
95
+ function formatLine(a: AddressRow): string {
96
+ // Rua, número — complemento · bairro · cidade/UF · CEP, skipping whatever is
97
+ // missing so a partial address never renders stray commas.
98
+ const streetLine = [a.street, a.number].filter(Boolean).join(', ')
99
+ const withComplement = a.complement ? `${streetLine} — ${a.complement}` : streetLine
100
+ return [withComplement, a.district, `${a.city}/${a.state}`, a.postal_code]
101
+ .filter((part) => part && part !== '—' && part !== '/')
102
+ .join(' · ')
103
+ }
104
+
105
+ export function AddressesTab({ item }: { item?: unknown }) {
106
+ const t = useTranslation()
107
+ const record = item as { id?: string } | undefined
108
+ const [rows, setRows] = useState<AddressRow[]>([])
109
+ const [loading, setLoading] = useState(true)
110
+ const [error, setError] = useState<string | null>(null)
111
+
112
+ const [editing, setEditing] = useState<AddressRow | null>(null)
113
+ const [formOpen, setFormOpen] = useState(false)
114
+ const [form, setForm] = useState<AddressForm>(EMPTY_FORM)
115
+ const [saving, setSaving] = useState(false)
116
+ const [deleting, setDeleting] = useState<AddressRow | null>(null)
117
+
118
+ const ownerId = record?.id
119
+
120
+ // getSupabaseClientOptional is loosely typed at this layer; the query shape is
121
+ // asserted here rather than pulling supabase-js into the UI package.
122
+ const db = () => getSupabaseClientOptional() as unknown as { from: (t: string) => any } | null
123
+
124
+ const load = useCallback(async () => {
125
+ const client = db()
126
+ if (!client || !ownerId) { setLoading(false); return }
127
+ setLoading(true)
128
+ setError(null)
129
+ const { data, error: err } = await client
130
+ .from('addresses')
131
+ .select('*')
132
+ .eq('owner_id', ownerId)
133
+ .order('is_default', { ascending: false })
134
+ if (err) setError(err.message)
135
+ else setRows((data ?? []) as AddressRow[])
136
+ setLoading(false)
137
+ }, [ownerId])
138
+
139
+ useEffect(() => { void load() }, [load])
140
+
141
+ const openCreate = () => { setEditing(null); setForm(EMPTY_FORM); setFormOpen(true) }
142
+ const openEdit = (a: AddressRow) => { setEditing(a); setForm(toForm(a)); setFormOpen(true) }
143
+
144
+ const set = <K extends keyof AddressForm>(key: K, value: AddressForm[K]) =>
145
+ setForm((prev) => ({ ...prev, [key]: value }))
146
+
147
+ // CEP, street and city are what make an address findable; the rest is optional.
148
+ const canSave = form.postal_code.trim() !== '' && form.street.trim() !== '' && form.city.trim() !== ''
149
+
150
+ const handleSave = async () => {
151
+ const client = db()
152
+ if (!client || !ownerId || !canSave) return
153
+ const tenantId = getActiveTenantId()
154
+ if (!tenantId) { toast.error(t('crud.archetype.addresses.noTenant')); return }
155
+
156
+ setSaving(true)
157
+ try {
158
+ const values = {
159
+ label: form.label.trim() || null,
160
+ kind: form.kind,
161
+ recipient: form.recipient.trim() || null,
162
+ phone: form.phone.trim() || null,
163
+ postal_code: form.postal_code.trim(),
164
+ street: form.street.trim(),
165
+ number: form.number.trim() || null,
166
+ complement: form.complement.trim() || null,
167
+ district: form.district.trim() || null,
168
+ city: form.city.trim(),
169
+ state: form.state.trim().toUpperCase(),
170
+ is_default: form.is_default,
171
+ }
172
+
173
+ // addresses_one_default_idx is a UNIQUE partial index on
174
+ // (owner_type, owner_id, kind) WHERE is_default — the previous default
175
+ // must be cleared BEFORE this row claims the flag, or the write fails.
176
+ if (form.is_default) {
177
+ let q = client.from('addresses').update({ is_default: false })
178
+ .eq('owner_id', ownerId).eq('kind', form.kind).eq('is_default', true)
179
+ if (editing) q = q.neq('id', editing.id)
180
+ const { error: clearErr } = await q
181
+ if (clearErr) throw clearErr
182
+ }
183
+
184
+ const { error: err } = editing
185
+ ? await client.from('addresses').update(values).eq('id', editing.id)
186
+ : await client.from('addresses').insert({
187
+ ...values,
188
+ tenant_id: tenantId,
189
+ owner_type: 'person',
190
+ owner_id: ownerId,
191
+ })
192
+ if (err) throw err
193
+
194
+ setFormOpen(false)
195
+ await load()
196
+ toast.success(editing ? t('crud.archetype.addresses.updated') : t('crud.archetype.addresses.created'))
197
+ } catch (e: any) {
198
+ toast.error(t('crud.archetype.addresses.saveFailed'), { description: e?.message })
199
+ } finally {
200
+ setSaving(false)
201
+ }
202
+ }
203
+
204
+ const handleDelete = async () => {
205
+ const client = db()
206
+ if (!client || !deleting) return
207
+ const { error: err } = await client.from('addresses').delete().eq('id', deleting.id)
208
+ setDeleting(null)
209
+ if (err) { toast.error(t('crud.archetype.addresses.deleteFailed'), { description: err.message }); return }
210
+ await load()
211
+ toast.success(t('crud.archetype.addresses.deleted'))
212
+ }
213
+
214
+ if (loading) {
215
+ return (
216
+ <Card><CardContent className="space-y-2 py-6">
217
+ <div className="h-4 w-56 animate-pulse rounded bg-muted/40" />
218
+ <div className="h-4 w-40 animate-pulse rounded bg-muted/30" />
219
+ </CardContent></Card>
220
+ )
221
+ }
222
+
223
+ if (error) {
224
+ return (
225
+ <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
226
+ <p className="font-medium">{t('crud.archetype.addresses.loadFailed')}</p>
227
+ <p className="mt-1 text-red-700">{error}</p>
228
+ </div>
229
+ )
230
+ }
231
+
232
+ return (
233
+ <div className="space-y-3">
234
+ <div className="flex justify-end">
235
+ <Button size="sm" onClick={openCreate}>
236
+ <Plus className="mr-1.5 h-3.5 w-3.5" /> {t('crud.archetype.addresses.add')}
237
+ </Button>
238
+ </div>
239
+
240
+ {rows.length === 0 ? (
241
+ <Card><CardContent className="flex flex-col items-center justify-center py-12 text-center">
242
+ <div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
243
+ <MapPin className="h-5 w-5 text-muted-foreground" />
244
+ </div>
245
+ <p className="text-sm font-medium">{t('crud.archetype.addresses.emptyTitle')}</p>
246
+ <p className="mt-1 max-w-xs text-xs text-muted-foreground">
247
+ {t('crud.archetype.addresses.emptyDescription')}
248
+ </p>
249
+ </CardContent></Card>
250
+ ) : (
251
+ <div className="space-y-2">
252
+ {rows.map((a) => (
253
+ <Card key={a.id}>
254
+ <CardContent className="flex items-start gap-3 py-3">
255
+ <MapPin className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
256
+ <div className="min-w-0 flex-1">
257
+ <div className="flex flex-wrap items-center gap-2">
258
+ <span className="text-sm font-medium">
259
+ {a.label ?? t('crud.archetype.addresses.fallbackLabel')}
260
+ </span>
261
+ {a.is_default && (
262
+ <Badge variant="secondary" className="gap-1 text-[10px]">
263
+ <Star className="h-2.5 w-2.5" /> {t('crud.archetype.addresses.default')}
264
+ </Badge>
265
+ )}
266
+ {a.kind !== 'both' && (
267
+ <Badge variant="secondary" className="text-[10px]">
268
+ {t(`crud.archetype.addresses.kind.${a.kind}`)}
269
+ </Badge>
270
+ )}
271
+ </div>
272
+ <p className="mt-0.5 text-sm text-muted-foreground">{formatLine(a)}</p>
273
+ {(a.recipient || a.phone) && (
274
+ <p className="mt-0.5 text-xs text-muted-foreground">
275
+ {[a.recipient, a.phone].filter(Boolean).join(' · ')}
276
+ </p>
277
+ )}
278
+ </div>
279
+ <div className="flex shrink-0 gap-1">
280
+ {/* Named for the ADDRESS, not just "Editar": the person
281
+ detail header already owns a plain Editar/Excluir pair,
282
+ and two identical accessible names on one screen is a
283
+ coin flip for a screen reader user. */}
284
+ <Button variant="ghost" size="sm" onClick={() => openEdit(a)}
285
+ aria-label={t('crud.archetype.addresses.editTitle')}>
286
+ <Pencil className="h-3.5 w-3.5" />
287
+ </Button>
288
+ <Button variant="ghost" size="sm" onClick={() => setDeleting(a)}
289
+ aria-label={t('crud.archetype.addresses.deleteTitle')}>
290
+ <Trash2 className="h-3.5 w-3.5 text-destructive" />
291
+ </Button>
292
+ </div>
293
+ </CardContent>
294
+ </Card>
295
+ ))}
296
+ </div>
297
+ )}
298
+
299
+ <Modal open={formOpen} onOpenChange={setFormOpen}>
300
+ <ModalContent size="lg">
301
+ <ModalHeader>
302
+ <ModalTitle>
303
+ {editing ? t('crud.archetype.addresses.editTitle') : t('crud.archetype.addresses.addTitle')}
304
+ </ModalTitle>
305
+ <ModalDescription>{t('crud.archetype.addresses.formDescription')}</ModalDescription>
306
+ </ModalHeader>
307
+
308
+ <div className="grid grid-cols-1 gap-3 px-6 sm:grid-cols-6">
309
+ <Field id="label" span={3} label={t('crud.archetype.addresses.field.label')}>
310
+ <Input id="address-label" value={form.label} onChange={(e) => set('label', e.target.value)}
311
+ placeholder={t('crud.archetype.addresses.labelPlaceholder')} />
312
+ </Field>
313
+ <Field id="kind" span={3} label={t('crud.archetype.addresses.field.kind')}>
314
+ <Select value={form.kind} onValueChange={(v: string) => set('kind', v)}>
315
+ <SelectTrigger id="address-kind"><SelectValue /></SelectTrigger>
316
+ <SelectContent>
317
+ <SelectItem value="both">{t('crud.archetype.addresses.kind.both')}</SelectItem>
318
+ <SelectItem value="shipping">{t('crud.archetype.addresses.kind.shipping')}</SelectItem>
319
+ <SelectItem value="billing">{t('crud.archetype.addresses.kind.billing')}</SelectItem>
320
+ </SelectContent>
321
+ </Select>
322
+ </Field>
323
+
324
+ <Field id="postal_code" span={2} required label={t('crud.archetype.addresses.field.postalCode')}>
325
+ <Input id="address-postal_code" value={form.postal_code}
326
+ onChange={(e) => set('postal_code', e.target.value)} placeholder="00000-000" />
327
+ </Field>
328
+ <Field id="street" span={4} required label={t('crud.archetype.addresses.field.street')}>
329
+ <Input id="address-street" value={form.street} onChange={(e) => set('street', e.target.value)} />
330
+ </Field>
331
+
332
+ <Field id="number" span={2} label={t('crud.archetype.addresses.field.number')}>
333
+ <Input id="address-number" value={form.number} onChange={(e) => set('number', e.target.value)} />
334
+ </Field>
335
+ <Field id="complement" span={4} label={t('crud.archetype.addresses.field.complement')}>
336
+ <Input id="address-complement" value={form.complement} onChange={(e) => set('complement', e.target.value)} />
337
+ </Field>
338
+
339
+ <Field id="district" span={3} label={t('crud.archetype.addresses.field.district')}>
340
+ <Input id="address-district" value={form.district} onChange={(e) => set('district', e.target.value)} />
341
+ </Field>
342
+ <Field id="city" span={2} required label={t('crud.archetype.addresses.field.city')}>
343
+ <Input id="address-city" value={form.city} onChange={(e) => set('city', e.target.value)} />
344
+ </Field>
345
+ <Field id="state" span={1} label={t('crud.archetype.addresses.field.state')}>
346
+ <Input id="address-state" value={form.state} onChange={(e) => set('state', e.target.value)}
347
+ maxLength={2} placeholder="UF" />
348
+ </Field>
349
+
350
+ <Field id="recipient" span={3} label={t('crud.archetype.addresses.field.recipient')}>
351
+ <Input id="address-recipient" value={form.recipient} onChange={(e) => set('recipient', e.target.value)} />
352
+ </Field>
353
+ <Field id="phone" span={3} label={t('crud.archetype.addresses.field.phone')}>
354
+ <Input id="address-phone" value={form.phone} onChange={(e) => set('phone', e.target.value)} />
355
+ </Field>
356
+
357
+ <label className="flex items-center gap-2 text-sm sm:col-span-6">
358
+ <input type="checkbox" checked={form.is_default}
359
+ onChange={(e) => set('is_default', e.target.checked)} className="h-4 w-4" />
360
+ {t('crud.archetype.addresses.setDefault')}
361
+ </label>
362
+ </div>
363
+
364
+ <ModalFooter>
365
+ <Button variant="ghost" onClick={() => setFormOpen(false)} disabled={saving}>
366
+ {t('common.cancel')}
367
+ </Button>
368
+ <Button onClick={() => void handleSave()} disabled={!canSave || saving}>
369
+ {saving ? t('common.saving') : t('common.save')}
370
+ </Button>
371
+ </ModalFooter>
372
+ </ModalContent>
373
+ </Modal>
374
+
375
+ <ConfirmDialog
376
+ open={deleting !== null}
377
+ variant="destructive"
378
+ title={t('crud.archetype.addresses.deleteTitle')}
379
+ description={deleting ? formatLine(deleting) : ''}
380
+ confirmLabel={t('common.delete')}
381
+ cancelLabel={t('common.cancel')}
382
+ onConfirm={() => void handleDelete()}
383
+ onCancel={() => setDeleting(null)}
384
+ />
385
+ </div>
386
+ )
387
+ }