@fayz-ai/saas 0.8.0 → 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 +4300 -722
- 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 +4080 -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/crud.d.ts +1 -1
- package/dist/shell/types/crud.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 +15 -12
- 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 +8 -1
- package/src/crud/CrudFormPage.tsx +124 -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/crud.ts +1 -1
- 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
|
@@ -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
|
+
}
|
|
@@ -3,6 +3,7 @@ import { Clock, FileText, CalendarDays } from 'lucide-react'
|
|
|
3
3
|
import { Card, CardContent } from '@fayz-ai/ui'
|
|
4
4
|
import { Badge } from '@fayz-ai/ui'
|
|
5
5
|
import { ScheduleEditor } from './ScheduleEditor'
|
|
6
|
+
import { AddressesTab } from './AddressesTab'
|
|
6
7
|
import { useTranslation } from '@fayz-ai/core'
|
|
7
8
|
import { usePluginRuntimeOptional as usePluginsOptional } from '@fayz-ai/core'
|
|
8
9
|
import { getWidgetsForZone } from '@fayz-ai/core'
|
|
@@ -101,6 +102,10 @@ export function ScheduleTab({ item }: { item: any }) {
|
|
|
101
102
|
|
|
102
103
|
export const PERSON_DETAIL_TABS = [
|
|
103
104
|
{ id: 'schedule', label: 'Schedule', icon: 'CalendarDays', component: ScheduleTab, visibleFor: ['staff'] },
|
|
105
|
+
// Core table (public.addresses), so every app with a person record gets this:
|
|
106
|
+
// delivery addresses for a client, pickup point for a supplier, home address
|
|
107
|
+
// for staff. Not shop-specific.
|
|
108
|
+
{ id: 'addresses', label: 'Endereços', icon: 'MapPin', component: AddressesTab },
|
|
104
109
|
{ id: 'documents', label: 'Documents', icon: 'FileText', component: DocumentsTab },
|
|
105
110
|
// Per-person financial statement — only shown when the financial plugin contributes
|
|
106
111
|
// its widget (see requiresWidgetZone). Clients = paid invoices; staff = commissions.
|
|
@@ -59,6 +59,8 @@ export function createCrudPage<T extends { id: string }>(
|
|
|
59
59
|
fields: entityDef.fields,
|
|
60
60
|
source: 'app',
|
|
61
61
|
archetype: entityDef.data?.archetype,
|
|
62
|
+
entityDef: entityDef as EntityDef,
|
|
63
|
+
mockData: options?.mockData as Array<{ id: string }> | undefined,
|
|
62
64
|
})
|
|
63
65
|
|
|
64
66
|
return GeneratedCrudPage
|
package/src/crud/index.ts
CHANGED
|
@@ -10,3 +10,9 @@ export { ImportWizard } from './ImportWizard'
|
|
|
10
10
|
export type { ImportRowError } from './ImportWizard'
|
|
11
11
|
export { exportToCSV } from './csv-export'
|
|
12
12
|
export { RelationSelect, RelationCell, useRelationOptions, loadRelationOptions, invalidateRelationOptions } from './relation-field'
|
|
13
|
+
// Link to another entity's detail page — used by detail tabs and by plugins
|
|
14
|
+
// that reference a record from a different module (e.g. the order list linking
|
|
15
|
+
// its customer).
|
|
16
|
+
// PersonLink is intentionally NOT re-exported: plugin-reports already exports
|
|
17
|
+
// a different component under that name and the collision breaks its build.
|
|
18
|
+
export { EntityLink, registerEntityPath, getPersonPath } from './archetypes/EntityLink'
|
package/src/env.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ interface ImportMetaEnv {
|
|
|
8
8
|
/** Deployment-fixed canonical origin (e.g. https://beauty-saas.live.fayz.ai),
|
|
9
9
|
* injected by the container. Used to build invite/magic-link redirects. */
|
|
10
10
|
readonly VITE_APP_URL?: string
|
|
11
|
+
/** Fayz project this app belongs to, injected by the container. */
|
|
12
|
+
readonly VITE_FAYZ_PROJECT_ID?: string
|
|
13
|
+
/** Fayz API base (e.g. https://fayz.ai/api), injected by the container. */
|
|
14
|
+
readonly VITE_FAYZ_API_BASE_URL?: string
|
|
15
|
+
/** Publishable key for the project's web agent channel. Present only once an
|
|
16
|
+
* agent is enabled in Fayz — its absence is what leaves the assistant off. */
|
|
17
|
+
readonly VITE_FAYZ_AGENT_KEY?: string
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
interface ImportMeta {
|
package/src/index.ts
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
// App factory (main entry point)
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
export { createFayzApp, AdminProviders } from './app/admin-app'
|
|
5
|
-
export type { FayzAppConfig, AuthConfig, OrgConfig, ChatConfig, CustomPage, PageSection } from './app/config'
|
|
5
|
+
export type { FayzAppConfig, AuthConfig, OrgConfig, ChatConfig, FayzBillingConfig, CustomPage, PageSection } from './app/config'
|
|
6
6
|
|
|
7
7
|
// Native admin scaffold — manifest-first entry. Importing this registers the
|
|
8
8
|
// 'admin' scaffold so renderApp(manifest) can resolve it.
|
|
9
9
|
export { defineSaas, AdminScaffold } from './app/scaffold'
|
|
10
|
+
export { deriveAgentContract } from './app/derive-contract'
|
|
11
|
+
export type { DerivedAgentSections } from './app/derive-contract'
|
|
10
12
|
export { AdminShell } from './app/AdminShell'
|
|
11
13
|
export { LoginPage } from './app/LoginPage'
|
|
12
14
|
export { navigateTo as adminNavigateTo, useAdminPath } from './app/routing'
|
|
@@ -40,8 +42,10 @@ export { WidgetSlot } from './plugins/WidgetSlot'
|
|
|
40
42
|
export { useFieldRules } from './hooks/useFieldRules'
|
|
41
43
|
|
|
42
44
|
export { createCrudPage } from './crud/createCrudPage'
|
|
45
|
+
export { EntityLink, registerEntityPath, getPersonPath } from './crud/archetypes/EntityLink'
|
|
43
46
|
// Client-orders archetype (used by beauty-saas client detail tab)
|
|
44
47
|
export { ClientOrdersTab } from './shell/components/crud/archetypes/ClientOrdersTab'
|
|
48
|
+
export type { ClientOrdersProvider, ClientOrdersQuery, ClientOrdersNavigator, ClientDocument, ClientDocumentStage, ClientOrdersStageFilter } from './shell/types/client-orders'
|
|
45
49
|
export { createClientOrdersProvider } from './shell/lib/create-client-orders-provider'
|
|
46
50
|
|
|
47
51
|
// ---------------------------------------------------------------------------
|
|
@@ -90,15 +94,53 @@ export {
|
|
|
90
94
|
usePermissionsStore,
|
|
91
95
|
PermissionsProvider,
|
|
92
96
|
usePermission,
|
|
97
|
+
usePermissionOptional,
|
|
93
98
|
useHasPermission,
|
|
94
99
|
usePermissions,
|
|
95
100
|
} from './permissions/index'
|
|
96
101
|
export type { PermissionsStore, PermissionsProviderProps } from './permissions/index'
|
|
97
102
|
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Access engine — role × plan decision, quantity limits, upgrade modal store
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
export {
|
|
107
|
+
AccessProvider,
|
|
108
|
+
useAccess,
|
|
109
|
+
useAccessOptional,
|
|
110
|
+
useLimit,
|
|
111
|
+
useLimitGuard,
|
|
112
|
+
useModuleNavAccess,
|
|
113
|
+
resolveAccess,
|
|
114
|
+
isEntitledByPlan,
|
|
115
|
+
invalidateLimit,
|
|
116
|
+
useUpgradeModalStore,
|
|
117
|
+
getAccessSnapshot,
|
|
118
|
+
checkAccess,
|
|
119
|
+
guardLimit,
|
|
120
|
+
agentDenial,
|
|
121
|
+
UPGRADE_URL,
|
|
122
|
+
} from './access/index'
|
|
123
|
+
export type {
|
|
124
|
+
AccessProviderProps,
|
|
125
|
+
AccessApi,
|
|
126
|
+
AccessDecision,
|
|
127
|
+
AccessSession,
|
|
128
|
+
DenyReason,
|
|
129
|
+
LimitState,
|
|
130
|
+
UpgradeModalPayload,
|
|
131
|
+
UpgradeModalStore,
|
|
132
|
+
AgentDenial,
|
|
133
|
+
AgentDenialLimit,
|
|
134
|
+
AgentGuardResult,
|
|
135
|
+
} from './access/index'
|
|
136
|
+
|
|
98
137
|
// ---------------------------------------------------------------------------
|
|
99
138
|
// Billing
|
|
100
139
|
// ---------------------------------------------------------------------------
|
|
101
140
|
export { useBillingStore } from './billing/index'
|
|
141
|
+
export { EntitlementGate, LimitGate } from './shell/components/billing/gates'
|
|
142
|
+
export { UpgradePrompt } from './shell/components/billing/UpgradePrompt'
|
|
143
|
+
export type { UpgradePromptProps } from './shell/components/billing/UpgradePrompt'
|
|
102
144
|
export type { BillingStore, BillingState, Subscription, Invoice } from './billing/index'
|
|
103
145
|
|
|
104
146
|
// ---------------------------------------------------------------------------
|
|
@@ -168,8 +210,14 @@ export { formatCurrency, type CurrencyConfig } from './lib/currency'
|
|
|
168
210
|
export { PluginRegistryManager } from './plugins/PluginRegistryManager'
|
|
169
211
|
export { useModuleNavigation } from './hooks/useModuleNavigation'
|
|
170
212
|
export { PersonLink } from './components/shared/PersonLink'
|
|
213
|
+
export { ContactPicker } from './components/shared/ContactPicker'
|
|
214
|
+
export type { ContactPickerProps, ContactPickerValue } from './components/shared/ContactPicker'
|
|
215
|
+
export { createPerson } from './components/shared/create-person'
|
|
216
|
+
export type { CreatePersonInput, CreatedPerson } from './components/shared/create-person'
|
|
171
217
|
export { resolveEntityHref } from './lib/entity-routes'
|
|
172
218
|
export { usePluginPrefs } from './hooks/usePluginPrefs'
|
|
219
|
+
export { useTenantPluginSettings } from './shell/hooks/useTenantPluginSettings'
|
|
220
|
+
export type { TenantPluginSettings } from './shell/hooks/useTenantPluginSettings'
|
|
173
221
|
export { setScheduleBlockConfig, getScheduleBlockConfig, subscribeScheduleBlockConfig } from './lib/schedule-config'
|
|
174
222
|
|
|
175
223
|
// Assistant chat store — lets apps drive the shell's chat panel imperatively
|
package/src/org/store.ts
CHANGED
|
@@ -62,7 +62,15 @@ export const useOrganizationStore = create<OrgStore>((set) => ({
|
|
|
62
62
|
setLoading: (loading) => set({ loading }),
|
|
63
63
|
reset: () => {
|
|
64
64
|
setActiveTenantId(undefined)
|
|
65
|
-
|
|
65
|
+
// NOTE: do NOT clear the persisted org id here. The org providers call
|
|
66
|
+
// reset() whenever the auth store transiently reports `user === null` —
|
|
67
|
+
// which happens on every fresh load while the Supabase session hydrates
|
|
68
|
+
// asynchronously. Wiping 'fayz:current-org' at that moment drops a
|
|
69
|
+
// multi-org user's workspace selection BEFORE loadOrgs can read it back,
|
|
70
|
+
// so the app always falls to memberships[0] on reload (single-org users
|
|
71
|
+
// never noticed). The pin is a last-selected hint that's re-validated
|
|
72
|
+
// against the fresh membership list on load, so it's safe to keep across
|
|
73
|
+
// sign-out too (a mismatching pin just falls back to memberships[0]).
|
|
66
74
|
set({ currentOrg: null, userOrgs: [], members: [], loading: false })
|
|
67
75
|
},
|
|
68
76
|
}))
|
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
import type { FeatureDeclaration, PermissionsConfig, PermissionProfile } from '@fayz-ai/core'
|
|
3
|
+
import { usePluginRuntimeOptional } from '@fayz-ai/core'
|
|
3
4
|
import { usePermissionsStore } from './store'
|
|
4
5
|
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Permission check helpers
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
): boolean {
|
|
14
|
-
if (!profile) return true
|
|
10
|
+
// The role-side check moved to @fayz-ai/core/access (single implementation for
|
|
11
|
+
// browser, headless and the Fayz broker). Re-exported to keep call-sites stable.
|
|
12
|
+
import { profileHasPermission } from '@fayz-ai/core/access'
|
|
13
|
+
export { profileHasPermission }
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Merge plugin-declared features with the app's own, deduped by id. The app's
|
|
17
|
+
* declaration wins on collision (it can override a plugin's label/actions).
|
|
18
|
+
*/
|
|
19
|
+
function mergeFeatures(
|
|
20
|
+
pluginFeatures: FeatureDeclaration[] | undefined,
|
|
21
|
+
appFeatures: FeatureDeclaration[] | undefined,
|
|
22
|
+
): FeatureDeclaration[] {
|
|
23
|
+
const byId = new Map<string, FeatureDeclaration>()
|
|
24
|
+
for (const f of pluginFeatures ?? []) byId.set(f.id, f)
|
|
25
|
+
for (const f of appFeatures ?? []) byId.set(f.id, f) // app override wins
|
|
26
|
+
return Array.from(byId.values())
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
// ---------------------------------------------------------------------------
|
|
@@ -47,11 +52,20 @@ export function PermissionsProvider({ config, children }: PermissionsProviderPro
|
|
|
47
52
|
const { setFeatures, startImpersonation, stopImpersonation, currentProfile, isImpersonating } =
|
|
48
53
|
usePermissionsStore()
|
|
49
54
|
|
|
55
|
+
// Plugins declare their own permission features via `declaredFeatures`, which the
|
|
56
|
+
// plugin runtime aggregates into `pluginFeatures`. Merging them here is what makes
|
|
57
|
+
// a plugin "born compatible" — its features show up in the RBAC matrix and gate
|
|
58
|
+
// checks without the app re-declaring them. The app's own `config.features` wins
|
|
59
|
+
// on id collisions (app override), so an app can refine a plugin's declaration.
|
|
60
|
+
const runtime = usePluginRuntimeOptional()
|
|
61
|
+
const pluginFeatures = runtime?.pluginFeatures
|
|
62
|
+
|
|
50
63
|
React.useEffect(() => {
|
|
51
|
-
|
|
52
|
-
|
|
64
|
+
const merged = mergeFeatures(pluginFeatures, config?.features)
|
|
65
|
+
if (merged.length > 0) {
|
|
66
|
+
setFeatures(merged)
|
|
53
67
|
}
|
|
54
|
-
}, [config, setFeatures])
|
|
68
|
+
}, [config, pluginFeatures, setFeatures])
|
|
55
69
|
|
|
56
70
|
function hasPermission(feature: string, action?: string): boolean {
|
|
57
71
|
return profileHasPermission(currentProfile, feature, action)
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { useAuth } from '../../hooks/useAuth'
|
|
3
|
-
import {
|
|
3
|
+
import { useTenantStore } from '../../stores/tenant.store'
|
|
4
4
|
import type { MemberRole } from '../../types'
|
|
5
5
|
|
|
6
|
+
const ROLE_HIERARCHY: Record<MemberRole, number> = {
|
|
7
|
+
owner: 5,
|
|
8
|
+
admin: 4,
|
|
9
|
+
manager: 3,
|
|
10
|
+
staff: 2,
|
|
11
|
+
viewer: 1,
|
|
12
|
+
}
|
|
13
|
+
|
|
6
14
|
interface ProtectedRouteProps {
|
|
7
15
|
children?: React.ReactNode
|
|
8
16
|
requiredRole?: MemberRole
|
|
@@ -16,7 +24,9 @@ function LoadingSkeleton() {
|
|
|
16
24
|
|
|
17
25
|
export function ProtectedRoute({ children, requiredRole, fallback, onUnauthenticated }: ProtectedRouteProps) {
|
|
18
26
|
const { user, loading, initialized } = useAuth()
|
|
19
|
-
const
|
|
27
|
+
const currentRole = useTenantStore((s) => s.currentRole)
|
|
28
|
+
const requireRole = (role: MemberRole): boolean =>
|
|
29
|
+
currentRole ? ROLE_HIERARCHY[currentRole] >= ROLE_HIERARCHY[role] : false
|
|
20
30
|
|
|
21
31
|
React.useEffect(() => {
|
|
22
32
|
if (initialized && !loading && !user && onUnauthenticated) {
|