Files
HarborForge.Frontend/src/pages/ProjectDetailPage.tsx
zhi 01affdb020 refactor: rename Issue → Task throughout frontend
- Rename files: IssuesPage → TasksPage, IssueDetailPage → TaskDetailPage,
  CreateIssuePage → CreateTaskPage
- Rename TypeScript interface: Issue → Task (keep backend field names)
- Update routes: /issues → /tasks, /issues/new → /tasks/new, /issues/:id → /tasks/:id
- Update CSS class names: issue-* → task-*, create-issue → create-task
- Update UI text: 'Issues' → 'Tasks', 'Create Issue' → 'Create Task'
- Keep 'issue' as a task subtype value in TASK_TYPES dropdown
- Keep all backend API endpoint paths unchanged (/issues, /comments, etc.)
- Rename local Task interface in MilestoneDetailPage to MilestoneTask
  to avoid conflict with the global Task type
2026-03-16 07:47:58 +00:00

199 lines
9.5 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 } 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'
export default function ProjectDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const [project, setProject] = useState<Project | null>(null)
const [members, setMembers] = useState<ProjectMember[]>([])
const [milestones, setMilestones] = useState<Milestone[]>([])
const [allProjects, setAllProjects] = useState<Project[]>([])
const [showAddMember, setShowAddMember] = useState(false)
const [showAddMilestone, setShowAddMilestone] = useState(false)
const [newMemberUserId, setNewMemberUserId] = useState(1)
const [newMemberRole, setNewMemberRole] = useState('developer')
const [newMilestoneTitle, setNewMilestoneTitle] = useState('')
const [users, setUsers] = useState<any[]>([])
const [roles, setRoles] = useState<any[]>([])
const [editing, setEditing] = useState(false)
const [editForm, setEditForm] = useState({ owner: '', repo: '', description: '', sub_projects: [] as string[], related_projects: [] as string[] })
useEffect(() => {
api.get<Project>(`/projects/${id}`).then(({ data }) => {
setProject(data)
setEditForm({
owner: data.owner_name || data.owner || '',
repo: data.repo || '',
description: data.description || '',
sub_projects: data.sub_projects || [],
related_projects: data.related_projects || [],
})
})
api.get<ProjectMember[]>(`/projects/${id}/members`).then(({ data }) => setMembers(data))
api.get<Milestone[]>(`/milestones?project_id=${id}`).then(({ data }) => setMilestones(data))
api.get<Project[]>('/projects').then(({ data }) => setAllProjects(data))
api.get('/users').then(r => setUsers(r.data)).catch(() => {})
api.get('/roles').then(r => setRoles(r.data)).catch(() => {})
api.get('/users').then(r => setUsers(r.data)).catch(() => {})
}, [id])
const handleMulti = (e: React.ChangeEvent<HTMLSelectElement>, field: 'sub_projects' | 'related_projects') => {
const values = Array.from(e.target.selectedOptions).map((o) => o.value)
setEditForm({ ...editForm, [field]: values })
}
const updateProject = async (e: React.FormEvent) => {
e.preventDefault()
const { data } = await api.patch<Project>(`/projects/${id}`, editForm)
setProject(data)
setEditing(false)
}
const addMember = async () => {
if (!newMemberUserId) return
await api.post(`/projects/${id}/members`, { user_id: newMemberUserId, role: newMemberRole })
setShowAddMember(false)
api.get<ProjectMember[]>(`/projects/${id}/members`).then(({ data }) => setMembers(data))
}
const removeMember = async (userId: number, role: string) => {
// Prevent removing owner
if (role === 'admin') {
alert('Cannot remove project owner (admin)')
return
}
if (!confirm('Remove this member?')) return
await api.delete(`/projects/${id}/members/${userId}`)
api.get<ProjectMember[]>(`/projects/${id}/members`).then(({ data }) => setMembers(data))
}
const addMilestone = async () => {
if (!newMilestoneTitle.trim()) return
await api.post(`/projects/${id}/milestones`, { title: newMilestoneTitle, status: 'open' })
setShowAddMilestone(false)
setNewMilestoneTitle('')
api.get<Milestone[]>(`/projects/${id}/milestones`).then(({ data }) => setMilestones(data)).catch(() => {})
}
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>
const selectableProjects = allProjects.filter((p) => p.id !== project.id && p.project_code)
return (
<div className="project-detail">
<button className="btn-back" onClick={() => navigate('/projects')}> Back to projects</button>
<div className="task-header">
{editing ? (
<form className="inline-form" onSubmit={updateProject}>
<div style={{ fontWeight: 600 }}>{project.name}</div>
{project.project_code && <span className="badge">{project.project_code}</span>}
<select value={editForm.owner} onChange={(e) => setEditForm({ ...editForm, owner: e.target.value })}>
{users.map((u: any) => <option key={u.id} value={u.username}>{u.username} ({u.full_name})</option>)}
</select>
<input value={editForm.description} onChange={(e) => setEditForm({ ...editForm, description: e.target.value })} placeholder="Description" />
<input value={editForm.repo} onChange={(e) => setEditForm({ ...editForm, repo: e.target.value })} placeholder="Repository URL" />
<label>Sub-projects
<select multiple value={editForm.sub_projects} onChange={(e) => handleMulti(e, 'sub_projects')}>
{selectableProjects.map((p) => (
<option key={p.id} value={p.project_code || ''}>{p.project_code || p.name}</option>
))}
</select>
</label>
<label>Related projects
<select multiple value={editForm.related_projects} onChange={(e) => handleMulti(e, 'related_projects')}>
{selectableProjects.map((p) => (
<option key={p.id} value={p.project_code || ''}>{p.project_code || p.name}</option>
))}
</select>
</label>
<button type="submit" className="btn-primary">Save</button>
<button type="button" className="btn-back" onClick={() => setEditing(false)}>Cancel</button>
</form>
) : (
<>
<h2>📁 {project.name} {project.project_code && <span className="badge">{project.project_code}</span>}</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 || project.owner || "Unknown"}</div>
<button className="btn-transition" style={{ marginTop: 8 }} onClick={() => setEditing(true)}>Edit</button>
<button className="btn-danger" style={{ marginLeft: 8 }} onClick={deleteProject}>Delete</button>
</>
)}
</div>
<div className="section">
<h3>Members ({members.length}) <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}}>
{`User #${m.user_id} (${m.role})`}
<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}) <button className="btn-sm" onClick={() => setShowAddMilestone(true)}>+ New</button></h3>
{milestones.map((ms) => (
<div key={ms.id} className="milestone-item" onClick={() => navigate(`/milestones/${ms.id}`)}>
<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>
{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>
)}
{showAddMilestone && (
<div className="modal-overlay" onClick={() => setShowAddMilestone(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h3>New Milestone</h3>
<input value={newMilestoneTitle} onChange={e => setNewMilestoneTitle(e.target.value)} placeholder="Milestone title" />
<div style={{marginTop: 10}}>
<button className="btn-primary" onClick={addMilestone}>Create</button>
<button className="btn-back" onClick={() => setShowAddMilestone(false)}>Cancel</button>
</div>
</div>
</div>
)}
</div>
)
}