diff --git a/backend/alembic/versions/002_seed_dropdown_options.py b/backend/alembic/versions/002_seed_dropdown_options.py index 5532cb0..b720c58 100644 --- a/backend/alembic/versions/002_seed_dropdown_options.py +++ b/backend/alembic/versions/002_seed_dropdown_options.py @@ -1,4 +1,4 @@ -"""Seed default dropdown options +"""Seed default dropdown options, agencies, and brand guidelines Revision ID: 002_seed_dropdown Revises: 001_initial @@ -20,9 +20,44 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - """Seed default channel/sub-channel/proof-type options.""" + """Seed default channel/sub-channel/proof-type options, agencies, and brand guidelines.""" - # Default dropdown hierarchy (from original hardcoded values) + conn = op.get_bind() + + # =========================================== + # 1. Seed Agencies + # =========================================== + agencies = ["OLIVER Agency", "Barclays", "Mindshare", "Zenith", "Unassigned"] + + for agency_name in agencies: + agency_id = str(uuid4()) + conn.execute( + sa.text(""" + INSERT INTO agencies (id, name) + VALUES (:id, :name) + ON CONFLICT (name) DO NOTHING + """), + {"id": agency_id, "name": agency_name} + ) + + # =========================================== + # 2. Seed Brand Guidelines (using dropdown_options table) + # =========================================== + brand_guidelines = ["Barclays", "Barclaycard"] + + for idx, brand in enumerate(brand_guidelines): + brand_id = str(uuid4()) + conn.execute( + sa.text(""" + INSERT INTO dropdown_options (id, option_type, parent_id, value, display_order) + VALUES (:id, 'brand_guideline', NULL, :value, :order) + """), + {"id": brand_id, "value": brand, "order": idx} + ) + + # =========================================== + # 3. Seed Channel/Sub-Channel/Proof-Type Hierarchy + # =========================================== dropdown_data = { "Social": { "Meta": ["In-feed 1x1", "In-feed 4x5", "In-feed 9x16", "Stories"], @@ -52,9 +87,6 @@ def upgrade() -> None: }, } - # Get connection for raw SQL inserts - conn = op.get_bind() - for channel_name, sub_channels in dropdown_data.items(): # Insert channel channel_id = str(uuid4()) @@ -90,5 +122,6 @@ def upgrade() -> None: def downgrade() -> None: - """Remove all seeded dropdown options.""" - op.execute("DELETE FROM dropdown_options WHERE option_type IN ('channel', 'sub_channel', 'proof_type')") + """Remove all seeded data.""" + op.execute("DELETE FROM dropdown_options WHERE option_type IN ('channel', 'sub_channel', 'proof_type', 'brand_guideline')") + op.execute("DELETE FROM agencies WHERE name IN ('OLIVER Agency', 'Barclays', 'Mindshare', 'Zenith', 'Unassigned')") diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 3e57ed5..6337be0 100755 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -19,6 +19,7 @@ from app.api.schemas import ( ErrorItemResponse, AnalyticsResponse, DropdownOptionsResponse, + AgencyResponse, UserResponse, ) from app.dependencies.auth import get_current_user @@ -590,3 +591,20 @@ async def delete_proof_type( if not success: raise HTTPException(status_code=404, detail=f"Proof type '{proof_type}' not found") await db.commit() + + +# Agency endpoints +@router.get("/agencies", response_model=list[AgencyResponse]) +async def list_agencies( + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """List all agencies.""" + from sqlalchemy import select + from app.models.models import Agency + + stmt = select(Agency).order_by(Agency.name) + result = await db.execute(stmt) + agencies = result.scalars().all() + + return [AgencyResponse(id=a.id, name=a.name) for a in agencies] diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 25474ca..caa36a6 100755 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -150,6 +150,16 @@ class AnalyticsResponse(BaseModel): class DropdownOptionsResponse(BaseModel): campaigns: list[str] channels: dict[str, dict[str, list[str]]] + brand_guidelines: list[str] = [] + + +# Agency schemas +class AgencyResponse(BaseModel): + id: uuid.UUID + name: str + + class Config: + from_attributes = True # User schemas diff --git a/backend/app/repositories/dropdown_repository.py b/backend/app/repositories/dropdown_repository.py index cdc3c12..b41b381 100644 --- a/backend/app/repositories/dropdown_repository.py +++ b/backend/app/repositories/dropdown_repository.py @@ -17,7 +17,11 @@ class DropdownRepository: async def get_all_hierarchical(self) -> dict: """ Get all dropdown options as a hierarchical structure. - Returns: { channels: { channel_name: { sub_channel_name: [proof_types] } } } + Returns: { + channels: { channel_name: { sub_channel_name: [proof_types] } }, + brand_guidelines: [brand_names], + campaigns: [] + } """ # Get all channels (top-level, no parent) stmt = select(DropdownOption).where( @@ -28,7 +32,7 @@ class DropdownRepository: result = await self.session.execute(stmt) channels = result.scalars().all() - # Build hierarchical structure + # Build hierarchical structure for channels hierarchy: dict[str, dict[str, list[str]]] = {} for channel in channels: @@ -41,7 +45,19 @@ class DropdownRepository: pt.value for pt in sub_channel.children ] - return {"channels": hierarchy, "campaigns": []} # campaigns not used from dropdown options + # Get brand guidelines + stmt = select(DropdownOption).where( + DropdownOption.option_type == "brand_guideline" + ).order_by(DropdownOption.display_order) + + result = await self.session.execute(stmt) + brand_guidelines = [bg.value for bg in result.scalars().all()] + + return { + "channels": hierarchy, + "brand_guidelines": brand_guidelines, + "campaigns": [] # campaigns not used from dropdown options + } async def add_channel(self, name: str) -> DropdownOption: """Add a new channel (top-level option).""" diff --git a/frontend/App.tsx b/frontend/App.tsx index b17be51..5d6baa9 100755 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -25,6 +25,8 @@ export interface DropdownOptions { campaigns: string[]; // Hierarchy: Channel -> SubChannel -> ProofType[] channels: Record>; + // Brand guidelines for campaign creation + brandGuidelines: string[]; } const App: React.FC = () => { @@ -48,7 +50,8 @@ const App: React.FC = () => { // Dropdown options now loaded from API const [dropdownOptions, setDropdownOptions] = useState({ campaigns: [], - channels: {} + channels: {}, + brandGuidelines: [] }); // Load dropdown options from API when authenticated @@ -60,7 +63,8 @@ const App: React.FC = () => { const options = await apiService.getDropdownOptions(); setDropdownOptions({ campaigns: options.campaigns || [], - channels: options.channels || {} + channels: options.channels || {}, + brandGuidelines: options.brand_guidelines || [] }); } catch (error) { console.error('Failed to load dropdown options:', error); @@ -70,7 +74,8 @@ const App: React.FC = () => { channels: { "Social": { "Meta": ["In-feed 1x1", "In-feed 4x5"] }, "Display": { "Banner": ["300x600", "300x250"] } - } + }, + brandGuidelines: ["Barclays", "Barclaycard"] }); } }; @@ -419,7 +424,8 @@ const App: React.FC = () => { const options = await apiService.getDropdownOptions(); setDropdownOptions({ campaigns: options.campaigns || [], - channels: options.channels || {} + channels: options.channels || {}, + brandGuidelines: options.brand_guidelines || [] }); } catch (error) { console.error('Failed to refresh dropdown options:', error); diff --git a/frontend/components/Campaigns.tsx b/frontend/components/Campaigns.tsx index c7a3da3..26d9935 100755 --- a/frontend/components/Campaigns.tsx +++ b/frontend/components/Campaigns.tsx @@ -1311,10 +1311,11 @@ export const Campaigns: React.FC = ({ return ( <> - setIsModalOpen(false)} onAddCampaign={onAddNewCampaign} + brandGuidelines={dropdownOptions.brandGuidelines} /> void; + brandGuidelines?: string[]; } -export const CreateCampaignModal: React.FC = ({ isOpen, onClose, onAddCampaign }) => { +export const CreateCampaignModal: React.FC = ({ isOpen, onClose, onAddCampaign, brandGuidelines: brandGuidelineOptions = [] }) => { const [name, setName] = useState(''); - const [brandGuidelines, setBrandGuidelines] = useState(''); + const [selectedBrandGuideline, setSelectedBrandGuideline] = useState(''); const [workfrontId, setWorkfrontId] = useState(''); const [clientLead, setClientLead] = useState(''); const [error, setError] = useState(null); @@ -24,7 +26,7 @@ export const CreateCampaignModal: React.FC = ({ isOpen // Reset form when modal opens if (isOpen) { setName(''); - setBrandGuidelines(''); + setSelectedBrandGuideline(''); setWorkfrontId(''); setClientLead(''); setError(null); @@ -51,7 +53,7 @@ export const CreateCampaignModal: React.FC = ({ isOpen e.preventDefault(); const isIdValidOnSubmit = /^#WF_\d+$/.test(workfrontId); - if (!name.trim() || !clientLead.trim() || !workfrontId.trim() || !brandGuidelines.trim()) { + if (!name.trim() || !clientLead.trim() || !workfrontId.trim() || !selectedBrandGuideline.trim()) { return; } @@ -59,15 +61,15 @@ export const CreateCampaignModal: React.FC = ({ isOpen setError("Workfront Campaign ID must be in the format '#WF_12345'"); return; } - + setError(null); - onAddCampaign({ name, workfrontId, clientLead, brandGuidelines }); + onAddCampaign({ name, workfrontId, clientLead, brandGuidelines: selectedBrandGuideline }); onClose(); }; if (!isOpen) return null; - - const isFormInvalid = !name.trim() || !workfrontId.trim() || !clientLead.trim() || !brandGuidelines.trim(); + + const isFormInvalid = !name.trim() || !workfrontId.trim() || !clientLead.trim() || !selectedBrandGuideline.trim(); return (
= ({ isOpen
diff --git a/frontend/components/Settings.tsx b/frontend/components/Settings.tsx index 10493c5..e05ecd5 100755 --- a/frontend/components/Settings.tsx +++ b/frontend/components/Settings.tsx @@ -1,6 +1,7 @@ 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'; @@ -95,16 +96,36 @@ const INITIAL_USERS: User[] = [ { id: '3', name: "Sarah Jenkins", email: "sarah.jenkins@mindshare.com", agency: "Mindshare" }, ]; -const AGENCIES = ["OLIVER Agency", "Barclays", "Mindshare", "Zenith", "Unassigned"]; +// Fallback agencies if API fails +const DEFAULT_AGENCIES = ["OLIVER Agency", "Barclays", "Mindshare", "Zenith", "Unassigned"]; const UsersTab: React.FC = () => { const [users, setUsers] = useState(() => { const saved = localStorage.getItem('barclays_modcomms_users'); return saved ? JSON.parse(saved) : INITIAL_USERS; }); + const [agencies, setAgencies] = useState(DEFAULT_AGENCIES); const [newName, setNewName] = useState(''); const [newEmail, setNewEmail] = useState(''); - const [newAgency, setNewAgency] = useState(AGENCIES[0]); + 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)); @@ -174,7 +195,7 @@ const UsersTab: React.FC = () => { 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 => )} + {agencies.map(a => )}
@@ -223,7 +244,7 @@ const UsersTab: React.FC = () => { 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 => )} + {agencies.map(a => )} diff --git a/frontend/services/apiService.ts b/frontend/services/apiService.ts index ee84b75..b90b2b7 100755 --- a/frontend/services/apiService.ts +++ b/frontend/services/apiService.ts @@ -352,11 +352,22 @@ class ApiService { method: 'DELETE', }); } + + // Agency endpoints + async getAgencies(): Promise { + return this.fetch('/agencies'); + } } export interface DropdownOptionsResponse { campaigns: string[]; channels: Record>; + brand_guidelines: string[]; +} + +export interface AgencyResponse { + id: string; + name: string; } export const apiService = new ApiService();