Marketing Hub: RBAC, i18n (AR/EN), tasks overhaul, team/user merge, tutorial
Features: - Full RBAC with 3 roles (superadmin/manager/contributor) - Ownership tracking on posts, tasks, campaigns, projects - Task system: assign to anyone, filter combobox, visibility scoping - Team members merged into users table (single source of truth) - Post thumbnails on kanban cards from attachments - Publication link validation before publishing - Interactive onboarding tutorial with Settings restart - Full Arabic/English i18n with RTL layout support - Language toggle in sidebar, IBM Plex Sans Arabic font - Brand-based visibility filtering for non-superadmins - Manager can only create contributors - Profile completion flow for new users - Cookie-based sessions (express-session + SQLite)
This commit is contained in:
202
client/src/pages/Projects.jsx
Normal file
202
client/src/pages/Projects.jsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, Search, FolderKanban } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { api } from '../utils/api'
|
||||
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 [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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user