@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.
- package/dist/access/context.d.ts +47 -0
- package/dist/access/context.d.ts.map +1 -0
- package/dist/access/headless.d.ts +21 -0
- package/dist/access/headless.d.ts.map +1 -0
- package/dist/access/index.d.ts +12 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/limits-registry.d.ts +33 -0
- package/dist/access/limits-registry.d.ts.map +1 -0
- package/dist/access/module-nav.d.ts +15 -0
- package/dist/access/module-nav.d.ts.map +1 -0
- package/dist/access/resolver.d.ts +2 -0
- package/dist/access/resolver.d.ts.map +1 -0
- package/dist/access/types.d.ts +43 -0
- package/dist/access/types.d.ts.map +1 -0
- package/dist/access/upgrade-modal-store.d.ts +23 -0
- package/dist/access/upgrade-modal-store.d.ts.map +1 -0
- package/dist/app/AdminShell.d.ts +3 -0
- package/dist/app/AdminShell.d.ts.map +1 -1
- package/dist/app/admin-app.d.ts +3 -1
- package/dist/app/admin-app.d.ts.map +1 -1
- package/dist/app/config.d.ts +47 -2
- package/dist/app/config.d.ts.map +1 -1
- package/dist/app/core-entities.d.ts +25 -0
- package/dist/app/core-entities.d.ts.map +1 -0
- package/dist/app/derive-contract.d.ts +8 -0
- package/dist/app/derive-contract.d.ts.map +1 -0
- package/dist/app/scaffold.d.ts.map +1 -1
- package/dist/archetype-lookup.d.ts.map +1 -1
- package/dist/billing/index.cjs +6 -2
- package/dist/billing/index.d.ts +2 -0
- package/dist/billing/index.d.ts.map +1 -1
- package/dist/billing/index.js +1 -1
- package/dist/billing/plan-badge.d.ts +17 -0
- package/dist/billing/plan-badge.d.ts.map +1 -0
- package/dist/billing/store.d.ts +6 -1
- package/dist/billing/store.d.ts.map +1 -1
- package/dist/{chunk-3M25GRRU.js → chunk-I3DY47N5.js} +3 -4
- package/dist/chunk-I3DY47N5.js.map +1 -0
- package/dist/chunk-NNFGMUF2.cjs +39 -0
- package/dist/chunk-NNFGMUF2.cjs.map +1 -0
- package/dist/{chunk-4XTQ4V5L.cjs → chunk-SAZLW54Y.cjs} +16 -12
- package/dist/chunk-SAZLW54Y.cjs.map +1 -0
- package/dist/{chunk-IPWCHJPE.cjs → chunk-VKYZACG5.cjs} +5 -6
- package/dist/chunk-VKYZACG5.cjs.map +1 -0
- package/dist/chunk-VN5NTK26.js +36 -0
- package/dist/chunk-VN5NTK26.js.map +1 -0
- package/dist/{chunk-XNWCGJYH.js → chunk-VTEP2AQX.js} +15 -11
- package/dist/chunk-VTEP2AQX.js.map +1 -0
- package/dist/components/shared/ContactPicker.d.ts +92 -0
- package/dist/components/shared/ContactPicker.d.ts.map +1 -0
- package/dist/components/shared/PersonLink.d.ts +12 -1
- package/dist/components/shared/PersonLink.d.ts.map +1 -1
- package/dist/components/shared/create-person.d.ts +20 -0
- package/dist/components/shared/create-person.d.ts.map +1 -0
- package/dist/crud/CrudDetailPage.d.ts.map +1 -1
- package/dist/crud/CrudFormPage.d.ts.map +1 -1
- package/dist/crud/CrudListView.d.ts.map +1 -1
- package/dist/crud/CrudPage.d.ts.map +1 -1
- package/dist/crud/archetypes/AddressesTab.d.ts +21 -0
- package/dist/crud/archetypes/AddressesTab.d.ts.map +1 -0
- package/dist/crud/archetypes/PersonDetailTabs.d.ts +2 -1
- package/dist/crud/archetypes/PersonDetailTabs.d.ts.map +1 -1
- package/dist/crud/createCrudPage.d.ts.map +1 -1
- package/dist/crud/index.d.ts +1 -0
- package/dist/crud/index.d.ts.map +1 -1
- package/dist/index.cjs +4281 -716
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +17 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4067 -542
- package/dist/index.js.map +1 -1
- package/dist/org/index.cjs +11 -11
- package/dist/org/index.js +2 -2
- package/dist/org/store.d.ts.map +1 -1
- package/dist/permissions/context.d.ts +2 -0
- package/dist/permissions/context.d.ts.map +1 -1
- package/dist/permissions/index.cjs +7 -7
- package/dist/permissions/index.js +1 -1
- package/dist/shell/components/auth/ProtectedRoute.d.ts.map +1 -1
- package/dist/shell/components/billing/SoftLimitBanner.d.ts +5 -0
- package/dist/shell/components/billing/SoftLimitBanner.d.ts.map +1 -0
- package/dist/shell/components/billing/SubscriptionPage.d.ts +5 -0
- package/dist/shell/components/billing/SubscriptionPage.d.ts.map +1 -0
- package/dist/shell/components/billing/UpgradeModal.d.ts +3 -0
- package/dist/shell/components/billing/UpgradeModal.d.ts.map +1 -0
- package/dist/shell/components/billing/UpgradePrompt.d.ts +10 -0
- package/dist/shell/components/billing/UpgradePrompt.d.ts.map +1 -0
- package/dist/shell/components/billing/access-contract.d.ts +18 -0
- package/dist/shell/components/billing/access-contract.d.ts.map +1 -0
- package/dist/shell/components/billing/gates.d.ts +14 -0
- package/dist/shell/components/billing/gates.d.ts.map +1 -0
- package/dist/shell/components/billing/index.d.ts +9 -2
- package/dist/shell/components/billing/index.d.ts.map +1 -1
- package/dist/shell/components/chat/ChatFab.d.ts +3 -1
- package/dist/shell/components/chat/ChatFab.d.ts.map +1 -1
- package/dist/shell/components/chat/ChatPanel.d.ts +3 -1
- package/dist/shell/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/shell/components/chat/ConfirmActionCard.d.ts +13 -0
- package/dist/shell/components/chat/ConfirmActionCard.d.ts.map +1 -0
- package/dist/shell/components/chat/markdown.d.ts +5 -0
- package/dist/shell/components/chat/markdown.d.ts.map +1 -0
- package/dist/shell/components/organization/TeamTab.d.ts.map +1 -1
- package/dist/shell/components/organization/TenantOnboarding.d.ts.map +1 -1
- package/dist/shell/components/organization/index.d.ts +0 -1
- package/dist/shell/components/organization/index.d.ts.map +1 -1
- package/dist/shell/components/settings/ConnectedCompanySettings.d.ts.map +1 -1
- package/dist/shell/components/settings/ConnectedFieldRulesSettings.d.ts.map +1 -1
- package/dist/shell/components/settings/ConnectedSecuritySettings.d.ts.map +1 -1
- package/dist/shell/components/settings/LocationsCrudPage.d.ts +1 -1
- package/dist/shell/components/settings/LocationsCrudPage.d.ts.map +1 -1
- package/dist/shell/components/settings/SettingsPage.d.ts +23 -2
- package/dist/shell/components/settings/SettingsPage.d.ts.map +1 -1
- package/dist/shell/hooks/index.d.ts +3 -2
- package/dist/shell/hooks/index.d.ts.map +1 -1
- package/dist/shell/hooks/useAITools.d.ts +2 -0
- package/dist/shell/hooks/useAITools.d.ts.map +1 -1
- package/dist/shell/hooks/useChat.d.ts +22 -0
- package/dist/shell/hooks/useChat.d.ts.map +1 -1
- package/dist/shell/hooks/useSystemPermission.d.ts +3 -0
- package/dist/shell/hooks/useSystemPermission.d.ts.map +1 -0
- package/dist/shell/hooks/useTenantPluginSettings.d.ts +12 -0
- package/dist/shell/hooks/useTenantPluginSettings.d.ts.map +1 -0
- package/dist/shell/lib/ai-tool-handlers.d.ts +119 -0
- package/dist/shell/lib/ai-tool-handlers.d.ts.map +1 -0
- package/dist/shell/lib/core-ai-tools.d.ts +14 -0
- package/dist/shell/lib/core-ai-tools.d.ts.map +1 -1
- package/dist/shell/lib/create-client-orders-provider.d.ts.map +1 -1
- package/dist/shell/lib/fayz-agent.d.ts +23 -0
- package/dist/shell/lib/fayz-agent.d.ts.map +1 -0
- package/dist/shell/lib/i18n.d.ts.map +1 -1
- package/dist/shell/stores/chat.store.d.ts +69 -0
- package/dist/shell/stores/chat.store.d.ts.map +1 -1
- package/dist/shell/types/billing.d.ts +7 -0
- package/dist/shell/types/billing.d.ts.map +1 -1
- package/dist/shell/types/plugins.d.ts +2 -45
- package/dist/shell/types/plugins.d.ts.map +1 -1
- package/dist/stores/createCrudStore.d.ts.map +1 -1
- package/package.json +14 -11
- package/src/access/context.tsx +228 -0
- package/src/access/headless.ts +71 -0
- package/src/access/index.ts +27 -0
- package/src/access/limits-registry.ts +86 -0
- package/src/access/module-nav.test.ts +78 -0
- package/src/access/module-nav.ts +82 -0
- package/src/access/resolver.test.ts +143 -0
- package/src/access/resolver.ts +5 -0
- package/src/access/types.ts +47 -0
- package/src/access/upgrade-modal-store.ts +30 -0
- package/src/app/AdminShell.tsx +451 -68
- package/src/app/admin-app.tsx +46 -9
- package/src/app/config.ts +54 -1
- package/src/app/core-entities.ts +110 -0
- package/src/app/derive-contract.test.ts +155 -0
- package/src/app/derive-contract.ts +214 -0
- package/src/app/scaffold.tsx +4 -0
- package/src/archetype-lookup.ts +8 -2
- package/src/billing/index.ts +2 -0
- package/src/billing/plan-badge.ts +32 -0
- package/src/billing/store.ts +8 -1
- package/src/components/shared/ContactPicker.tsx +394 -0
- package/src/components/shared/PersonLink.tsx +11 -3
- package/src/components/shared/create-person.ts +74 -0
- package/src/crud/CrudDetailPage.tsx +5 -1
- package/src/crud/CrudFormPage.tsx +113 -6
- package/src/crud/CrudListView.tsx +16 -1
- package/src/crud/CrudPage.tsx +27 -5
- package/src/crud/DeleteConfirmDialog.tsx +1 -1
- package/src/crud/archetypes/AddressesTab.tsx +387 -0
- package/src/crud/archetypes/PersonDetailTabs.tsx +5 -0
- package/src/crud/createCrudPage.tsx +2 -0
- package/src/crud/index.ts +6 -0
- package/src/env.d.ts +7 -0
- package/src/index.ts +49 -1
- package/src/org/store.ts +9 -1
- package/src/permissions/context.tsx +29 -15
- package/src/shell/components/auth/ProtectedRoute.tsx +12 -2
- package/src/shell/components/billing/SoftLimitBanner.tsx +81 -0
- package/src/shell/components/billing/SubscriptionPage.tsx +372 -0
- package/src/shell/components/billing/UpgradeModal.tsx +102 -0
- package/src/shell/components/billing/UpgradePrompt.tsx +229 -0
- package/src/shell/components/billing/access-contract.ts +64 -0
- package/src/shell/components/billing/gates.tsx +62 -0
- package/src/shell/components/billing/index.ts +18 -2
- package/src/shell/components/chat/ChatFab.tsx +4 -2
- package/src/shell/components/chat/ChatPanel.tsx +174 -23
- package/src/shell/components/chat/ConfirmActionCard.tsx +57 -0
- package/src/shell/components/chat/markdown.tsx +102 -0
- package/src/shell/components/organization/TeamTab.tsx +21 -4
- package/src/shell/components/organization/TenantOnboarding.tsx +3 -1
- package/src/shell/components/organization/index.ts +0 -1
- package/src/shell/components/settings/ConnectedCompanySettings.tsx +4 -1
- package/src/shell/components/settings/ConnectedFieldRulesSettings.tsx +3 -1
- package/src/shell/components/settings/ConnectedSecuritySettings.tsx +3 -1
- package/src/shell/components/settings/LocationsCrudPage.ts +6 -1
- package/src/shell/components/settings/SettingsPage.tsx +63 -21
- package/src/shell/hooks/index.ts +3 -2
- package/src/shell/hooks/useAITools.ts +27 -23
- package/src/shell/hooks/useChat.ts +381 -16
- package/src/shell/hooks/useSystemPermission.ts +23 -0
- package/src/shell/hooks/useTenantPluginSettings.ts +127 -0
- package/src/shell/lib/ai-tool-handlers.ts +910 -0
- package/src/shell/lib/core-ai-tools.ts +234 -2
- package/src/shell/lib/create-client-orders-provider.ts +24 -2
- package/src/shell/lib/fayz-agent.ts +56 -0
- package/src/shell/lib/i18n.ts +71 -0
- package/src/shell/stores/chat.store.ts +118 -1
- package/src/shell/styles.css +30 -0
- package/src/shell/types/billing.ts +8 -0
- package/src/shell/types/plugins.ts +13 -50
- package/src/stores/createCrudStore.ts +9 -0
- package/dist/chunk-3M25GRRU.js.map +0 -1
- package/dist/chunk-4XTQ4V5L.cjs.map +0 -1
- package/dist/chunk-5GYQK5IW.js +0 -21
- package/dist/chunk-5GYQK5IW.js.map +0 -1
- package/dist/chunk-IPWCHJPE.cjs.map +0 -1
- package/dist/chunk-KYID2MR6.cjs +0 -23
- package/dist/chunk-KYID2MR6.cjs.map +0 -1
- package/dist/chunk-XNWCGJYH.js.map +0 -1
- package/dist/shell/components/billing/BillingPage.d.ts +0 -7
- package/dist/shell/components/billing/BillingPage.d.ts.map +0 -1
- package/dist/shell/components/billing/PaywallGate.d.ts +0 -11
- package/dist/shell/components/billing/PaywallGate.d.ts.map +0 -1
- package/dist/shell/components/organization/PermissionGate.d.ts +0 -11
- package/dist/shell/components/organization/PermissionGate.d.ts.map +0 -1
- package/dist/shell/hooks/useBilling.d.ts +0 -14
- package/dist/shell/hooks/useBilling.d.ts.map +0 -1
- package/dist/shell/hooks/usePermission.d.ts +0 -16
- package/dist/shell/hooks/usePermission.d.ts.map +0 -1
- package/src/shell/components/billing/BillingPage.tsx +0 -56
- package/src/shell/components/billing/PaywallGate.tsx +0 -80
- package/src/shell/components/organization/PermissionGate.tsx +0 -16
- package/src/shell/hooks/useBilling.ts +0 -151
- 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
|
-
{
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
{
|
|
142
|
+
{gatedNewButton}
|
|
128
143
|
{hasImportExport && (
|
|
129
144
|
<Dropdown>
|
|
130
145
|
<DropdownTrigger asChild>
|
package/src/crud/CrudPage.tsx
CHANGED
|
@@ -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
|
-
|
|
466
|
-
|
|
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', {
|
|
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
|
+
}
|