Files
marketing-app/client/src/pages/Projects.jsx
fahed d15e54044e Campaign assignments, ownership-based editing, and role-scoped data
- 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>
2026-02-09 13:59:40 +03:00

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>
)
}