mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
fix(settings): add Custom Fields tab to Settings (#238)
Custom-field definitions are account-wide config — a sibling of Tags, Templates and Deals, all of which live under Settings. But the field catalogue could only be managed via an admin-only button on the Contacts page, so users looking under Settings found nothing (#238). Surface it as a "Custom Fields" tab in Settings: - Extract the manager's body into a reusable `CustomFieldsPanel`; `CustomFieldsManager` keeps its dialog signature and now wraps the panel, so the Contacts page is unchanged. Radix unmounts dialog content on close, so the panel still refetches on each open. - New `CustomFieldsSettings` renders the panel inline with a settings-style header. - Tab is admin+ gated (mirrors the Contacts gate); deep-linking to it as a non-admin falls back to Profile. `custom_fields` RLS still enforces writes server-side. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
91e6375e61
commit
599b21eed0
@ -9,8 +9,10 @@ import {
|
|||||||
Palette,
|
Palette,
|
||||||
UsersRound,
|
UsersRound,
|
||||||
Coins,
|
Coins,
|
||||||
|
SlidersHorizontal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { useCan } from '@/hooks/use-can';
|
||||||
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
|
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
|
||||||
import { TemplateManager } from '@/components/settings/template-manager';
|
import { TemplateManager } from '@/components/settings/template-manager';
|
||||||
import { TagManager } from '@/components/settings/tag-manager';
|
import { TagManager } from '@/components/settings/tag-manager';
|
||||||
@ -20,12 +22,14 @@ import { SessionsCard } from '@/components/settings/sessions-card';
|
|||||||
import { AppearancePanel } from '@/components/settings/appearance-panel';
|
import { AppearancePanel } from '@/components/settings/appearance-panel';
|
||||||
import { MembersTab } from '@/components/settings/members-tab';
|
import { MembersTab } from '@/components/settings/members-tab';
|
||||||
import { DealsSettings } from '@/components/settings/deals-settings';
|
import { DealsSettings } from '@/components/settings/deals-settings';
|
||||||
|
import { CustomFieldsSettings } from '@/components/settings/custom-fields-settings';
|
||||||
|
|
||||||
const TAB_VALUES = [
|
const TAB_VALUES = [
|
||||||
'profile',
|
'profile',
|
||||||
'whatsapp',
|
'whatsapp',
|
||||||
'templates',
|
'templates',
|
||||||
'tags',
|
'tags',
|
||||||
|
'custom-fields',
|
||||||
'deals',
|
'deals',
|
||||||
'appearance',
|
'appearance',
|
||||||
'members',
|
'members',
|
||||||
@ -40,12 +44,21 @@ export default function SettingsPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// Custom-field definitions are account-wide config, so editing them is
|
||||||
|
// admin+ only — mirror the gate on the Contacts page. The `custom_fields`
|
||||||
|
// RLS rejects non-admin writes regardless.
|
||||||
|
const canEditSettings = useCan('edit-settings');
|
||||||
|
|
||||||
// The URL is the single source of truth for the active tab — no
|
// The URL is the single source of truth for the active tab — no
|
||||||
// local state, no sync effect. A previous revision duplicated this
|
// local state, no sync effect. A previous revision duplicated this
|
||||||
// into `useState` + a sync effect, which tripped React 19's
|
// into `useState` + a sync effect, which tripped React 19's
|
||||||
// set-state-in-effect rule and was also redundant.
|
// set-state-in-effect rule and was also redundant.
|
||||||
const queryTab = searchParams.get('tab');
|
const queryTab = searchParams.get('tab');
|
||||||
const tab: TabValue = isTabValue(queryTab) ? queryTab : 'profile';
|
// Deep-linking to the admin-only tab as a non-admin falls back to profile
|
||||||
|
// rather than landing on a tab with no trigger or content.
|
||||||
|
const resolved: TabValue = isTabValue(queryTab) ? queryTab : 'profile';
|
||||||
|
const tab: TabValue =
|
||||||
|
resolved === 'custom-fields' && !canEditSettings ? 'profile' : resolved;
|
||||||
|
|
||||||
const onChange = (next: TabValue) => {
|
const onChange = (next: TabValue) => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
@ -57,59 +70,68 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
||||||
<p className="text-sm text-slate-400 mt-1">
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
Manage your profile, WhatsApp® integration, message templates, and
|
Manage your profile, WhatsApp® integration, message templates, and
|
||||||
tags.
|
tags.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs value={tab} onValueChange={(v) => onChange(v as TabValue)}>
|
<Tabs value={tab} onValueChange={(v) => onChange(v as TabValue)}>
|
||||||
<TabsList className="bg-slate-900 border border-slate-700">
|
<TabsList className="border border-slate-700 bg-slate-900">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="profile"
|
value="profile"
|
||||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
|
||||||
>
|
>
|
||||||
<User className="size-4" />
|
<User className="size-4" />
|
||||||
Profile
|
Profile
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="whatsapp"
|
value="whatsapp"
|
||||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
|
||||||
>
|
>
|
||||||
<Settings className="size-4" />
|
<Settings className="size-4" />
|
||||||
WhatsApp Config
|
WhatsApp Config
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="templates"
|
value="templates"
|
||||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
|
||||||
>
|
>
|
||||||
<MessageSquare className="size-4" />
|
<MessageSquare className="size-4" />
|
||||||
Templates
|
Templates
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="tags"
|
value="tags"
|
||||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
|
||||||
>
|
>
|
||||||
<Tag className="size-4" />
|
<Tag className="size-4" />
|
||||||
Tags
|
Tags
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
{canEditSettings && (
|
||||||
|
<TabsTrigger
|
||||||
|
value="custom-fields"
|
||||||
|
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="size-4" />
|
||||||
|
Custom Fields
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="deals"
|
value="deals"
|
||||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
|
||||||
>
|
>
|
||||||
<Coins className="size-4" />
|
<Coins className="size-4" />
|
||||||
Deals
|
Deals
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="appearance"
|
value="appearance"
|
||||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
|
||||||
>
|
>
|
||||||
<Palette className="size-4" />
|
<Palette className="size-4" />
|
||||||
Appearance
|
Appearance
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="members"
|
value="members"
|
||||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
|
||||||
>
|
>
|
||||||
<UsersRound className="size-4" />
|
<UsersRound className="size-4" />
|
||||||
Members
|
Members
|
||||||
@ -134,6 +156,12 @@ export default function SettingsPage() {
|
|||||||
<TagManager />
|
<TagManager />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{canEditSettings && (
|
||||||
|
<TabsContent value="custom-fields">
|
||||||
|
<CustomFieldsSettings />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="deals">
|
<TabsContent value="deals">
|
||||||
<DealsSettings />
|
<DealsSettings />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@ -22,15 +22,39 @@ interface CustomFieldsManagerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create / rename / delete account-wide custom contact field definitions.
|
* Dialog wrapper around {@link CustomFieldsPanel}, used on the Contacts page.
|
||||||
* Per-contact values are edited elsewhere (contact detail → Custom Fields);
|
* The same panel is rendered inline under Settings → Custom Fields, so the
|
||||||
* this only manages the field catalogue. Admin+ gated by the caller — the
|
* editing UI lives in one place. Radix unmounts the dialog content on close,
|
||||||
* `custom_fields` RLS also rejects non-admin writes as defense in depth.
|
* so the panel remounts (and refetches) on each open.
|
||||||
*/
|
*/
|
||||||
export function CustomFieldsManager({
|
export function CustomFieldsManager({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: CustomFieldsManagerProps) {
|
}: CustomFieldsManagerProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="border-slate-700 bg-slate-900 text-slate-200 sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white">Custom fields</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400">
|
||||||
|
Define extra contact fields (e.g. ZIP code, lead source). They
|
||||||
|
appear on every contact and in the “Update Contact Field” automation
|
||||||
|
action.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<CustomFieldsPanel />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create / rename / delete account-wide custom contact field definitions.
|
||||||
|
* Per-contact values are edited elsewhere (contact detail → Custom Fields);
|
||||||
|
* this only manages the field catalogue. Admin+ gated by the caller — the
|
||||||
|
* `custom_fields` RLS also rejects non-admin writes as defense in depth.
|
||||||
|
*/
|
||||||
|
export function CustomFieldsPanel() {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { user, accountId } = useAuth();
|
const { user, accountId } = useAuth();
|
||||||
|
|
||||||
@ -51,27 +75,21 @@ export function CustomFieldsManager({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [supabase, accountId]);
|
}, [supabase, accountId]);
|
||||||
|
|
||||||
// Load the field list when the dialog opens. The setters inside
|
// Load the field list on mount once the account is known. The setters
|
||||||
// fetchFields run after the Supabase await — not synchronously in the
|
// inside fetchFields run after the Supabase await — not synchronously in
|
||||||
// effect body — so the cascade the lint rule warns about doesn't apply.
|
// the effect body — so the cascade the lint rule warns about doesn't apply.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && accountId) {
|
if (accountId) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
fetchFields();
|
fetchFields();
|
||||||
}
|
}
|
||||||
}, [open, accountId, fetchFields]);
|
}, [accountId, fetchFields]);
|
||||||
|
|
||||||
// Clear the draft on close (handler, not effect) so reopening starts fresh.
|
|
||||||
function handleOpenChange(next: boolean) {
|
|
||||||
if (!next) setNewName('');
|
|
||||||
onOpenChange(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Case-insensitive name clash within the loaded list. */
|
/** Case-insensitive name clash within the loaded list. */
|
||||||
function isDuplicate(name: string, exceptId?: string): boolean {
|
function isDuplicate(name: string, exceptId?: string): boolean {
|
||||||
const lower = name.toLowerCase();
|
const lower = name.toLowerCase();
|
||||||
return fields.some(
|
return fields.some(
|
||||||
(f) => f.id !== exceptId && f.field_name.toLowerCase() === lower,
|
(f) => f.id !== exceptId && f.field_name.toLowerCase() === lower
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,7 +125,10 @@ export function CustomFieldsManager({
|
|||||||
|
|
||||||
/** Returns true on success so the row can keep the new name, false so it
|
/** Returns true on success so the row can keep the new name, false so it
|
||||||
* reverts to the previous one. No-ops (blank / unchanged) count as success. */
|
* reverts to the previous one. No-ops (blank / unchanged) count as success. */
|
||||||
async function handleRename(field: CustomField, nextName: string): Promise<boolean> {
|
async function handleRename(
|
||||||
|
field: CustomField,
|
||||||
|
nextName: string
|
||||||
|
): Promise<boolean> {
|
||||||
const name = nextName.trim();
|
const name = nextName.trim();
|
||||||
if (!name || name === field.field_name) return true;
|
if (!name || name === field.field_name) return true;
|
||||||
if (isDuplicate(name, field.id)) {
|
if (isDuplicate(name, field.id)) {
|
||||||
@ -131,7 +152,7 @@ export function CustomFieldsManager({
|
|||||||
async function handleDelete(field: CustomField) {
|
async function handleDelete(field: CustomField) {
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!window.confirm(
|
||||||
`Delete "${field.field_name}"? This also removes its stored value on every contact. This cannot be undone.`,
|
`Delete "${field.field_name}"? This also removes its stored value on every contact. This cannot be undone.`
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@ -151,16 +172,7 @@ export function CustomFieldsManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<div className="space-y-4">
|
||||||
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-white">Custom fields</DialogTitle>
|
|
||||||
<DialogDescription className="text-slate-400">
|
|
||||||
Define extra contact fields (e.g. ZIP code, lead source). They appear
|
|
||||||
on every contact and in the “Update Contact Field” automation action.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* Create */}
|
{/* Create */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
@ -214,8 +226,7 @@ export function CustomFieldsManager({
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -254,7 +265,7 @@ function FieldRow({
|
|||||||
if (e.key === 'Enter') e.currentTarget.blur();
|
if (e.key === 'Enter') e.currentTarget.blur();
|
||||||
}}
|
}}
|
||||||
aria-label={`Rename ${field.field_name}`}
|
aria-label={`Rename ${field.field_name}`}
|
||||||
className="h-8 border-transparent bg-transparent text-white hover:border-slate-700 focus:border-primary"
|
className="focus:border-primary h-8 border-transparent bg-transparent text-white hover:border-slate-700"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
29
src/components/settings/custom-fields-settings.tsx
Normal file
29
src/components/settings/custom-fields-settings.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { CustomFieldsPanel } from '@/components/contacts/custom-fields-manager';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings → Custom Fields. Manage the account-wide custom contact field
|
||||||
|
* catalogue (the same panel the Contacts page exposes via a dialog). Writes
|
||||||
|
* are admin-gated by the caller and enforced by `custom_fields` RLS.
|
||||||
|
*/
|
||||||
|
export function CustomFieldsSettings() {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Custom fields</h2>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Define extra contact fields (e.g. ZIP code, lead source). They appear
|
||||||
|
on every contact and in the “Update Contact Field” automation action.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-slate-700 bg-slate-900 ring-0 ring-transparent">
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<CustomFieldsPanel />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user