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:
Arnas Donauskas 2026-06-11 15:48:41 +03:00
parent 91e6375e61
commit 599b21eed0
No known key found for this signature in database
3 changed files with 160 additions and 92 deletions

View File

@ -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>

View File

@ -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"

View 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>
);
}