modcomms/frontend/components/Settings.tsx
michael 6bdb02d78b Seed database with agencies, brand guidelines, and dropdown options
Backend:
- Update migration to seed agencies (OLIVER Agency, Barclays, etc.)
- Seed brand guidelines (Barclays, Barclaycard) in dropdown_options
- Seed channel/sub-channel/proof-type hierarchy
- Add /api/agencies endpoint to list all agencies
- Update DropdownOptionsResponse to include brand_guidelines
- Update dropdown repository to return brand guidelines

Frontend:
- Update DropdownOptions interface to include brandGuidelines
- CreateCampaignModal now receives brand guidelines from API
- Settings UsersTab fetches agencies from API instead of hardcoded list
- Add getAgencies() method to apiService

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 17:16:23 -06:00

519 lines
27 KiB
TypeScript
Executable file

import React, { useState, useEffect } from 'react';
import type { DropdownOptions } from '../App';
import apiService from '../services/apiService';
import { TrashIcon } from './icons/TrashIcon';
import { UserIcon } from './icons/UserIcon';
import { PlusIcon } from './icons/PlusIcon';
import { ChevronDownIcon } from './icons/ChevronDownIcon';
import { ClipboardIcon } from './icons/ClipboardIcon';
import { CopyGenAIIcon } from './icons/CopyGenAIIcon';
interface ManagementCardProps {
title: string;
items: string[];
onAdd: (item: string) => void;
onRemove: (item: string) => void;
disabled?: boolean;
placeholder?: string;
}
const ManagementCard: React.FC<ManagementCardProps> = ({ title, items, onAdd, onRemove, disabled = false, placeholder }) => {
const [newItem, setNewItem] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (newItem.trim()) {
onAdd(newItem.trim());
setNewItem('');
}
};
return (
<div className="bg-white rounded-lg shadow-md p-6 border border-gray-200 flex flex-col h-full">
<h2 className="text-xl font-bold text-brand-dark-blue mb-4">{title}</h2>
<form onSubmit={handleSubmit} className="flex gap-2 mb-4">
<input
type="text"
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
placeholder={placeholder || `New ${title.slice(0, -1)}...`}
className="flex-grow p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent transition disabled:bg-gray-100 disabled:cursor-not-allowed"
disabled={disabled}
/>
<button
type="submit"
className="bg-brand-accent text-white font-semibold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed"
disabled={!newItem.trim() || disabled}
>
Add
</button>
</form>
<div className="flex-1 overflow-y-auto pr-2 -mr-2">
{items.length > 0 ? (
<ul className="space-y-2">
{items.map((item) => (
<li
key={item}
className="flex items-center justify-between bg-gray-50 p-2.5 rounded-md border border-gray-200 group hover:border-brand-accent/30 transition-colors"
>
<span className="text-gray-800">{item}</span>
<button
onClick={() => onRemove(item)}
className={`text-gray-400 hover:text-red-600 transition-opacity ${disabled ? 'opacity-0 cursor-not-allowed' : 'opacity-0 group-hover:opacity-100'}`}
title={`Remove ${item}`}
disabled={disabled}
>
<TrashIcon className="h-5 w-5" />
</button>
</li>
))}
</ul>
) : (
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-md">
No items found.
</div>
)}
</div>
</div>
);
};
// --- USER MANAGEMENT ---
interface User {
id: string;
name: string;
email: string;
agency: string;
}
const INITIAL_USERS: User[] = [
{ id: '1', name: "Steve O'Donoghue", email: "steveodonoghue@oliver.agency", agency: "OLIVER Agency" },
{ id: '2', name: "Jane Doe", email: "jane.doe@barclays.com", agency: "Barclays" },
{ id: '3', name: "Sarah Jenkins", email: "sarah.jenkins@mindshare.com", agency: "Mindshare" },
];
// Fallback agencies if API fails
const DEFAULT_AGENCIES = ["OLIVER Agency", "Barclays", "Mindshare", "Zenith", "Unassigned"];
const UsersTab: React.FC = () => {
const [users, setUsers] = useState<User[]>(() => {
const saved = localStorage.getItem('barclays_modcomms_users');
return saved ? JSON.parse(saved) : INITIAL_USERS;
});
const [agencies, setAgencies] = useState<string[]>(DEFAULT_AGENCIES);
const [newName, setNewName] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newAgency, setNewAgency] = useState('');
// Load agencies from API
useEffect(() => {
const loadAgencies = async () => {
try {
const response = await apiService.getAgencies();
const agencyNames = response.map(a => a.name);
setAgencies(agencyNames.length > 0 ? agencyNames : DEFAULT_AGENCIES);
if (agencyNames.length > 0 && !newAgency) {
setNewAgency(agencyNames[0]);
}
} catch (error) {
console.error('Failed to load agencies:', error);
setAgencies(DEFAULT_AGENCIES);
}
};
loadAgencies();
}, []);
useEffect(() => {
localStorage.setItem('barclays_modcomms_users', JSON.stringify(users));
}, [users]);
const handleAddUser = (e: React.FormEvent) => {
e.preventDefault();
if (newName && newEmail) {
const newUser: User = {
id: Date.now().toString(),
name: newName,
email: newEmail,
agency: newAgency
};
setUsers([...users, newUser]);
setNewName('');
setNewEmail('');
}
};
const handleAgencyChange = (userId: string, newAgency: string) => {
setUsers(users.map(u => u.id === userId ? { ...u, agency: newAgency } : u));
};
const handleDeleteUser = (userId: string) => {
if (window.confirm('Are you sure you want to remove this user?')) {
setUsers(users.filter(u => u.id !== userId));
}
};
return (
<div className="space-y-8">
{/* Add User Section */}
<div className="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<h3 className="text-lg font-bold text-brand-dark-blue mb-4 flex items-center gap-2">
<PlusIcon className="h-5 w-5 text-brand-accent" />
Add New User
</h3>
<form onSubmit={handleAddUser} className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent"
placeholder="e.g. John Smith"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent"
placeholder="user@example.com"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Agency</label>
<div className="relative">
<select
value={newAgency}
onChange={(e) => setNewAgency(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent appearance-none"
>
{agencies.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
</div>
</div>
<button
type="submit"
className="bg-brand-accent text-white font-semibold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors"
>
Add User
</button>
</form>
</div>
{/* User List */}
<div className="bg-white rounded-lg shadow-md overflow-hidden border border-gray-200">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Name</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Email</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Assigned Agency</th>
<th scope="col" className="relative px-6 py-3"><span className="sr-only">Actions</span></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-brand-light-blue/20 flex items-center justify-center text-brand-accent">
<UserIcon className="h-4 w-4" />
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{user.name}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="relative max-w-xs">
<select
value={user.agency}
onChange={(e) => handleAgencyChange(user.id, e.target.value)}
className="w-full bg-white border border-gray-300 text-gray-700 py-1.5 pl-3 pr-8 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent appearance-none"
>
{agencies.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<ChevronDownIcon className="absolute right-2 top-1/2 -translate-y-1/2 h-3 w-3 text-gray-500 pointer-events-none" />
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleDeleteUser(user.id)}
className="text-gray-400 hover:text-red-600 transition-colors"
title="Remove User"
>
<TrashIcon className="h-5 w-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
// --- SETTINGS COMPONENT ---
interface SettingsProps {
options: DropdownOptions;
onAddCampaign: (value: string) => void;
onRemoveCampaign: (value: string) => void;
onAddChannel: (value: string) => void;
onRemoveChannel: (value: string) => void;
onAddSubChannel: (channel: string, value: string) => void;
onRemoveSubChannel: (channel: string, value: string) => void;
onAddProofType: (channel: string, subChannel: string, value: string) => void;
onRemoveProofType: (channel: string, subChannel: string, value: string) => void;
onNavigate: (view: string) => void;
}
type Tab = 'Campaigns' | 'Channels' | 'SubChannels' | 'ProofTypes' | 'Users' | 'Beta';
export const Settings: React.FC<SettingsProps> = ({
options,
onAddCampaign,
onRemoveCampaign,
onAddChannel,
onRemoveChannel,
onAddSubChannel,
onRemoveSubChannel,
onAddProofType,
onRemoveProofType,
onNavigate,
}) => {
const [activeTab, setActiveTab] = useState<Tab>('Channels');
const [selectedChannel, setSelectedChannel] = useState<string>('');
const [selectedSubChannel, setSelectedSubChannel] = useState<string>('');
// Update selected channel if it disappears
useEffect(() => {
if (selectedChannel && !options.channels[selectedChannel]) {
setSelectedChannel('');
setSelectedSubChannel('');
}
}, [options.channels, selectedChannel]);
// Update selected sub-channel if it disappears
useEffect(() => {
if (selectedChannel && selectedSubChannel) {
const subChannels = options.channels[selectedChannel] || {};
if (!subChannels[selectedSubChannel]) {
setSelectedSubChannel('');
}
}
}, [options.channels, selectedChannel, selectedSubChannel]);
return (
<div className="p-4 sm:p-6 lg:p-8 h-full bg-brand-gray flex flex-col">
<header className="mb-6 flex-shrink-0">
<h1 className="text-3xl lg:text-4xl font-bold text-brand-dark-blue">Settings</h1>
<p className="text-base lg:text-lg text-gray-600 mt-1">
Configure application defaults and user access.
</p>
</header>
<div className="flex-1 flex flex-col min-h-0">
{/* Tabs Header */}
<div className="border-b border-gray-200 bg-white px-4 rounded-t-lg shadow-sm flex-shrink-0">
<nav className="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs">
{[
{ id: 'Campaigns', label: 'Campaigns' },
{ id: 'Channels', label: 'Channels' },
{ id: 'SubChannels', label: 'Sub-channels' },
{ id: 'ProofTypes', label: 'Proof Types' },
{ id: 'Users', label: 'Users' },
{ id: 'Beta', label: '[Beta]' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as Tab)}
className={`
whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors
${activeTab === tab.id
? 'border-brand-accent text-brand-accent'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
aria-current={activeTab === tab.id ? 'page' : undefined}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Tabs Content */}
<div className="flex-1 bg-transparent pt-6 overflow-y-auto">
{activeTab === 'Campaigns' && (
<div className="max-w-3xl">
<ManagementCard
title="Campaigns"
items={options.campaigns}
onAdd={onAddCampaign}
onRemove={onRemoveCampaign}
placeholder="e.g. Q4 Marketing"
/>
</div>
)}
{activeTab === 'Channels' && (
<div className="max-w-3xl">
<ManagementCard
title="Channels"
items={Object.keys(options.channels)}
onAdd={onAddChannel}
onRemove={onRemoveChannel}
placeholder="e.g. Social, OOH"
/>
</div>
)}
{activeTab === 'SubChannels' && (
<div className="max-w-3xl space-y-4">
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
<label className="block text-sm font-medium text-gray-700 mb-2">Select Parent Channel</label>
<div className="relative">
<select
value={selectedChannel}
onChange={(e) => setSelectedChannel(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent appearance-none"
>
<option value="" disabled>Select Channel</option>
{Object.keys(options.channels).map(c => <option key={c} value={c}>{c}</option>)}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
</div>
</div>
<ManagementCard
title={selectedChannel ? `Sub-Channels for ${selectedChannel}` : "Sub-Channels"}
items={selectedChannel ? Object.keys(options.channels[selectedChannel]) : []}
onAdd={(val) => onAddSubChannel(selectedChannel, val)}
onRemove={(val) => onRemoveSubChannel(selectedChannel, val)}
disabled={!selectedChannel}
placeholder="e.g. Meta, Video"
/>
</div>
)}
{activeTab === 'ProofTypes' && (
<div className="max-w-3xl space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
<label className="block text-sm font-medium text-gray-700 mb-2">Select Channel</label>
<div className="relative">
<select
value={selectedChannel}
onChange={(e) => {
setSelectedChannel(e.target.value);
setSelectedSubChannel('');
}}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent appearance-none"
>
<option value="" disabled>Select Channel</option>
{Object.keys(options.channels).map(c => <option key={c} value={c}>{c}</option>)}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
</div>
</div>
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
<label className="block text-sm font-medium text-gray-700 mb-2">Select Sub-Channel</label>
<div className="relative">
<select
value={selectedSubChannel}
onChange={(e) => setSelectedSubChannel(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent appearance-none disabled:bg-gray-100"
disabled={!selectedChannel}
>
<option value="" disabled>Select Sub-Channel</option>
{selectedChannel && Object.keys(options.channels[selectedChannel]).map(sc => (
<option key={sc} value={sc}>{sc}</option>
))}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
</div>
</div>
</div>
<ManagementCard
title={selectedSubChannel ? `Proof Types for ${selectedSubChannel}` : "Proof Types"}
items={
(selectedChannel && selectedSubChannel)
? (options.channels[selectedChannel][selectedSubChannel] || [])
: []
}
onAdd={(val) => onAddProofType(selectedChannel, selectedSubChannel, val)}
onRemove={(val) => onRemoveProofType(selectedChannel, selectedSubChannel, val)}
disabled={!selectedChannel || !selectedSubChannel}
placeholder="e.g. In-feed 1x1, 300x600"
/>
</div>
)}
{activeTab === 'Users' && (
<UsersTab />
)}
{activeTab === 'Beta' && (
<div className="max-w-3xl space-y-6">
<div className="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<h2 className="text-xl font-bold text-brand-dark-blue mb-2 flex items-center gap-2">
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-brand-accent opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-brand-accent"></span>
</span>
Beta Features
</h2>
<p className="text-gray-600 mb-6">
Explore experimental features currently in development. These tools are functional but may change over time.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={() => onNavigate('WIP Reviewer')}
className="flex flex-col items-start p-4 rounded-lg border border-gray-200 hover:border-brand-accent hover:bg-blue-50/50 transition-all duration-300 group text-left"
>
<div className="p-2 rounded-md bg-brand-light-blue/10 text-brand-accent mb-3 group-hover:scale-110 transition-transform">
<ClipboardIcon className="h-6 w-6" />
</div>
<h3 className="font-bold text-brand-dark-blue mb-1">WIP Reviewer</h3>
<p className="text-sm text-gray-500">Early-stage feedback on assets before they are finalized.</p>
</button>
<button
onClick={() => onNavigate('CopyGenAI')}
className="flex flex-col items-start p-4 rounded-lg border border-gray-200 hover:border-brand-accent hover:bg-blue-50/50 transition-all duration-300 group text-left"
>
<div className="p-2 rounded-md bg-purple-100 text-purple-600 mb-3 group-hover:scale-110 transition-transform">
<CopyGenAIIcon className="h-6 w-6" />
</div>
<h3 className="font-bold text-brand-dark-blue mb-1">CopyGenAI</h3>
<p className="text-sm text-gray-500">Generate and refine marketing copy with AI assistance.</p>
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};