Files
HarborForge.Frontend/src/pages/ProjectDetailPage.tsx

164 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}