- 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>
486 lines
25 KiB
TypeScript
Executable file
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>
|
|
);
|
|
};
|