@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,394 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
|
2
|
+
import { X } from 'lucide-react'
|
|
3
|
+
import { SearchCombobox, cn, toast, type ComboboxOption } from '@fayz-ai/ui'
|
|
4
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
5
|
+
import { createArchetypeLookup, type EntityLookup } from '../../archetype-lookup'
|
|
6
|
+
import { createPerson } from './create-person'
|
|
7
|
+
import { PersonLink } from './PersonLink'
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// ContactPicker — "find the person, or create them" as ONE component.
|
|
11
|
+
//
|
|
12
|
+
// The agenda invented this flow inline (search a client → no match → quick-add
|
|
13
|
+
// with name/phone/email → keep going without leaving the modal). It's the same
|
|
14
|
+
// need anywhere a plugin starts something FOR someone: a booking, a
|
|
15
|
+
// conversation, an invoice. This is that flow, extracted, so the behaviour and
|
|
16
|
+
// the rows written stay identical across surfaces (see `createPerson`).
|
|
17
|
+
//
|
|
18
|
+
// Three states, one component:
|
|
19
|
+
// • empty → SearchCombobox over the person lookup, with "+ Create <text>"
|
|
20
|
+
// • selected → a compact chip (PersonLink + phone) with an optional clear
|
|
21
|
+
// • creating → inline name / phone / email form → `createPerson` → selected
|
|
22
|
+
//
|
|
23
|
+
// ONE behaviour, two skins (`variant`). What used to differ between hosts was
|
|
24
|
+
// the FLOW — the agenda hid its search behind a collapsed "+ Add client" and
|
|
25
|
+
// rendered its own quick-create, conversations rendered its own phone field —
|
|
26
|
+
// and that is what made the same feature read as two. Those are gone: search,
|
|
27
|
+
// chip, inline create, label and handle capture all live here now, and the host
|
|
28
|
+
// passes configuration, not layout. `variant` only picks how much chrome the
|
|
29
|
+
// input wears, so it can sit in a form-shaped modal or in a dense icon row
|
|
30
|
+
// without the surrounding layout looking broken. Always open in both: the
|
|
31
|
+
// contact is the primary input everywhere this is mounted.
|
|
32
|
+
//
|
|
33
|
+
// Uncontrolled-friendly: pass `value`/`onChange` and the parent owns the id.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export interface ContactPickerValue {
|
|
37
|
+
/** Person id — undefined while the user has only typed a name. */
|
|
38
|
+
id?: string
|
|
39
|
+
name: string
|
|
40
|
+
phone?: string
|
|
41
|
+
email?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ContactPickerProps {
|
|
45
|
+
value: ContactPickerValue | null
|
|
46
|
+
onChange: (value: ContactPickerValue | null) => void
|
|
47
|
+
/**
|
|
48
|
+
* `people.kind` for records created here (e.g. 'client', 'lead', 'contact').
|
|
49
|
+
* Also narrows the default lookup, so a vertical only searches its own people.
|
|
50
|
+
*/
|
|
51
|
+
kind?: string
|
|
52
|
+
/** Per-vertical extension table linked by `person_id` — skipped when absent from the pool. */
|
|
53
|
+
extensionTable?: string
|
|
54
|
+
/** Search source. Defaults to the `person` archetype lookup narrowed by `kind`. */
|
|
55
|
+
lookup?: EntityLookup
|
|
56
|
+
/**
|
|
57
|
+
* Allow committing a typed name with NO person record (`{ name }` and no id).
|
|
58
|
+
* Off by default: the point of the picker is to end up with a real contact.
|
|
59
|
+
* Conversations turns it on so an ad-hoc handle can still start a thread.
|
|
60
|
+
*/
|
|
61
|
+
allowFreeText?: boolean
|
|
62
|
+
/** Hide the clear button (agenda locks the client on a paid booking). */
|
|
63
|
+
clearable?: boolean
|
|
64
|
+
disabled?: boolean
|
|
65
|
+
autoFocus?: boolean
|
|
66
|
+
placeholder?: string
|
|
67
|
+
/**
|
|
68
|
+
* How much chrome the field carries. The BEHAVIOUR — search, chip, inline
|
|
69
|
+
* create, handle capture — is identical either way; only the input's skin
|
|
70
|
+
* changes, so it can sit in whichever layout grammar the host already uses.
|
|
71
|
+
*
|
|
72
|
+
* • 'field' (default) — label above + bordered input. For form-shaped modals
|
|
73
|
+
* (the conversations composer), where every control is a labelled field.
|
|
74
|
+
* • 'inline' — no label, borderless input that underlines on focus. For the
|
|
75
|
+
* appointment modal's icon rows, where a bordered box next to "Scheduled"
|
|
76
|
+
* and "Add notes" is the thing that looks out of place.
|
|
77
|
+
*/
|
|
78
|
+
variant?: 'field' | 'inline'
|
|
79
|
+
/** Field label, rendered BY the component so every host gets the same one. Ignored when `variant='inline'`. */
|
|
80
|
+
label?: string
|
|
81
|
+
/**
|
|
82
|
+
* Right-hand detail on the selected chip. Defaults to the person's phone; a
|
|
83
|
+
* host that cares about a different datum (the conversations composer shows
|
|
84
|
+
* the handle for the ACTIVE channel) passes its own.
|
|
85
|
+
*/
|
|
86
|
+
secondaryText?: string
|
|
87
|
+
/**
|
|
88
|
+
* Optional second datum captured alongside the contact — the address to reach
|
|
89
|
+
* them at in this context (a channel handle, a delivery phone…). Lives HERE,
|
|
90
|
+
* not in the host, because it is contact data and hosts were rendering their
|
|
91
|
+
* own field around the picker, which is exactly what made the same flow look
|
|
92
|
+
* like two different features.
|
|
93
|
+
*
|
|
94
|
+
* `derived` is what we already know from the picked person: when set it rides
|
|
95
|
+
* on the chip and no field is offered. Otherwise the picker shows a discreet
|
|
96
|
+
* "+ Add {label}" that expands into the input.
|
|
97
|
+
*/
|
|
98
|
+
handleField?: {
|
|
99
|
+
/** Human name of the datum, interpolated into "+ Add {label}". */
|
|
100
|
+
label: string
|
|
101
|
+
/** Known from the contact — suppresses the field entirely. */
|
|
102
|
+
derived?: string
|
|
103
|
+
value: string
|
|
104
|
+
onChange: (value: string) => void
|
|
105
|
+
/** Label above the input once open. Defaults to `label`. */
|
|
106
|
+
fieldLabel?: string
|
|
107
|
+
placeholder?: string
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Copy for the "+ Create …" row. Defaults to the shared "New contact"; a
|
|
111
|
+
* plugin whose vertical calls them something else (the agenda says "client")
|
|
112
|
+
* passes its own label so the wording doesn't regress into generic SDK copy.
|
|
113
|
+
*/
|
|
114
|
+
createLabel?: string
|
|
115
|
+
/**
|
|
116
|
+
* Fires when the inline create form opens/closes. The create form owns phone
|
|
117
|
+
* and email while it is open, so a host that ALSO renders a phone/handle field
|
|
118
|
+
* (the conversations composer does) hides its own to avoid asking twice.
|
|
119
|
+
*/
|
|
120
|
+
onCreatingChange?: (creating: boolean) => void
|
|
121
|
+
className?: string
|
|
122
|
+
/** Extra hint under the input while a name is typed but nothing is selected. */
|
|
123
|
+
hint?: React.ReactNode
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function ContactPicker({
|
|
127
|
+
value,
|
|
128
|
+
onChange,
|
|
129
|
+
kind,
|
|
130
|
+
extensionTable,
|
|
131
|
+
lookup,
|
|
132
|
+
allowFreeText = false,
|
|
133
|
+
clearable = true,
|
|
134
|
+
disabled,
|
|
135
|
+
autoFocus,
|
|
136
|
+
placeholder,
|
|
137
|
+
variant = 'field',
|
|
138
|
+
label,
|
|
139
|
+
secondaryText,
|
|
140
|
+
handleField,
|
|
141
|
+
createLabel,
|
|
142
|
+
onCreatingChange,
|
|
143
|
+
className,
|
|
144
|
+
hint,
|
|
145
|
+
}: ContactPickerProps) {
|
|
146
|
+
const t = useTranslation()
|
|
147
|
+
const resolvedLookup = useMemo(
|
|
148
|
+
() => lookup ?? createArchetypeLookup({ archetype: 'person', kind }),
|
|
149
|
+
[lookup, kind],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const [search, setSearch] = useState(value?.id ? value.name : (value?.name ?? ''))
|
|
153
|
+
const [options, setOptions] = useState<ComboboxOption[]>([])
|
|
154
|
+
const [searching, setSearching] = useState(false)
|
|
155
|
+
|
|
156
|
+
const [creating, setCreatingState] = useState(false)
|
|
157
|
+
const creatingChanged = useRef(onCreatingChange)
|
|
158
|
+
creatingChanged.current = onCreatingChange
|
|
159
|
+
const setCreating = (next: boolean) => {
|
|
160
|
+
setCreatingState(next)
|
|
161
|
+
creatingChanged.current?.(next)
|
|
162
|
+
}
|
|
163
|
+
const [handleOpen, setHandleOpen] = useState(false)
|
|
164
|
+
const [newName, setNewName] = useState('')
|
|
165
|
+
const [newPhone, setNewPhone] = useState('')
|
|
166
|
+
const [newEmail, setNewEmail] = useState('')
|
|
167
|
+
const [saving, setSaving] = useState(false)
|
|
168
|
+
|
|
169
|
+
// Keep the visible text in sync with the parent — but ONLY on an id
|
|
170
|
+
// transition. Parents build `value` as a fresh object literal every render, so
|
|
171
|
+
// reacting to the object itself would re-run each keystroke and wipe the text
|
|
172
|
+
// the user is typing (there is no id yet while they type).
|
|
173
|
+
const selectedId = value?.id
|
|
174
|
+
const selectedName = value?.id ? value.name : undefined
|
|
175
|
+
const lastSelectedId = useRef(selectedId)
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (selectedId === lastSelectedId.current) return
|
|
178
|
+
lastSelectedId.current = selectedId
|
|
179
|
+
if (selectedId) {
|
|
180
|
+
setSearch(selectedName ?? '')
|
|
181
|
+
setCreating(false)
|
|
182
|
+
} else {
|
|
183
|
+
// Parent dropped the selection (form reset, "change contact", …).
|
|
184
|
+
setSearch('')
|
|
185
|
+
}
|
|
186
|
+
}, [selectedId, selectedName])
|
|
187
|
+
|
|
188
|
+
// Debounced search — skipped once a person is selected.
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (value?.id || creating || search.trim().length < 1) {
|
|
191
|
+
setOptions([])
|
|
192
|
+
setSearching(false)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
setSearching(true)
|
|
196
|
+
const timer = setTimeout(async () => {
|
|
197
|
+
try {
|
|
198
|
+
const results = await resolvedLookup.search(search)
|
|
199
|
+
setOptions(results.map((r) => ({ id: r.id, label: r.label, subtitle: r.subtitle, data: r.data })))
|
|
200
|
+
} catch {
|
|
201
|
+
setOptions([])
|
|
202
|
+
}
|
|
203
|
+
setSearching(false)
|
|
204
|
+
}, 250)
|
|
205
|
+
return () => clearTimeout(timer)
|
|
206
|
+
}, [search, value?.id, creating, resolvedLookup])
|
|
207
|
+
|
|
208
|
+
// Free-text mode: a typed name with no match is still a usable value, so the
|
|
209
|
+
// parent sees every keystroke. Without it the text would be lost on submit.
|
|
210
|
+
function handleSearchChange(next: string) {
|
|
211
|
+
setSearch(next)
|
|
212
|
+
if (value?.id) onChange(null)
|
|
213
|
+
if (allowFreeText) onChange(next.trim() ? { name: next } : null)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function handleSelect(opt: ComboboxOption) {
|
|
217
|
+
const data = (opt.data ?? {}) as Record<string, unknown>
|
|
218
|
+
setSearch(opt.label)
|
|
219
|
+
setOptions([])
|
|
220
|
+
onChange({
|
|
221
|
+
id: opt.id,
|
|
222
|
+
name: opt.label,
|
|
223
|
+
phone: (data.phone as string) || undefined,
|
|
224
|
+
email: (data.email as string) || undefined,
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function startCreate(prefillName: string) {
|
|
229
|
+
setCreating(true)
|
|
230
|
+
setNewName(prefillName)
|
|
231
|
+
setNewPhone('')
|
|
232
|
+
setNewEmail('')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function handleCreate() {
|
|
236
|
+
if (!newName.trim() || saving) return
|
|
237
|
+
setSaving(true)
|
|
238
|
+
try {
|
|
239
|
+
const person = await createPerson({
|
|
240
|
+
name: newName,
|
|
241
|
+
phone: newPhone,
|
|
242
|
+
email: newEmail,
|
|
243
|
+
kind: kind ?? 'contact',
|
|
244
|
+
extensionTable,
|
|
245
|
+
})
|
|
246
|
+
setSearch(person.name)
|
|
247
|
+
setCreating(false)
|
|
248
|
+
onChange(person)
|
|
249
|
+
} catch (err) {
|
|
250
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
251
|
+
toast.error(t('contactPicker.createFailed'), { description: message })
|
|
252
|
+
}
|
|
253
|
+
setSaving(false)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function handleClear() {
|
|
257
|
+
setSearch('')
|
|
258
|
+
setOptions([])
|
|
259
|
+
onChange(null)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const inputClass =
|
|
263
|
+
'w-full rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring'
|
|
264
|
+
|
|
265
|
+
// Each state renders only its BODY; `frame` below wraps every one of them in
|
|
266
|
+
// the same label + handle chrome, so the picker looks identical wherever it is
|
|
267
|
+
// mounted no matter which state it is in.
|
|
268
|
+
const frame = (body: React.ReactNode) => (
|
|
269
|
+
<div className={cn('flex flex-col', className)}>
|
|
270
|
+
{label && variant === 'field' && (
|
|
271
|
+
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</label>
|
|
272
|
+
)}
|
|
273
|
+
{body}
|
|
274
|
+
{handleField && !creating && !handleField.derived && (
|
|
275
|
+
handleOpen || handleField.value ? (
|
|
276
|
+
<div className="mt-3">
|
|
277
|
+
<label htmlFor="contact-handle" className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
|
278
|
+
{handleField.fieldLabel ?? handleField.label}
|
|
279
|
+
</label>
|
|
280
|
+
<input
|
|
281
|
+
id="contact-handle"
|
|
282
|
+
type="text"
|
|
283
|
+
value={handleField.value}
|
|
284
|
+
onChange={(e) => handleField.onChange(e.target.value)}
|
|
285
|
+
placeholder={handleField.placeholder}
|
|
286
|
+
className={inputClass}
|
|
287
|
+
autoFocus={handleOpen}
|
|
288
|
+
/>
|
|
289
|
+
</div>
|
|
290
|
+
) : (
|
|
291
|
+
<button
|
|
292
|
+
type="button"
|
|
293
|
+
onClick={() => setHandleOpen(true)}
|
|
294
|
+
className="mt-2 self-start text-xs font-medium text-primary hover:underline"
|
|
295
|
+
>
|
|
296
|
+
+ {t('contactPicker.addField').replace('{label}', handleField.label)}
|
|
297
|
+
</button>
|
|
298
|
+
)
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
// ---- Selected -----------------------------------------------------------
|
|
304
|
+
if (value?.id && !creating) {
|
|
305
|
+
return frame(
|
|
306
|
+
<div className="flex items-center gap-2 rounded-md bg-muted/30 px-2.5 py-1.5 text-sm">
|
|
307
|
+
<PersonLink personId={value.id} name={value.name} size="default" className="flex-1 min-w-0" />
|
|
308
|
+
{(secondaryText ?? value.phone) && (
|
|
309
|
+
<span className="text-[10px] text-muted-foreground shrink-0">{secondaryText ?? value.phone}</span>
|
|
310
|
+
)}
|
|
311
|
+
{clearable && !disabled && (
|
|
312
|
+
<button
|
|
313
|
+
type="button"
|
|
314
|
+
onClick={handleClear}
|
|
315
|
+
aria-label={t('contactPicker.clear')}
|
|
316
|
+
className="p-0.5 text-muted-foreground hover:text-destructive shrink-0"
|
|
317
|
+
>
|
|
318
|
+
<X className="h-3 w-3" />
|
|
319
|
+
</button>
|
|
320
|
+
)}
|
|
321
|
+
</div>,
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---- Creating -----------------------------------------------------------
|
|
326
|
+
if (creating) {
|
|
327
|
+
return frame(
|
|
328
|
+
<div className="rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] p-2.5 space-y-2">
|
|
329
|
+
<div className="flex items-center gap-2">
|
|
330
|
+
<input
|
|
331
|
+
type="text"
|
|
332
|
+
value={newName}
|
|
333
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
334
|
+
placeholder={t('contactPicker.name')}
|
|
335
|
+
className={cn(inputClass, 'flex-1 font-medium')}
|
|
336
|
+
autoFocus
|
|
337
|
+
/>
|
|
338
|
+
<button
|
|
339
|
+
type="button"
|
|
340
|
+
onClick={() => setCreating(false)}
|
|
341
|
+
aria-label={t('contactPicker.cancel')}
|
|
342
|
+
className="p-1 text-muted-foreground hover:text-foreground shrink-0"
|
|
343
|
+
>
|
|
344
|
+
<X className="h-3.5 w-3.5" />
|
|
345
|
+
</button>
|
|
346
|
+
</div>
|
|
347
|
+
<div className="grid grid-cols-2 gap-2">
|
|
348
|
+
<input
|
|
349
|
+
type="tel"
|
|
350
|
+
value={newPhone}
|
|
351
|
+
onChange={(e) => setNewPhone(e.target.value)}
|
|
352
|
+
placeholder={t('contactPicker.phone')}
|
|
353
|
+
className={inputClass}
|
|
354
|
+
/>
|
|
355
|
+
<input
|
|
356
|
+
type="email"
|
|
357
|
+
value={newEmail}
|
|
358
|
+
onChange={(e) => setNewEmail(e.target.value)}
|
|
359
|
+
placeholder={t('contactPicker.emailOptional')}
|
|
360
|
+
className={inputClass}
|
|
361
|
+
/>
|
|
362
|
+
</div>
|
|
363
|
+
<button
|
|
364
|
+
type="button"
|
|
365
|
+
disabled={saving || !newName.trim()}
|
|
366
|
+
onClick={handleCreate}
|
|
367
|
+
className="w-full rounded-md bg-primary border border-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 shadow-button-primary active:shadow-button-inset disabled:opacity-40 transition-colors"
|
|
368
|
+
>
|
|
369
|
+
{saving ? t('contactPicker.creating') : t('contactPicker.create')}
|
|
370
|
+
</button>
|
|
371
|
+
</div>,
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---- Empty / searching --------------------------------------------------
|
|
376
|
+
return frame(
|
|
377
|
+
<div>
|
|
378
|
+
<SearchCombobox
|
|
379
|
+
value={search}
|
|
380
|
+
onChange={handleSearchChange}
|
|
381
|
+
onSelect={handleSelect}
|
|
382
|
+
options={options}
|
|
383
|
+
loading={searching}
|
|
384
|
+
placeholder={placeholder ?? t('contactPicker.search')}
|
|
385
|
+
allowCreate
|
|
386
|
+
createLabel={createLabel ?? t('contactPicker.newContact')}
|
|
387
|
+
onCreateNew={startCreate}
|
|
388
|
+
autoFocus={autoFocus}
|
|
389
|
+
minimal={variant === 'inline'}
|
|
390
|
+
/>
|
|
391
|
+
{hint && search.trim() && !searching && <div className="mt-1">{hint}</div>}
|
|
392
|
+
</div>,
|
|
393
|
+
)
|
|
394
|
+
}
|
|
@@ -12,6 +12,14 @@ const personLookup = createArchetypeLookup({ archetype: 'person' })
|
|
|
12
12
|
interface PersonLinkProps {
|
|
13
13
|
/** Person UUID — required for fetching details */
|
|
14
14
|
personId?: string | null
|
|
15
|
+
/**
|
|
16
|
+
* Where to load the details from. Defaults to the `person` archetype lookup
|
|
17
|
+
* (the `people` table). Modules whose "person" lives elsewhere pass their own
|
|
18
|
+
* loader — a shop customer, for instance, is a plg_shop_customers row and the
|
|
19
|
+
* default lookup would never find it. Without this the popover silently shows
|
|
20
|
+
* an empty card, which reads as a bug rather than as a wrong data source.
|
|
21
|
+
*/
|
|
22
|
+
lookup?: { getById: (id: string) => Promise<EntityLookupResult | null> }
|
|
15
23
|
/** Display name (shown as the clickable text) */
|
|
16
24
|
name: string
|
|
17
25
|
/** Optional href to navigate to the person's profile */
|
|
@@ -34,7 +42,7 @@ function formatAge(dob: string): string | null {
|
|
|
34
42
|
} catch { return null }
|
|
35
43
|
}
|
|
36
44
|
|
|
37
|
-
export function PersonLink({ personId, name, profileHref, tab, size = 'default', className }: PersonLinkProps) {
|
|
45
|
+
export function PersonLink({ personId, name, lookup, profileHref, tab, size = 'default', className }: PersonLinkProps) {
|
|
38
46
|
const [person, setPerson] = useState<EntityLookupResult | null>(null)
|
|
39
47
|
const [loading, setLoading] = useState(false)
|
|
40
48
|
const [fetched, setFetched] = useState(false)
|
|
@@ -44,7 +52,7 @@ export function PersonLink({ personId, name, profileHref, tab, size = 'default',
|
|
|
44
52
|
const fetchPerson = useCallback(() => {
|
|
45
53
|
if (!fetched && personId) {
|
|
46
54
|
setLoading(true)
|
|
47
|
-
personLookup.getById(personId).then((result) => {
|
|
55
|
+
;(lookup ?? personLookup).getById(personId).then((result) => {
|
|
48
56
|
setPerson(result)
|
|
49
57
|
setFetched(true)
|
|
50
58
|
setLoading(false)
|
|
@@ -53,7 +61,7 @@ export function PersonLink({ personId, name, profileHref, tab, size = 'default',
|
|
|
53
61
|
setLoading(false)
|
|
54
62
|
})
|
|
55
63
|
}
|
|
56
|
-
}, [personId, fetched])
|
|
64
|
+
}, [personId, fetched, lookup])
|
|
57
65
|
|
|
58
66
|
const handleOpenChange = useCallback((v: boolean) => {
|
|
59
67
|
setOpen(v)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { getSupabaseClientOptional } from '@fayz-ai/core'
|
|
2
|
+
import { useOrganizationStore } from '../../org/store'
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// createPerson — the ONE way a plugin turns "a name someone typed" into a real
|
|
6
|
+
// person record. Extracted from the agenda's inline quick-create so every
|
|
7
|
+
// surface that needs "find the contact, or create it" writes the SAME rows:
|
|
8
|
+
// `public.people` (the archetype base) plus, when the pool ships it, a
|
|
9
|
+
// per-vertical extension table (`clients`, `leads`, …).
|
|
10
|
+
//
|
|
11
|
+
// Pool tolerance is the whole reason this is shared: beauty-style pools have
|
|
12
|
+
// `public.clients`, school/agency pools live off `public.people` alone. A
|
|
13
|
+
// MISSING extension table is not an error (PostgREST reports PGRST205 /
|
|
14
|
+
// "Could not find the table"); any other failure rolls the person back so a
|
|
15
|
+
// half-written contact never survives.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export interface CreatePersonInput {
|
|
19
|
+
name: string
|
|
20
|
+
phone?: string
|
|
21
|
+
email?: string
|
|
22
|
+
/** `people.kind` discriminator for this vertical (e.g. 'client', 'lead'). */
|
|
23
|
+
kind: string
|
|
24
|
+
/**
|
|
25
|
+
* Optional per-vertical extension table linked by `person_id`. Absent in the
|
|
26
|
+
* pool → silently skipped (see above).
|
|
27
|
+
*/
|
|
28
|
+
extensionTable?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CreatedPerson {
|
|
32
|
+
id: string
|
|
33
|
+
name: string
|
|
34
|
+
phone?: string
|
|
35
|
+
email?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function createPerson(input: CreatePersonInput): Promise<CreatedPerson> {
|
|
39
|
+
const supabase = getSupabaseClientOptional() as any
|
|
40
|
+
const tenantId = useOrganizationStore.getState().currentOrg?.id
|
|
41
|
+
if (!supabase) throw new Error('[createPerson] Supabase client not registered.')
|
|
42
|
+
if (!tenantId) throw new Error('[createPerson] Active tenant not resolved — try again in a moment.')
|
|
43
|
+
|
|
44
|
+
const name = input.name.trim()
|
|
45
|
+
const phone = input.phone?.trim() || null
|
|
46
|
+
const email = input.email?.trim() || null
|
|
47
|
+
|
|
48
|
+
const { data: personRow, error: personError } = await supabase
|
|
49
|
+
.from('people')
|
|
50
|
+
.insert({ tenant_id: tenantId, kind: input.kind, name, phone, email })
|
|
51
|
+
.select('id, name, phone, email')
|
|
52
|
+
.single()
|
|
53
|
+
if (personError) throw personError
|
|
54
|
+
|
|
55
|
+
if (input.extensionTable) {
|
|
56
|
+
const { error: extError } = await supabase
|
|
57
|
+
.from(input.extensionTable)
|
|
58
|
+
.insert({ person_id: personRow.id, tenant_id: tenantId })
|
|
59
|
+
const missingExtensionTable =
|
|
60
|
+
extError?.code === 'PGRST205' || extError?.message?.includes('Could not find the table')
|
|
61
|
+
if (extError && !missingExtensionTable) {
|
|
62
|
+
// Roll the person back so the failure leaves nothing behind.
|
|
63
|
+
await supabase.from('people').delete().eq('id', personRow.id)
|
|
64
|
+
throw extError
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
id: String(personRow.id),
|
|
70
|
+
name: String(personRow.name ?? name),
|
|
71
|
+
phone: personRow.phone ?? undefined,
|
|
72
|
+
email: personRow.email ?? undefined,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -7,6 +7,7 @@ import { Badge } from '@fayz-ai/ui'
|
|
|
7
7
|
import { Breadcrumb } from '@fayz-ai/ui'
|
|
8
8
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@fayz-ai/ui'
|
|
9
9
|
import { Separator } from '@fayz-ai/ui'
|
|
10
|
+
import { Markdown } from '@fayz-ai/ui'
|
|
10
11
|
import { WidgetSlot } from '../plugins/WidgetSlot'
|
|
11
12
|
import { formatCurrency } from '@fayz-ai/core'
|
|
12
13
|
import { PERSON_DETAIL_TABS } from './archetypes/PersonDetailTabs'
|
|
@@ -76,6 +77,8 @@ function formatValue(field: FieldDef, value: any, t?: (key: string) => string):
|
|
|
76
77
|
return <a href={value} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">{value}</a>
|
|
77
78
|
case 'textarea':
|
|
78
79
|
return <p className="whitespace-pre-wrap">{value}</p>
|
|
80
|
+
case 'markdown':
|
|
81
|
+
return value ? <Markdown source={String(value)} /> : null
|
|
79
82
|
default:
|
|
80
83
|
return String(value)
|
|
81
84
|
}
|
|
@@ -270,7 +273,11 @@ export function CrudDetailPage({
|
|
|
270
273
|
const displayField = entityDef.displayField ?? entityDef.fields[0]?.key ?? 'id'
|
|
271
274
|
const displayValue = item[displayField] ?? 'Untitled'
|
|
272
275
|
const subtitleValue = entityDef.subtitleField ? item[entityDef.subtitleField] : null
|
|
273
|
-
|
|
276
|
+
// `imageField` only reaches a plain string column. Entities whose image lives
|
|
277
|
+
// somewhere richer (a product's images[] relation, say) supply `image.get`,
|
|
278
|
+
// and without consulting it the hero fell back to the initial letter even for
|
|
279
|
+
// records that clearly have a photo.
|
|
280
|
+
const imageValue = entityDef.image?.get(item) ?? (entityDef.imageField ? item[entityDef.imageField] : null)
|
|
274
281
|
const initial = typeof displayValue === 'string' ? displayValue.charAt(0).toUpperCase() : '?'
|
|
275
282
|
|
|
276
283
|
// Merge archetype tabs + custom entity tabs. Entity-specific tabs win by id
|