270 lines
11 KiB
JavaScript
270 lines
11 KiB
JavaScript
import { useState, useEffect, useContext } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { Plus, Search, FolderKanban, LayoutGrid, GanttChart } 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'
|
|
import InteractiveTimeline from '../components/InteractiveTimeline'
|
|
import { SkeletonCard } from '../components/SkeletonLoader'
|
|
|
|
const EMPTY_PROJECT = {
|
|
name: '', description: '', brand_id: '', status: 'active',
|
|
owner_id: '', start_date: '', due_date: '',
|
|
}
|
|
|
|
export default function Projects() {
|
|
const navigate = useNavigate()
|
|
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('')
|
|
const [view, setView] = useState('timeline') // 'grid' | 'timeline'
|
|
|
|
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,
|
|
start_date: formData.start_date || null,
|
|
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="space-y-4">
|
|
<div className="h-12 bg-surface-tertiary rounded-xl animate-pulse"></div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{[...Array(6)].map((_, i) => <SkeletonCard key={i} />)}
|
|
</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>
|
|
|
|
{/* View switcher */}
|
|
<div className="flex items-center gap-1 bg-surface-tertiary rounded-lg p-0.5">
|
|
{[
|
|
{ id: 'grid', icon: LayoutGrid, label: 'Grid' },
|
|
{ id: 'timeline', icon: GanttChart, label: 'Timeline' },
|
|
].map(v => (
|
|
<button
|
|
key={v.id}
|
|
onClick={() => setView(v.id)}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
|
view === v.id ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
|
|
}`}
|
|
>
|
|
<v.icon className="w-4 h-4" />
|
|
{v.label}
|
|
</button>
|
|
))}
|
|
</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>
|
|
|
|
{/* Content */}
|
|
{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>
|
|
) : view === 'grid' ? (
|
|
<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>
|
|
) : (
|
|
<InteractiveTimeline
|
|
items={filtered}
|
|
mapItem={(project) => ({
|
|
id: project._id || project.id,
|
|
label: project.name,
|
|
description: project.description,
|
|
startDate: project.startDate || project.start_date || project.createdAt,
|
|
endDate: project.dueDate || project.due_date,
|
|
status: project.status,
|
|
priority: project.priority,
|
|
assigneeName: project.ownerName || project.owner_name,
|
|
thumbnailUrl: project.thumbnail_url || project.thumbnailUrl,
|
|
tags: [project.status, project.priority].filter(Boolean),
|
|
})}
|
|
onDateChange={async (projectId, { startDate, endDate }) => {
|
|
try {
|
|
await api.patch(`/projects/${projectId}`, { start_date: startDate, due_date: endDate })
|
|
} catch (err) {
|
|
console.error('Timeline date update failed:', err)
|
|
} finally {
|
|
loadProjects()
|
|
}
|
|
}}
|
|
onItemClick={(project) => {
|
|
navigate(`/projects/${project._id || project.id}`)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 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">Start Date</label>
|
|
<input
|
|
type="date"
|
|
value={formData.start_date}
|
|
onChange={e => setFormData(f => ({ ...f, start_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>
|
|
<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 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>
|
|
)
|
|
}
|