- Add campaign_assignments table for user-to-campaign mapping - Superadmin/managers can assign users to campaigns; visibility filtered by assignment/ownership - Managers can only manage (tracks, assignments) on campaigns they created - Budget controlled by superadmin only, with proper modal UI for editing - Ownership-based editing for campaigns, projects, comments (creators can edit their own) - Role-scoped dashboard and finance data (managers see only their campaigns' data) - Manager's budget derived from sum of their campaign budgets set by superadmin - Hide UI features users cannot use (principle of least privilege across all pages) - Fix profile completion prompt persisting after saving (login response now includes profileComplete) - Add post detail modal in campaign detail with thumbnails, publication links, and metadata - Add comment inline editing for comment authors - Move financial summary cards below filters on Campaigns page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
207 lines
8.4 KiB
JavaScript
207 lines
8.4 KiB
JavaScript
import { useState, useEffect, useContext } from 'react'
|
|
import { Plus, Search, FolderKanban } from 'lucide-react'
|
|
import { AppContext } from '../App'
|
|
import { api } from '../utils/api'
|
|
import { useAuth } from '../contexts/AuthContext'
|
|
import ProjectCard from '../components/ProjectCard'
|
|
import Modal from '../components/Modal'
|
|
|
|
const EMPTY_PROJECT = {
|
|
name: '', description: '', brand_id: '', status: 'active',
|
|
owner_id: '', due_date: '',
|
|
}
|
|
|
|
export default function Projects() {
|
|
const { teamMembers, brands } = useContext(AppContext)
|
|
const { permissions } = useAuth()
|
|
const [projects, setProjects] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showModal, setShowModal] = useState(false)
|
|
const [formData, setFormData] = useState(EMPTY_PROJECT)
|
|
const [searchTerm, setSearchTerm] = useState('')
|
|
|
|
useEffect(() => { loadProjects() }, [])
|
|
|
|
const loadProjects = async () => {
|
|
try {
|
|
const res = await api.get('/projects')
|
|
setProjects(res.data || res || [])
|
|
} catch (err) {
|
|
console.error('Failed to load projects:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleCreate = async () => {
|
|
try {
|
|
const data = {
|
|
name: formData.name,
|
|
description: formData.description,
|
|
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
|
|
owner_id: formData.owner_id ? Number(formData.owner_id) : null,
|
|
status: formData.status,
|
|
due_date: formData.due_date || null,
|
|
}
|
|
await api.post('/projects', data)
|
|
setShowModal(false)
|
|
setFormData(EMPTY_PROJECT)
|
|
loadProjects()
|
|
} catch (err) {
|
|
console.error('Create failed:', err)
|
|
}
|
|
}
|
|
|
|
const filtered = projects.filter(p => {
|
|
if (searchTerm && !p.name?.toLowerCase().includes(searchTerm.toLowerCase())) return false
|
|
return true
|
|
})
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="animate-pulse">
|
|
<div className="h-12 bg-surface-tertiary rounded-xl mb-4"></div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{[...Array(6)].map((_, i) => <div key={i} className="h-56 bg-surface-tertiary rounded-xl"></div>)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4 animate-fade-in">
|
|
{/* Toolbar */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search projects..."
|
|
value={searchTerm}
|
|
onChange={e => setSearchTerm(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
|
/>
|
|
</div>
|
|
|
|
{permissions?.canCreateProjects && (
|
|
<button
|
|
onClick={() => setShowModal(true)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
New Project
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Project grid */}
|
|
{filtered.length === 0 ? (
|
|
<div className="py-20 text-center">
|
|
<FolderKanban className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
|
<p className="text-text-secondary font-medium">No projects yet</p>
|
|
<p className="text-sm text-text-tertiary mt-1">Create your first project to start organizing work</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 stagger-children">
|
|
{filtered.map(project => (
|
|
<ProjectCard key={project._id} project={project} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create Modal */}
|
|
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Create New Project" size="md">
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
|
|
<input
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={e => setFormData(f => ({ ...f, name: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
placeholder="Project name"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
|
|
<textarea
|
|
value={formData.description}
|
|
onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
|
|
rows={3}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
|
placeholder="Project description..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">Brand</label>
|
|
<select
|
|
value={formData.brand_id}
|
|
onChange={e => setFormData(f => ({ ...f, brand_id: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
>
|
|
<option value="">Select brand</option>
|
|
{brands.map(b => <option key={b._id} value={b._id}>{b.icon} {b.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
|
|
<select
|
|
value={formData.status}
|
|
onChange={e => setFormData(f => ({ ...f, status: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
>
|
|
<option value="active">Active</option>
|
|
<option value="paused">Paused</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="cancelled">Cancelled</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">Owner</label>
|
|
<select
|
|
value={formData.owner_id}
|
|
onChange={e => setFormData(f => ({ ...f, owner_id: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
>
|
|
<option value="">Unassigned</option>
|
|
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
|
|
<input
|
|
type="date"
|
|
value={formData.due_date}
|
|
onChange={e => setFormData(f => ({ ...f, due_date: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
|
<button
|
|
onClick={() => setShowModal(false)}
|
|
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleCreate}
|
|
disabled={!formData.name}
|
|
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
|
>
|
|
Create Project
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|