164 lines
6.7 KiB
TypeScript
164 lines
6.7 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react'
|
||
import { useParams, useNavigate } from 'react-router-dom'
|
||
import api from '@/services/api'
|
||
import type { Project, ProjectMember, Milestone } from '@/types'
|
||
import dayjs from 'dayjs'
|
||
import { useAuth } from '@/hooks/useAuth'
|
||
import ProjectFormModal from '@/components/ProjectFormModal'
|
||
import MilestoneFormModal from '@/components/MilestoneFormModal'
|
||
import CopyableCode from '@/components/CopyableCode'
|
||
|
||
export default function ProjectDetailPage() {
|
||
const { id } = useParams()
|
||
const navigate = useNavigate()
|
||
const { user } = useAuth()
|
||
const [project, setProject] = useState<Project | null>(null)
|
||
const [members, setMembers] = useState<ProjectMember[]>([])
|
||
const [milestones, setMilestones] = useState<Milestone[]>([])
|
||
const [showAddMember, setShowAddMember] = useState(false)
|
||
const [showMilestoneModal, setShowMilestoneModal] = useState(false)
|
||
const [showProjectEdit, setShowProjectEdit] = useState(false)
|
||
const [newMemberUserId, setNewMemberUserId] = useState(1)
|
||
const [newMemberRole, setNewMemberRole] = useState('developer')
|
||
const [users, setUsers] = useState<any[]>([])
|
||
const [roles, setRoles] = useState<any[]>([])
|
||
|
||
const fetchProject = () => {
|
||
api.get<Project>(`/projects/${id}`).then(({ data }) => setProject(data))
|
||
api.get<ProjectMember[]>(`/projects/${id}/members`).then(({ data }) => setMembers(data))
|
||
api.get<Milestone[]>(`/projects/${id}/milestones`).then(({ data }) => setMilestones(data))
|
||
}
|
||
|
||
useEffect(() => {
|
||
fetchProject()
|
||
api.get('/users').then(r => setUsers(r.data)).catch(() => {})
|
||
api.get('/roles').then(r => setRoles(r.data)).catch(() => {})
|
||
}, [id])
|
||
|
||
const currentMemberRole = useMemo(
|
||
() => members.find((m) => m.user_id === user?.id)?.role,
|
||
[members, user?.id]
|
||
)
|
||
const canEditProject = Boolean(project && user && (user.is_admin || user.id === project.owner_id || currentMemberRole === 'admin'))
|
||
|
||
const addMember = async () => {
|
||
if (!newMemberUserId) return
|
||
await api.post(`/projects/${id}/members`, { user_id: newMemberUserId, role: newMemberRole })
|
||
setShowAddMember(false)
|
||
fetchProject()
|
||
}
|
||
|
||
const removeMember = async (userId: number, role: string) => {
|
||
if (role === 'admin') {
|
||
alert('Cannot remove project owner (admin)')
|
||
return
|
||
}
|
||
if (!confirm('Remove this member?')) return
|
||
await api.delete(`/projects/${id}/members/${userId}`)
|
||
fetchProject()
|
||
}
|
||
|
||
const deleteProject = async () => {
|
||
const confirmName = prompt(`Type the project name "${project?.name}" to confirm deletion:`)
|
||
if (confirmName !== project?.name) {
|
||
alert('Project name does not match. Deletion cancelled.')
|
||
return
|
||
}
|
||
await api.delete(`/projects/${id}`)
|
||
navigate('/projects')
|
||
}
|
||
|
||
if (!project) return <div className="loading">Loading...</div>
|
||
|
||
return (
|
||
<div className="project-detail">
|
||
<button className="btn-back" onClick={() => navigate('/projects')}>← Back to projects</button>
|
||
|
||
<div className="task-header">
|
||
<h2>📁 {project.name} {project.project_code && <CopyableCode code={project.project_code} />}</h2>
|
||
<p style={{ color: 'var(--text-dim)', marginTop: 4 }}>{project.description || 'No description'}</p>
|
||
{project.repo && <p style={{ color: 'var(--text-dim)', marginTop: 4 }}>📦 {project.repo}</p>}
|
||
<div className="text-dim">Owner: {project.owner_name || 'Unknown'}</div>
|
||
{canEditProject && (
|
||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||
<button className="btn-transition" onClick={() => setShowProjectEdit(true)}>Edit</button>
|
||
<button className="btn-danger" onClick={deleteProject}>Delete</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="section">
|
||
<h3>Members ({members.length}) {canEditProject && <button className="btn-sm" onClick={() => setShowAddMember(true)}>+ Add</button>}</h3>
|
||
{members.length > 0 ? (
|
||
<div className="member-list">
|
||
{members.map((m) => (
|
||
<span key={m.id} className="badge" style={{ marginRight: 8 }}>
|
||
{`${m.username || m.full_name || `User #${m.user_id}`} (${m.role})`}
|
||
{canEditProject && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); removeMember(m.user_id, m.role) }}
|
||
style={{ marginLeft: 8, background: 'none', border: 'none', color: 'red', cursor: 'pointer' }}
|
||
>×</button>
|
||
)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="empty">No members</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="section">
|
||
<h3>
|
||
Milestones ({milestones.length})
|
||
{canEditProject && <button className="btn-sm" onClick={() => setShowMilestoneModal(true)}>+ New</button>}
|
||
</h3>
|
||
{milestones.map((ms) => (
|
||
<div key={ms.id} className="milestone-item" onClick={() => ms.milestone_code && navigate(`/milestones/${ms.milestone_code}`)}>
|
||
<span className={`badge status-${ms.status === 'open' ? 'open' : ms.status === 'closed' ? 'closed' : 'in_progress'}`}>{ms.status}</span>
|
||
<span className="milestone-title">{ms.title}</span>
|
||
{ms.due_date && <span className="text-dim"> · Due {dayjs(ms.due_date).format('YYYY-MM-DD')}</span>}
|
||
</div>
|
||
))}
|
||
{milestones.length === 0 && <p className="empty">No milestones</p>}
|
||
</div>
|
||
|
||
<ProjectFormModal
|
||
isOpen={showProjectEdit}
|
||
onClose={() => setShowProjectEdit(false)}
|
||
project={project}
|
||
onSaved={(data) => {
|
||
setProject(data)
|
||
fetchProject()
|
||
}}
|
||
/>
|
||
|
||
<MilestoneFormModal
|
||
isOpen={showMilestoneModal}
|
||
onClose={() => setShowMilestoneModal(false)}
|
||
initialProjectCode={project.project_code || ''}
|
||
lockProject
|
||
onSaved={() => fetchProject()}
|
||
/>
|
||
|
||
{showAddMember && (
|
||
<div className="modal-overlay" onClick={() => setShowAddMember(false)}>
|
||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||
<h3>Add Member</h3>
|
||
<select value={newMemberUserId} onChange={e => setNewMemberUserId(Number(e.target.value))}>
|
||
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.full_name})</option>)}
|
||
</select>
|
||
<select value={newMemberRole} onChange={e => setNewMemberRole(e.target.value)}>
|
||
{roles.map(r => <option key={r.id} value={r.name}>{r.name}</option>)}
|
||
</select>
|
||
<div style={{ marginTop: 10 }}>
|
||
<button className="btn-primary" onClick={addMember}>Add</button>
|
||
<button className="btn-back" onClick={() => setShowAddMember(false)}>Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|