HarborForge.Frontend: proposal/essential tests on current branch head #11
11
src/App.tsx
11
src/App.tsx
@@ -14,8 +14,8 @@ import MilestoneDetailPage from '@/pages/MilestoneDetailPage'
|
||||
import NotificationsPage from '@/pages/NotificationsPage'
|
||||
import RoleEditorPage from '@/pages/RoleEditorPage'
|
||||
import MonitorPage from '@/pages/MonitorPage'
|
||||
import ProposesPage from '@/pages/ProposesPage'
|
||||
import ProposeDetailPage from '@/pages/ProposeDetailPage'
|
||||
import ProposalsPage from '@/pages/ProposalsPage'
|
||||
import ProposalDetailPage from '@/pages/ProposalDetailPage'
|
||||
import UsersPage from '@/pages/UsersPage'
|
||||
import SupportDetailPage from '@/pages/SupportDetailPage'
|
||||
import MeetingDetailPage from '@/pages/MeetingDetailPage'
|
||||
@@ -116,8 +116,11 @@ export default function App() {
|
||||
<Route path="/projects/:id" element={<ProjectDetailPage />} />
|
||||
<Route path="/milestones" element={<MilestonesPage />} />
|
||||
<Route path="/milestones/:id" element={<MilestoneDetailPage />} />
|
||||
<Route path="/proposes" element={<ProposesPage />} />
|
||||
<Route path="/proposes/:id" element={<ProposeDetailPage />} />
|
||||
<Route path="/proposals" element={<ProposalsPage />} />
|
||||
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
||||
{/* Legacy routes for backward compatibility */}
|
||||
<Route path="/proposes" element={<ProposalsPage />} />
|
||||
<Route path="/proposes/:id" element={<ProposalDetailPage />} />
|
||||
<Route path="/meetings/:meetingId" element={<MeetingDetailPage />} />
|
||||
<Route path="/supports/:supportId" element={<SupportDetailPage />} />
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import api from '@/services/api'
|
||||
import type { Milestone, Project, Task } from '@/types'
|
||||
|
||||
const TASK_TYPES = [
|
||||
{ value: 'story', label: 'Story', subtypes: ['improvement', 'refactor'] }, // P9.6: 'feature' removed — must come from propose accept
|
||||
{ value: 'story', label: 'Story', subtypes: ['improvement', 'refactor'] }, // P9.6: 'feature' removed — must come from proposal accept
|
||||
{ value: 'issue', label: 'Issue', subtypes: ['infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'] },
|
||||
// P7.1: 'task' type removed — defect subtype migrated to issue/defect
|
||||
{ value: 'test', label: 'Test', subtypes: ['regression', 'security', 'smoke', 'stress'] },
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function Sidebar({ user, onLogout }: Props) {
|
||||
const links = user ? [
|
||||
{ to: '/', icon: '📊', label: 'Dashboard' },
|
||||
{ to: '/projects', icon: '📁', label: 'Projects' },
|
||||
{ to: '/proposes', icon: '💡', label: 'Proposes' },
|
||||
{ to: '/proposals', icon: '💡', label: 'Proposals' },
|
||||
{ to: '/notifications', icon: '🔔', label: 'Notifications' + (unreadCount > 0 ? ' (' + unreadCount + ')' : '') },
|
||||
{ to: '/monitor', icon: '📡', label: 'Monitor' },
|
||||
...(user.is_admin ? [
|
||||
|
||||
@@ -4,7 +4,7 @@ import api from '@/services/api'
|
||||
import type { Project, Milestone } from '@/types'
|
||||
|
||||
const TASK_TYPES = [
|
||||
{ value: 'story', label: 'Story', subtypes: ['improvement', 'refactor'] }, // P9.6: 'feature' removed — must come from propose accept
|
||||
{ value: 'story', label: 'Story', subtypes: ['improvement', 'refactor'] }, // P9.6: 'feature' removed — must come from proposal accept
|
||||
{ value: 'issue', label: 'Issue', subtypes: ['infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'] },
|
||||
// P7.1: 'task' type removed — defect subtype migrated to issue/defect
|
||||
{ value: 'test', label: 'Test', subtypes: ['regression', 'security', 'smoke', 'stress'] },
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import api from '@/services/api'
|
||||
import type { Propose, Milestone } from '@/types'
|
||||
import type { Proposal, Milestone } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
import CopyableCode from '@/components/CopyableCode'
|
||||
|
||||
export default function ProposeDetailPage() {
|
||||
export default function ProposalDetailPage() {
|
||||
const { id } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const projectId = searchParams.get('project_id')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [propose, setPropose] = useState<Propose | null>(null)
|
||||
const [proposal, setProposal] = useState<Proposal | null>(null)
|
||||
const [milestones, setMilestones] = useState<Milestone[]>([])
|
||||
const [showAccept, setShowAccept] = useState(false)
|
||||
const [selectedMilestone, setSelectedMilestone] = useState<number | ''>('')
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Edit state (P10.7)
|
||||
// Edit state
|
||||
const [showEdit, setShowEdit] = useState(false)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [editDescription, setEditDescription] = useState('')
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
|
||||
const fetchPropose = () => {
|
||||
const fetchProposal = () => {
|
||||
if (!projectId) return
|
||||
api.get<Propose>(`/projects/${projectId}/proposes/${id}`).then(({ data }) => setPropose(data))
|
||||
api.get<Proposal>(`/projects/${projectId}/proposals/${id}`).then(({ data }) => setProposal(data))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPropose()
|
||||
fetchProposal()
|
||||
}, [id, projectId])
|
||||
|
||||
const loadMilestones = () => {
|
||||
@@ -44,9 +44,9 @@ export default function ProposeDetailPage() {
|
||||
setActionLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.post(`/projects/${projectId}/proposes/${id}/accept`, { milestone_id: selectedMilestone })
|
||||
await api.post(`/projects/${projectId}/proposals/${id}/accept`, { milestone_id: selectedMilestone })
|
||||
setShowAccept(false)
|
||||
fetchPropose()
|
||||
fetchProposal()
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Accept failed')
|
||||
} finally {
|
||||
@@ -60,8 +60,8 @@ export default function ProposeDetailPage() {
|
||||
setActionLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.post(`/projects/${projectId}/proposes/${id}/reject`, { reason: reason || undefined })
|
||||
fetchPropose()
|
||||
await api.post(`/projects/${projectId}/proposals/${id}/reject`, { reason: reason || undefined })
|
||||
fetchProposal()
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Reject failed')
|
||||
} finally {
|
||||
@@ -70,9 +70,9 @@ export default function ProposeDetailPage() {
|
||||
}
|
||||
|
||||
const openEditModal = () => {
|
||||
if (!propose) return
|
||||
setEditTitle(propose.title)
|
||||
setEditDescription(propose.description || '')
|
||||
if (!proposal) return
|
||||
setEditTitle(proposal.title)
|
||||
setEditDescription(proposal.description || '')
|
||||
setError('')
|
||||
setShowEdit(true)
|
||||
}
|
||||
@@ -82,12 +82,12 @@ export default function ProposeDetailPage() {
|
||||
setEditLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.patch(`/projects/${projectId}/proposes/${id}`, {
|
||||
await api.patch(`/projects/${projectId}/proposals/${id}`, {
|
||||
title: editTitle,
|
||||
description: editDescription,
|
||||
})
|
||||
setShowEdit(false)
|
||||
fetchPropose()
|
||||
fetchProposal()
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Update failed')
|
||||
} finally {
|
||||
@@ -100,8 +100,8 @@ export default function ProposeDetailPage() {
|
||||
setActionLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.post(`/projects/${projectId}/proposes/${id}/reopen`)
|
||||
fetchPropose()
|
||||
await api.post(`/projects/${projectId}/proposals/${id}/reopen`)
|
||||
fetchProposal()
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Reopen failed')
|
||||
} finally {
|
||||
@@ -109,7 +109,7 @@ export default function ProposeDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!propose) return <div className="loading">Loading...</div>
|
||||
if (!proposal) return <div className="loading">Loading...</div>
|
||||
|
||||
const statusBadgeClass = (s: string) => {
|
||||
if (s === 'open') return 'status-open'
|
||||
@@ -124,11 +124,11 @@ export default function ProposeDetailPage() {
|
||||
|
||||
<div className="task-header">
|
||||
<h2>
|
||||
💡 {propose.title}
|
||||
{propose.propose_code && <span style={{ marginLeft: 8 }}><CopyableCode code={propose.propose_code} /></span>}
|
||||
💡 {proposal.title}
|
||||
{proposal.proposal_code && <span style={{ marginLeft: 8 }}><CopyableCode code={proposal.proposal_code} /></span>}
|
||||
</h2>
|
||||
<span className={`badge ${statusBadgeClass(propose.status)}`} style={{ fontSize: '1rem' }}>
|
||||
{propose.status}
|
||||
<span className={`badge ${statusBadgeClass(proposal.status)}`} style={{ fontSize: '1rem' }}>
|
||||
{proposal.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -137,23 +137,45 @@ export default function ProposeDetailPage() {
|
||||
<div className="section">
|
||||
<h3>Details</h3>
|
||||
<div className="detail-grid">
|
||||
<div><strong>Propose Code:</strong> {propose.propose_code ? <CopyableCode code={propose.propose_code} /> : '—'}</div>
|
||||
<div><strong>Status:</strong> {propose.status}</div>
|
||||
<div><strong>Created By:</strong> {propose.created_by_username || (propose.created_by_id ? `User #${propose.created_by_id}` : '—')}</div>
|
||||
<div><strong>Created:</strong> {dayjs(propose.created_at).format('YYYY-MM-DD HH:mm')}</div>
|
||||
<div><strong>Updated:</strong> {propose.updated_at ? dayjs(propose.updated_at).format('YYYY-MM-DD HH:mm') : '—'}</div>
|
||||
<div><strong>Feature Task:</strong> {propose.feat_task_id || '—'}</div>
|
||||
<div><strong>Proposal Code:</strong> {proposal.proposal_code ? <CopyableCode code={proposal.proposal_code} /> : '—'}</div>
|
||||
<div><strong>Status:</strong> {proposal.status}</div>
|
||||
<div><strong>Created By:</strong> {proposal.created_by_username || (proposal.created_by_id ? `User #${proposal.created_by_id}` : '—')}</div>
|
||||
<div><strong>Created:</strong> {dayjs(proposal.created_at).format('YYYY-MM-DD HH:mm')}</div>
|
||||
<div><strong>Updated:</strong> {proposal.updated_at ? dayjs(proposal.updated_at).format('YYYY-MM-DD HH:mm') : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3>Description</h3>
|
||||
<p style={{ whiteSpace: 'pre-wrap' }}>{propose.description || 'No description'}</p>
|
||||
<p style={{ whiteSpace: 'pre-wrap' }}>{proposal.description || 'No description'}</p>
|
||||
</div>
|
||||
|
||||
{/* Generated tasks (shown when accepted) */}
|
||||
{proposal.status === 'accepted' && proposal.generated_tasks && proposal.generated_tasks.length > 0 && (
|
||||
<div className="section">
|
||||
<h3>Generated Tasks</h3>
|
||||
<div className="milestone-grid">
|
||||
{proposal.generated_tasks.map((gt) => (
|
||||
<div
|
||||
key={gt.task_id}
|
||||
className="milestone-card"
|
||||
onClick={() => navigate(`/tasks/${gt.task_code || gt.task_id}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="milestone-card-header">
|
||||
<span className="badge">{gt.task_type}/{gt.task_subtype}</span>
|
||||
{gt.task_code && <span className="badge">{gt.task_code}</span>}
|
||||
<h3>{gt.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="section" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{propose.status === 'open' && (
|
||||
{proposal.status === 'open' && (
|
||||
<>
|
||||
<button
|
||||
className="btn-transition"
|
||||
@@ -177,12 +199,7 @@ export default function ProposeDetailPage() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{propose.status === 'accepted' && propose.feat_task_id && (
|
||||
<button className="btn-transition" onClick={() => navigate(`/tasks/${propose.feat_task_id}`)}>
|
||||
📋 View Generated Task
|
||||
</button>
|
||||
)}
|
||||
{propose.status === 'rejected' && (
|
||||
{proposal.status === 'rejected' && (
|
||||
<button
|
||||
className="btn-transition"
|
||||
disabled={actionLoading}
|
||||
@@ -193,11 +210,11 @@ export default function ProposeDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit modal (P10.7 — only reachable when open) */}
|
||||
{/* Edit modal */}
|
||||
{showEdit && (
|
||||
<div className="modal-overlay" onClick={() => setShowEdit(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Edit Propose</h3>
|
||||
<h3>Edit Proposal</h3>
|
||||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||
<strong>Title</strong>
|
||||
<input
|
||||
@@ -234,8 +251,8 @@ export default function ProposeDetailPage() {
|
||||
{showAccept && (
|
||||
<div className="modal-overlay" onClick={() => setShowAccept(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Accept Propose</h3>
|
||||
<p>Select an <strong>open</strong> milestone to create a feature story task in:</p>
|
||||
<h3>Accept Proposal</h3>
|
||||
<p>Select an <strong>open</strong> milestone to generate story tasks in:</p>
|
||||
<select
|
||||
value={selectedMilestone}
|
||||
onChange={(e) => setSelectedMilestone(Number(e.target.value))}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '@/services/api'
|
||||
import type { Propose, Project } from '@/types'
|
||||
import type { Proposal, Project } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export default function ProposesPage() {
|
||||
const [proposes, setProposes] = useState<Propose[]>([])
|
||||
export default function ProposalsPage() {
|
||||
const [proposals, setProposals] = useState<Proposal[]>([])
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [projectFilter, setProjectFilter] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
@@ -14,32 +14,32 @@ export default function ProposesPage() {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const fetchProposes = () => {
|
||||
const fetchProposals = () => {
|
||||
if (!projectFilter) {
|
||||
setProposes([])
|
||||
setProposals([])
|
||||
return
|
||||
}
|
||||
api.get<Propose[]>(`/projects/${projectFilter}/proposes`).then(({ data }) => setProposes(data))
|
||||
api.get<Proposal[]>(`/projects/${projectFilter}/proposals`).then(({ data }) => setProposals(data))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Project[]>('/projects').then(({ data }) => setProjects(data))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchProposes() }, [projectFilter])
|
||||
useEffect(() => { fetchProposals() }, [projectFilter])
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newTitle.trim() || !projectFilter) return
|
||||
setCreating(true)
|
||||
try {
|
||||
await api.post(`/projects/${projectFilter}/proposes`, {
|
||||
await api.post(`/projects/${projectFilter}/proposals`, {
|
||||
title: newTitle.trim(),
|
||||
description: newDesc.trim() || null,
|
||||
})
|
||||
setNewTitle('')
|
||||
setNewDesc('')
|
||||
setShowCreate(false)
|
||||
fetchProposes()
|
||||
fetchProposals()
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
@@ -55,9 +55,9 @@ export default function ProposesPage() {
|
||||
return (
|
||||
<div className="milestones-page">
|
||||
<div className="page-header">
|
||||
<h2>💡 Proposes ({proposes.length})</h2>
|
||||
<h2>💡 Proposals ({proposals.length})</h2>
|
||||
<button className="btn-primary" disabled={!projectFilter} onClick={() => setShowCreate(true)}>
|
||||
+ New Propose
|
||||
+ New Proposal
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -68,32 +68,31 @@ export default function ProposesPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!projectFilter && <p className="empty">Please select a project to view proposes.</p>}
|
||||
{!projectFilter && <p className="empty">Please select a project to view proposals.</p>}
|
||||
|
||||
<div className="milestone-grid">
|
||||
{proposes.map((pr) => (
|
||||
<div key={pr.id} className="milestone-card" onClick={() => navigate(`/proposes/${pr.propose_code || pr.id}?project_id=${pr.project_id}`)}>
|
||||
{proposals.map((pr) => (
|
||||
<div key={pr.id} className="milestone-card" onClick={() => navigate(`/proposals/${pr.proposal_code || pr.id}?project_id=${pr.project_id}`)}>
|
||||
<div className="milestone-card-header">
|
||||
<span className={`badge ${statusBadgeClass(pr.status)}`}>{pr.status}</span>
|
||||
{pr.propose_code && <span className="badge">{pr.propose_code}</span>}
|
||||
{pr.proposal_code && <span className="badge">{pr.proposal_code}</span>}
|
||||
<h3>{pr.title}</h3>
|
||||
</div>
|
||||
<p className="project-desc">{pr.description || 'No description'}</p>
|
||||
<div className="project-meta">
|
||||
{pr.feat_task_id && <span>Task: {pr.feat_task_id}</span>}
|
||||
<span>Created {dayjs(pr.created_at).format('YYYY-MM-DD')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{projectFilter && proposes.length === 0 && <p className="empty">No proposes</p>}
|
||||
{projectFilter && proposals.length === 0 && <p className="empty">No proposals</p>}
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>New Propose</h3>
|
||||
<h3>New Proposal</h3>
|
||||
<label>Title</label>
|
||||
<input value={newTitle} onChange={(e) => setNewTitle(e.target.value)} placeholder="Propose title" />
|
||||
<input value={newTitle} onChange={(e) => setNewTitle(e.target.value)} placeholder="Proposal title" />
|
||||
<label>Description</label>
|
||||
<textarea value={newDesc} onChange={(e) => setNewDesc(e.target.value)} placeholder="Description (optional)" rows={4} />
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
||||
@@ -127,9 +127,9 @@ export interface DashboardStats {
|
||||
recent_tasks: Task[]
|
||||
}
|
||||
|
||||
export interface Propose {
|
||||
export interface Proposal {
|
||||
id: number
|
||||
propose_code: string | null
|
||||
proposal_code: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
status: 'open' | 'accepted' | 'rejected'
|
||||
@@ -137,10 +137,22 @@ export interface Propose {
|
||||
created_by_id: number | null
|
||||
created_by_username: string | null
|
||||
feat_task_id: string | null
|
||||
generated_tasks: GeneratedTask[] | null
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
/** @deprecated Use Proposal instead */
|
||||
export type Propose = Proposal
|
||||
|
||||
export interface GeneratedTask {
|
||||
task_id: number
|
||||
task_code: string | null
|
||||
title: string
|
||||
task_type: string
|
||||
task_subtype: string | null
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
token_type: string
|
||||
|
||||
Reference in New Issue
Block a user