modcomms/frontend/components/Settings.tsx
Vadym Samoilenko 1f2a2e5016 Settings: fix sub-channel dropdown and Add button styling
- Sub-Channel dropdown: always white bg + azure border (never azure fill),
  even when a value is selected; channel dropdown retains azure fill
- Add button: joined to input field as a single group (no gap, shared
  border, matching corner radius); button colour is azure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 16:10:10 +00:00

486 lines
25 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';
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="p-6 flex flex-col h-full">
<h2 className="text-xl font-semibold text-oliver-black mb-4">{title}</h2>
<form onSubmit={handleSubmit} className="flex 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-2 border-oliver-azure rounded-l-[10px] rounded-r-none border-r-0 focus:ring-0 focus:outline-none transition disabled:bg-oliver-grey disabled:cursor-not-allowed text-oliver-black"
disabled={disabled}
/>
<button
type="submit"
className="bg-oliver-azure text-white font-semibold py-2 px-6 rounded-r-[10px] rounded-l-none hover:bg-oliver-azure/90 transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed flex-shrink-0"
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-oliver-grey p-2.5 rounded-[10px] border border-grey-300 group hover:border-oliver-azure/30 transition-colors"
>
<span className="text-oliver-black">{item}</span>
<button
onClick={() => onRemove(item)}
className={`text-oliver-black/60 hover:text-error 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-oliver-black/60 bg-oliver-grey rounded-[10px]">
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="p-6">
<h3 className="text-lg font-semibold text-oliver-black mb-4 flex items-center gap-2">
<PlusIcon className="h-5 w-5 text-oliver-azure" />
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-oliver-black mb-1">Name</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full p-2 border-2 border-oliver-azure rounded-[10px] focus:ring-2 focus:ring-oliver-azure focus:border-oliver-azure text-oliver-black"
placeholder="e.g. John Smith"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-oliver-black mb-1">Email</label>
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
className="w-full p-2 border-2 border-oliver-azure rounded-[10px] focus:ring-2 focus:ring-oliver-azure focus:border-oliver-azure text-oliver-black"
placeholder="user@example.com"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-oliver-black mb-1">Agency</label>
<div className="relative">
<select
value={newAgency}
onChange={(e) => setNewAgency(e.target.value)}
className="w-full p-2 border-2 border-oliver-azure rounded-[10px] focus:ring-2 focus:ring-oliver-azure focus:border-oliver-azure appearance-none text-oliver-black"
>
{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-oliver-black/60 pointer-events-none" />
</div>
</div>
<button
type="submit"
className="bg-oliver-azure text-white font-semibold py-2 px-6 rounded-full hover:bg-oliver-azure/90 transition-colors"
>
Add User
</button>
</form>
</div>
{/* User List */}
<div className="overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full">
<thead className="bg-oliver-sky">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Name</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Email</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-oliver-black 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="divide-y divide-grey-300">
{users.map((user, index) => (
<tr key={user.id} className={index % 2 === 0 ? 'bg-white' : 'bg-oliver-grey'}>
<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-oliver-grey flex items-center justify-center text-oliver-azure">
<UserIcon className="h-4 w-4" />
</div>
<div className="ml-4">
<div className="text-sm font-medium text-oliver-black">{user.name}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-oliver-black">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-oliver-black">
<div className="relative max-w-xs">
<select
value={user.agency}
onChange={(e) => handleAgencyChange(user.id, e.target.value)}
className="w-full bg-white border-2 border-oliver-azure text-oliver-black py-1.5 pl-3 pr-8 rounded-[10px] text-sm focus:outline-none focus:ring-2 focus:ring-oliver-azure 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-oliver-azure 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-oliver-black/60 hover:text-error 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;
readOnly?: boolean;
}
type Tab = 'Campaigns' | 'Channels' | 'SubChannels' | 'ProofTypes' | 'Users';
export const Settings: React.FC<SettingsProps> = ({
options,
onAddCampaign,
onRemoveCampaign,
onAddChannel,
onRemoveChannel,
onAddSubChannel,
onRemoveSubChannel,
onAddProofType,
onRemoveProofType,
readOnly = false,
}) => {
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-white flex flex-col">
<header className="mb-6 flex-shrink-0">
<h1 className="text-3xl lg:text-4xl font-semibold text-oliver-black">Settings</h1>
<p className="text-base lg:text-lg text-oliver-black mt-1">
Configure application defaults and user access.
</p>
{readOnly && (
<div className="mt-3 inline-flex items-center gap-2 bg-oliver-grey border border-oliver-azure text-oliver-azure text-sm font-medium px-4 py-2 rounded-[10px]">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
View-only mode
</div>
)}
</header>
<div className="flex-1 flex flex-col min-h-0">
{/* Tabs Header */}
<div className="border-b border-grey-300 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' }, // Hidden: not connected to backend user system
].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-oliver-azure text-oliver-azure'
: 'border-transparent text-oliver-black hover:text-oliver-azure hover:border-grey-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"
disabled={readOnly}
/>
</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"
disabled={readOnly}
/>
</div>
)}
{activeTab === 'SubChannels' && (
<div className="max-w-3xl space-y-4">
<div className="bg-white p-4 rounded-[10px] border border-grey-300 shadow-sm">
<label className="block text-sm font-medium text-oliver-black mb-2">Select Parent Channel</label>
<div className="relative">
<select
value={selectedChannel}
onChange={(e) => setSelectedChannel(e.target.value)}
className={`w-full p-2 border-2 rounded-[10px] focus:ring-2 focus:ring-oliver-azure appearance-none ${selectedChannel ? 'bg-oliver-azure text-white border-oliver-azure' : 'border-oliver-azure text-oliver-black'}`}
>
<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 pointer-events-none ${selectedChannel ? 'text-white' : 'text-oliver-azure'}`}/>
</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={readOnly || !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-[10px] border border-grey-300 shadow-sm">
<label className="block text-sm font-medium text-oliver-black mb-2">Select Channel</label>
<div className="relative">
<select
value={selectedChannel}
onChange={(e) => {
setSelectedChannel(e.target.value);
setSelectedSubChannel('');
}}
className={`w-full p-2 border-2 rounded-[10px] focus:ring-2 focus:ring-oliver-azure appearance-none ${selectedChannel ? 'bg-oliver-azure text-white border-oliver-azure' : 'border-oliver-azure text-oliver-black'}`}
>
<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 pointer-events-none ${selectedChannel ? 'text-white' : 'text-oliver-azure'}`}/>
</div>
</div>
<div className="bg-white p-4 rounded-[10px] border border-grey-300 shadow-sm">
<label className="block text-sm font-medium text-oliver-black mb-2">Select Sub-Channel</label>
<div className="relative">
<select
value={selectedSubChannel}
onChange={(e) => setSelectedSubChannel(e.target.value)}
className="w-full p-2 border-2 border-oliver-azure rounded-[10px] focus:ring-2 focus:ring-oliver-azure appearance-none bg-white text-oliver-black disabled:opacity-50 disabled:cursor-not-allowed"
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 pointer-events-none text-oliver-azure"/>
</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={readOnly || !selectedChannel || !selectedSubChannel}
placeholder="e.g. In-feed 1x1, 300x600"
/>
</div>
)}
{/* Users tab hidden: not connected to backend user system
{activeTab === 'Users' && (
<UsersTab />
)}
*/}
</div>
</div>
</div>
);
};