FE-PR-001: Rename Propose -> Proposal across frontend

- Rename ProposesPage -> ProposalsPage, ProposeDetailPage -> ProposalDetailPage
- Update Propose type to Proposal (keep Propose as deprecated alias)
- Add GeneratedTask type for accept results
- Switch API calls from /proposes to /proposals (canonical)
- Update sidebar label: Proposes -> Proposals
- Update routes: /proposals (+ legacy /proposes compat)
- Update all UI text: Propose -> Proposal
- Remove feat_task_id display, add generated_tasks section
- Clean up propose references in comments
This commit is contained in:
zhi
2026-04-01 04:46:46 +00:00
parent 6c8c8b78b6
commit a08644dde3
7 changed files with 100 additions and 69 deletions

View File

@@ -14,8 +14,8 @@ import MilestoneDetailPage from '@/pages/MilestoneDetailPage'
import NotificationsPage from '@/pages/NotificationsPage' import NotificationsPage from '@/pages/NotificationsPage'
import RoleEditorPage from '@/pages/RoleEditorPage' import RoleEditorPage from '@/pages/RoleEditorPage'
import MonitorPage from '@/pages/MonitorPage' import MonitorPage from '@/pages/MonitorPage'
import ProposesPage from '@/pages/ProposesPage' import ProposalsPage from '@/pages/ProposalsPage'
import ProposeDetailPage from '@/pages/ProposeDetailPage' import ProposalDetailPage from '@/pages/ProposalDetailPage'
import UsersPage from '@/pages/UsersPage' import UsersPage from '@/pages/UsersPage'
import SupportDetailPage from '@/pages/SupportDetailPage' import SupportDetailPage from '@/pages/SupportDetailPage'
import MeetingDetailPage from '@/pages/MeetingDetailPage' import MeetingDetailPage from '@/pages/MeetingDetailPage'
@@ -116,8 +116,11 @@ export default function App() {
<Route path="/projects/:id" element={<ProjectDetailPage />} /> <Route path="/projects/:id" element={<ProjectDetailPage />} />
<Route path="/milestones" element={<MilestonesPage />} /> <Route path="/milestones" element={<MilestonesPage />} />
<Route path="/milestones/:id" element={<MilestoneDetailPage />} /> <Route path="/milestones/:id" element={<MilestoneDetailPage />} />
<Route path="/proposes" element={<ProposesPage />} /> <Route path="/proposals" element={<ProposalsPage />} />
<Route path="/proposes/:id" element={<ProposeDetailPage />} /> <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="/meetings/:meetingId" element={<MeetingDetailPage />} />
<Route path="/supports/:supportId" element={<SupportDetailPage />} /> <Route path="/supports/:supportId" element={<SupportDetailPage />} />
<Route path="/notifications" element={<NotificationsPage />} /> <Route path="/notifications" element={<NotificationsPage />} />

View File

@@ -3,7 +3,7 @@ import api from '@/services/api'
import type { Milestone, Project, Task } from '@/types' import type { Milestone, Project, Task } from '@/types'
const TASK_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'] }, { value: 'issue', label: 'Issue', subtypes: ['infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'] },
// P7.1: 'task' type removed — defect subtype migrated to issue/defect // P7.1: 'task' type removed — defect subtype migrated to issue/defect
{ value: 'test', label: 'Test', subtypes: ['regression', 'security', 'smoke', 'stress'] }, { value: 'test', label: 'Test', subtypes: ['regression', 'security', 'smoke', 'stress'] },

View File

@@ -32,7 +32,7 @@ export default function Sidebar({ user, onLogout }: Props) {
const links = user ? [ const links = user ? [
{ to: '/', icon: '📊', label: 'Dashboard' }, { to: '/', icon: '📊', label: 'Dashboard' },
{ to: '/projects', icon: '📁', label: 'Projects' }, { to: '/projects', icon: '📁', label: 'Projects' },
{ to: '/proposes', icon: '💡', label: 'Proposes' }, { to: '/proposals', icon: '💡', label: 'Proposals' },
{ to: '/notifications', icon: '🔔', label: 'Notifications' + (unreadCount > 0 ? ' (' + unreadCount + ')' : '') }, { to: '/notifications', icon: '🔔', label: 'Notifications' + (unreadCount > 0 ? ' (' + unreadCount + ')' : '') },
{ to: '/monitor', icon: '📡', label: 'Monitor' }, { to: '/monitor', icon: '📡', label: 'Monitor' },
...(user.is_admin ? [ ...(user.is_admin ? [

View File

@@ -4,7 +4,7 @@ import api from '@/services/api'
import type { Project, Milestone } from '@/types' import type { Project, Milestone } from '@/types'
const TASK_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'] }, { value: 'issue', label: 'Issue', subtypes: ['infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'] },
// P7.1: 'task' type removed — defect subtype migrated to issue/defect // P7.1: 'task' type removed — defect subtype migrated to issue/defect
{ value: 'test', label: 'Test', subtypes: ['regression', 'security', 'smoke', 'stress'] }, { value: 'test', label: 'Test', subtypes: ['regression', 'security', 'smoke', 'stress'] },

View File

@@ -1,36 +1,36 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import api from '@/services/api' import api from '@/services/api'
import type { Propose, Milestone } from '@/types' import type { Proposal, Milestone } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import CopyableCode from '@/components/CopyableCode' import CopyableCode from '@/components/CopyableCode'
export default function ProposeDetailPage() { export default function ProposalDetailPage() {
const { id } = useParams() const { id } = useParams()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const projectId = searchParams.get('project_id') const projectId = searchParams.get('project_id')
const navigate = useNavigate() const navigate = useNavigate()
const [propose, setPropose] = useState<Propose | null>(null) const [proposal, setProposal] = useState<Proposal | null>(null)
const [milestones, setMilestones] = useState<Milestone[]>([]) const [milestones, setMilestones] = useState<Milestone[]>([])
const [showAccept, setShowAccept] = useState(false) const [showAccept, setShowAccept] = useState(false)
const [selectedMilestone, setSelectedMilestone] = useState<number | ''>('') const [selectedMilestone, setSelectedMilestone] = useState<number | ''>('')
const [actionLoading, setActionLoading] = useState(false) const [actionLoading, setActionLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
// Edit state (P10.7) // Edit state
const [showEdit, setShowEdit] = useState(false) const [showEdit, setShowEdit] = useState(false)
const [editTitle, setEditTitle] = useState('') const [editTitle, setEditTitle] = useState('')
const [editDescription, setEditDescription] = useState('') const [editDescription, setEditDescription] = useState('')
const [editLoading, setEditLoading] = useState(false) const [editLoading, setEditLoading] = useState(false)
const fetchPropose = () => { const fetchProposal = () => {
if (!projectId) return 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(() => { useEffect(() => {
fetchPropose() fetchProposal()
}, [id, projectId]) }, [id, projectId])
const loadMilestones = () => { const loadMilestones = () => {
@@ -44,9 +44,9 @@ export default function ProposeDetailPage() {
setActionLoading(true) setActionLoading(true)
setError('') setError('')
try { 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) setShowAccept(false)
fetchPropose() fetchProposal()
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.detail || 'Accept failed') setError(err.response?.data?.detail || 'Accept failed')
} finally { } finally {
@@ -60,8 +60,8 @@ export default function ProposeDetailPage() {
setActionLoading(true) setActionLoading(true)
setError('') setError('')
try { try {
await api.post(`/projects/${projectId}/proposes/${id}/reject`, { reason: reason || undefined }) await api.post(`/projects/${projectId}/proposals/${id}/reject`, { reason: reason || undefined })
fetchPropose() fetchProposal()
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.detail || 'Reject failed') setError(err.response?.data?.detail || 'Reject failed')
} finally { } finally {
@@ -70,9 +70,9 @@ export default function ProposeDetailPage() {
} }
const openEditModal = () => { const openEditModal = () => {
if (!propose) return if (!proposal) return
setEditTitle(propose.title) setEditTitle(proposal.title)
setEditDescription(propose.description || '') setEditDescription(proposal.description || '')
setError('') setError('')
setShowEdit(true) setShowEdit(true)
} }
@@ -82,12 +82,12 @@ export default function ProposeDetailPage() {
setEditLoading(true) setEditLoading(true)
setError('') setError('')
try { try {
await api.patch(`/projects/${projectId}/proposes/${id}`, { await api.patch(`/projects/${projectId}/proposals/${id}`, {
title: editTitle, title: editTitle,
description: editDescription, description: editDescription,
}) })
setShowEdit(false) setShowEdit(false)
fetchPropose() fetchProposal()
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.detail || 'Update failed') setError(err.response?.data?.detail || 'Update failed')
} finally { } finally {
@@ -100,8 +100,8 @@ export default function ProposeDetailPage() {
setActionLoading(true) setActionLoading(true)
setError('') setError('')
try { try {
await api.post(`/projects/${projectId}/proposes/${id}/reopen`) await api.post(`/projects/${projectId}/proposals/${id}/reopen`)
fetchPropose() fetchProposal()
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.detail || 'Reopen failed') setError(err.response?.data?.detail || 'Reopen failed')
} finally { } 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) => { const statusBadgeClass = (s: string) => {
if (s === 'open') return 'status-open' if (s === 'open') return 'status-open'
@@ -124,11 +124,11 @@ export default function ProposeDetailPage() {
<div className="task-header"> <div className="task-header">
<h2> <h2>
💡 {propose.title} 💡 {proposal.title}
{propose.propose_code && <span style={{ marginLeft: 8 }}><CopyableCode code={propose.propose_code} /></span>} {proposal.proposal_code && <span style={{ marginLeft: 8 }}><CopyableCode code={proposal.proposal_code} /></span>}
</h2> </h2>
<span className={`badge ${statusBadgeClass(propose.status)}`} style={{ fontSize: '1rem' }}> <span className={`badge ${statusBadgeClass(proposal.status)}`} style={{ fontSize: '1rem' }}>
{propose.status} {proposal.status}
</span> </span>
</div> </div>
@@ -137,23 +137,45 @@ export default function ProposeDetailPage() {
<div className="section"> <div className="section">
<h3>Details</h3> <h3>Details</h3>
<div className="detail-grid"> <div className="detail-grid">
<div><strong>Propose Code:</strong> {propose.propose_code ? <CopyableCode code={propose.propose_code} /> : '—'}</div> <div><strong>Proposal Code:</strong> {proposal.proposal_code ? <CopyableCode code={proposal.proposal_code} /> : '—'}</div>
<div><strong>Status:</strong> {propose.status}</div> <div><strong>Status:</strong> {proposal.status}</div>
<div><strong>Created By:</strong> {propose.created_by_username || (propose.created_by_id ? `User #${propose.created_by_id}` : '—')}</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(propose.created_at).format('YYYY-MM-DD HH:mm')}</div> <div><strong>Created:</strong> {dayjs(proposal.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>Updated:</strong> {proposal.updated_at ? dayjs(proposal.updated_at).format('YYYY-MM-DD HH:mm') : '—'}</div>
<div><strong>Feature Task:</strong> {propose.feat_task_id || '—'}</div>
</div> </div>
</div> </div>
<div className="section"> <div className="section">
<h3>Description</h3> <h3>Description</h3>
<p style={{ whiteSpace: 'pre-wrap' }}>{propose.description || 'No description'}</p> <p style={{ whiteSpace: 'pre-wrap' }}>{proposal.description || 'No description'}</p>
</div> </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 */} {/* Action buttons */}
<div className="section" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> <div className="section" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{propose.status === 'open' && ( {proposal.status === 'open' && (
<> <>
<button <button
className="btn-transition" className="btn-transition"
@@ -177,12 +199,7 @@ export default function ProposeDetailPage() {
</button> </button>
</> </>
)} )}
{propose.status === 'accepted' && propose.feat_task_id && ( {proposal.status === 'rejected' && (
<button className="btn-transition" onClick={() => navigate(`/tasks/${propose.feat_task_id}`)}>
📋 View Generated Task
</button>
)}
{propose.status === 'rejected' && (
<button <button
className="btn-transition" className="btn-transition"
disabled={actionLoading} disabled={actionLoading}
@@ -193,11 +210,11 @@ export default function ProposeDetailPage() {
)} )}
</div> </div>
{/* Edit modal (P10.7 — only reachable when open) */} {/* Edit modal */}
{showEdit && ( {showEdit && (
<div className="modal-overlay" onClick={() => setShowEdit(false)}> <div className="modal-overlay" onClick={() => setShowEdit(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}> <div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>Edit Propose</h3> <h3>Edit Proposal</h3>
<label style={{ display: 'block', marginBottom: 8 }}> <label style={{ display: 'block', marginBottom: 8 }}>
<strong>Title</strong> <strong>Title</strong>
<input <input
@@ -234,8 +251,8 @@ export default function ProposeDetailPage() {
{showAccept && ( {showAccept && (
<div className="modal-overlay" onClick={() => setShowAccept(false)}> <div className="modal-overlay" onClick={() => setShowAccept(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}> <div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>Accept Propose</h3> <h3>Accept Proposal</h3>
<p>Select an <strong>open</strong> milestone to create a feature story task in:</p> <p>Select an <strong>open</strong> milestone to generate story tasks in:</p>
<select <select
value={selectedMilestone} value={selectedMilestone}
onChange={(e) => setSelectedMilestone(Number(e.target.value))} onChange={(e) => setSelectedMilestone(Number(e.target.value))}

View File

@@ -1,11 +1,11 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import api from '@/services/api' import api from '@/services/api'
import type { Propose, Project } from '@/types' import type { Proposal, Project } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
export default function ProposesPage() { export default function ProposalsPage() {
const [proposes, setProposes] = useState<Propose[]>([]) const [proposals, setProposals] = useState<Proposal[]>([])
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<Project[]>([])
const [projectFilter, setProjectFilter] = useState('') const [projectFilter, setProjectFilter] = useState('')
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
@@ -14,32 +14,32 @@ export default function ProposesPage() {
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const fetchProposes = () => { const fetchProposals = () => {
if (!projectFilter) { if (!projectFilter) {
setProposes([]) setProposals([])
return return
} }
api.get<Propose[]>(`/projects/${projectFilter}/proposes`).then(({ data }) => setProposes(data)) api.get<Proposal[]>(`/projects/${projectFilter}/proposals`).then(({ data }) => setProposals(data))
} }
useEffect(() => { useEffect(() => {
api.get<Project[]>('/projects').then(({ data }) => setProjects(data)) api.get<Project[]>('/projects').then(({ data }) => setProjects(data))
}, []) }, [])
useEffect(() => { fetchProposes() }, [projectFilter]) useEffect(() => { fetchProposals() }, [projectFilter])
const handleCreate = async () => { const handleCreate = async () => {
if (!newTitle.trim() || !projectFilter) return if (!newTitle.trim() || !projectFilter) return
setCreating(true) setCreating(true)
try { try {
await api.post(`/projects/${projectFilter}/proposes`, { await api.post(`/projects/${projectFilter}/proposals`, {
title: newTitle.trim(), title: newTitle.trim(),
description: newDesc.trim() || null, description: newDesc.trim() || null,
}) })
setNewTitle('') setNewTitle('')
setNewDesc('') setNewDesc('')
setShowCreate(false) setShowCreate(false)
fetchProposes() fetchProposals()
} finally { } finally {
setCreating(false) setCreating(false)
} }
@@ -55,9 +55,9 @@ export default function ProposesPage() {
return ( return (
<div className="milestones-page"> <div className="milestones-page">
<div className="page-header"> <div className="page-header">
<h2>💡 Proposes ({proposes.length})</h2> <h2>💡 Proposals ({proposals.length})</h2>
<button className="btn-primary" disabled={!projectFilter} onClick={() => setShowCreate(true)}> <button className="btn-primary" disabled={!projectFilter} onClick={() => setShowCreate(true)}>
+ New Propose + New Proposal
</button> </button>
</div> </div>
@@ -68,32 +68,31 @@ export default function ProposesPage() {
</select> </select>
</div> </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"> <div className="milestone-grid">
{proposes.map((pr) => ( {proposals.map((pr) => (
<div key={pr.id} className="milestone-card" onClick={() => navigate(`/proposes/${pr.propose_code || pr.id}?project_id=${pr.project_id}`)}> <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"> <div className="milestone-card-header">
<span className={`badge ${statusBadgeClass(pr.status)}`}>{pr.status}</span> <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> <h3>{pr.title}</h3>
</div> </div>
<p className="project-desc">{pr.description || 'No description'}</p> <p className="project-desc">{pr.description || 'No description'}</p>
<div className="project-meta"> <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> <span>Created {dayjs(pr.created_at).format('YYYY-MM-DD')}</span>
</div> </div>
</div> </div>
))} ))}
{projectFilter && proposes.length === 0 && <p className="empty">No proposes</p>} {projectFilter && proposals.length === 0 && <p className="empty">No proposals</p>}
</div> </div>
{showCreate && ( {showCreate && (
<div className="modal-overlay" onClick={() => setShowCreate(false)}> <div className="modal-overlay" onClick={() => setShowCreate(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}> <div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>New Propose</h3> <h3>New Proposal</h3>
<label>Title</label> <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> <label>Description</label>
<textarea value={newDesc} onChange={(e) => setNewDesc(e.target.value)} placeholder="Description (optional)" rows={4} /> <textarea value={newDesc} onChange={(e) => setNewDesc(e.target.value)} placeholder="Description (optional)" rows={4} />
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}> <div style={{ marginTop: 12, display: 'flex', gap: 8 }}>

View File

@@ -127,9 +127,9 @@ export interface DashboardStats {
recent_tasks: Task[] recent_tasks: Task[]
} }
export interface Propose { export interface Proposal {
id: number id: number
propose_code: string | null proposal_code: string | null
title: string title: string
description: string | null description: string | null
status: 'open' | 'accepted' | 'rejected' status: 'open' | 'accepted' | 'rejected'
@@ -137,10 +137,22 @@ export interface Propose {
created_by_id: number | null created_by_id: number | null
created_by_username: string | null created_by_username: string | null
feat_task_id: string | null feat_task_id: string | null
generated_tasks: GeneratedTask[] | null
created_at: string created_at: string
updated_at: string | null 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 { export interface LoginResponse {
access_token: string access_token: string
token_type: string token_type: string