FE-PR-002: Proposal 详情页增加 Essential 列表区
- Add Essential and EssentialType types to frontend types - Add essentials state and CRUD handlers in ProposalDetailPage - Display Essential list with type, code, title - Handle empty state (show message when no essentials) - Add create/edit modal for Essentials - Only show edit/delete buttons for open proposals
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
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 { Proposal, Milestone } from '@/types'
|
import type { Proposal, Milestone, Essential, EssentialType } from '@/types'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import CopyableCode from '@/components/CopyableCode'
|
import CopyableCode from '@/components/CopyableCode'
|
||||||
|
|
||||||
@@ -24,6 +24,18 @@ export default function ProposalDetailPage() {
|
|||||||
const [editDescription, setEditDescription] = useState('')
|
const [editDescription, setEditDescription] = useState('')
|
||||||
const [editLoading, setEditLoading] = useState(false)
|
const [editLoading, setEditLoading] = useState(false)
|
||||||
|
|
||||||
|
// Essential state
|
||||||
|
const [essentials, setEssentials] = useState<Essential[]>([])
|
||||||
|
const [loadingEssentials, setLoadingEssentials] = useState(false)
|
||||||
|
|
||||||
|
// Essential create/edit state
|
||||||
|
const [showEssentialModal, setShowEssentialModal] = useState(false)
|
||||||
|
const [editingEssential, setEditingEssential] = useState<Essential | null>(null)
|
||||||
|
const [essentialTitle, setEssentialTitle] = useState('')
|
||||||
|
const [essentialType, setEssentialType] = useState<EssentialType>('feature')
|
||||||
|
const [essentialDesc, setEssentialDesc] = useState('')
|
||||||
|
const [essentialLoading, setEssentialLoading] = useState(false)
|
||||||
|
|
||||||
const fetchProposal = () => {
|
const fetchProposal = () => {
|
||||||
if (!projectId) return
|
if (!projectId) return
|
||||||
api.get<Proposal>(`/projects/${projectId}/proposals/${id}`).then(({ data }) => setProposal(data))
|
api.get<Proposal>(`/projects/${projectId}/proposals/${id}`).then(({ data }) => setProposal(data))
|
||||||
@@ -39,6 +51,18 @@ export default function ProposalDetailPage() {
|
|||||||
.then(({ data }) => setMilestones(data.filter((m) => m.status === 'open')))
|
.then(({ data }) => setMilestones(data.filter((m) => m.status === 'open')))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchEssentials = () => {
|
||||||
|
if (!projectId || !id) return
|
||||||
|
setLoadingEssentials(true)
|
||||||
|
api.get<Essential[]>(`/projects/${projectId}/proposals/${id}/essentials`)
|
||||||
|
.then(({ data }) => setEssentials(data))
|
||||||
|
.finally(() => setLoadingEssentials(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEssentials()
|
||||||
|
}, [id, projectId])
|
||||||
|
|
||||||
const handleAccept = async () => {
|
const handleAccept = async () => {
|
||||||
if (!selectedMilestone || !projectId) return
|
if (!selectedMilestone || !projectId) return
|
||||||
setActionLoading(true)
|
setActionLoading(true)
|
||||||
@@ -109,6 +133,64 @@ export default function ProposalDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Essential handlers
|
||||||
|
const openEssentialModal = (essential?: Essential) => {
|
||||||
|
if (essential) {
|
||||||
|
setEditingEssential(essential)
|
||||||
|
setEssentialTitle(essential.title)
|
||||||
|
setEssentialType(essential.type)
|
||||||
|
setEssentialDesc(essential.description || '')
|
||||||
|
} else {
|
||||||
|
setEditingEssential(null)
|
||||||
|
setEssentialTitle('')
|
||||||
|
setEssentialType('feature')
|
||||||
|
setEssentialDesc('')
|
||||||
|
}
|
||||||
|
setError('')
|
||||||
|
setShowEssentialModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveEssential = async () => {
|
||||||
|
if (!projectId || !id) return
|
||||||
|
setEssentialLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
if (editingEssential) {
|
||||||
|
await api.patch(`/projects/${projectId}/proposals/${id}/essentials/${editingEssential.id}`, {
|
||||||
|
title: essentialTitle,
|
||||||
|
type: essentialType,
|
||||||
|
description: essentialDesc || null,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await api.post(`/projects/${projectId}/proposals/${id}/essentials`, {
|
||||||
|
title: essentialTitle,
|
||||||
|
type: essentialType,
|
||||||
|
description: essentialDesc || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setShowEssentialModal(false)
|
||||||
|
fetchEssentials()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail || 'Save failed')
|
||||||
|
} finally {
|
||||||
|
setEssentialLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteEssential = async (essentialId: number) => {
|
||||||
|
if (!projectId || !id) return
|
||||||
|
if (!confirm('Are you sure you want to delete this Essential?')) return
|
||||||
|
setEssentialLoading(true)
|
||||||
|
try {
|
||||||
|
await api.delete(`/projects/${projectId}/proposals/${id}/essentials/${essentialId}`)
|
||||||
|
fetchEssentials()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail || 'Delete failed')
|
||||||
|
} finally {
|
||||||
|
setEssentialLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!proposal) return <div className="loading">Loading...</div>
|
if (!proposal) return <div className="loading">Loading...</div>
|
||||||
|
|
||||||
const statusBadgeClass = (s: string) => {
|
const statusBadgeClass = (s: string) => {
|
||||||
@@ -150,6 +232,56 @@ export default function ProposalDetailPage() {
|
|||||||
<p style={{ whiteSpace: 'pre-wrap' }}>{proposal.description || 'No description'}</p>
|
<p style={{ whiteSpace: 'pre-wrap' }}>{proposal.description || 'No description'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Essentials section */}
|
||||||
|
<div className="section">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<h3>Essentials ({essentials.length})</h3>
|
||||||
|
{proposal.status === 'open' && (
|
||||||
|
<button className="btn-primary" onClick={() => openEssentialModal()}>+ New Essential</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loadingEssentials ? (
|
||||||
|
<div className="loading">Loading...</div>
|
||||||
|
) : essentials.length === 0 ? (
|
||||||
|
<div className="empty" style={{ padding: '20px 0', color: 'var(--text-secondary)' }}>
|
||||||
|
No essentials yet. {proposal.status === 'open' ? 'Add one to define deliverables for this proposal.' : ''}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="milestone-grid">
|
||||||
|
{essentials.map((e) => (
|
||||||
|
<div key={e.id} className="milestone-card">
|
||||||
|
<div className="milestone-card-header" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span className="badge">{e.type}</span>
|
||||||
|
{e.essential_code && <span className="badge"><CopyableCode code={e.essential_code} /></span>}
|
||||||
|
<h4 style={{ margin: 0, flex: 1 }}>{e.title}</h4>
|
||||||
|
{proposal.status === 'open' && (
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<button
|
||||||
|
className="btn-transition"
|
||||||
|
style={{ padding: '4px 8px', fontSize: '0.8rem' }}
|
||||||
|
onClick={() => openEssentialModal(e)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-danger"
|
||||||
|
style={{ padding: '4px 8px', fontSize: '0.8rem' }}
|
||||||
|
onClick={() => handleDeleteEssential(e.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{e.description && (
|
||||||
|
<p style={{ marginTop: 8, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{e.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Generated tasks (shown when accepted) */}
|
{/* Generated tasks (shown when accepted) */}
|
||||||
{proposal.status === 'accepted' && proposal.generated_tasks && proposal.generated_tasks.length > 0 && (
|
{proposal.status === 'accepted' && proposal.generated_tasks && proposal.generated_tasks.length > 0 && (
|
||||||
<div className="section">
|
<div className="section">
|
||||||
@@ -278,6 +410,56 @@ export default function ProposalDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Essential create/edit modal */}
|
||||||
|
{showEssentialModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowEssentialModal(false)}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{editingEssential ? 'Edit Essential' : 'New Essential'}</h3>
|
||||||
|
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
<strong>Title</strong>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={essentialTitle}
|
||||||
|
onChange={(e) => setEssentialTitle(e.target.value)}
|
||||||
|
style={{ width: '100%', marginTop: 4 }}
|
||||||
|
placeholder="Essential title"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
<strong>Type</strong>
|
||||||
|
<select
|
||||||
|
value={essentialType}
|
||||||
|
onChange={(e) => setEssentialType(e.target.value as EssentialType)}
|
||||||
|
style={{ width: '100%', marginTop: 4 }}
|
||||||
|
>
|
||||||
|
<option value="feature">Feature</option>
|
||||||
|
<option value="improvement">Improvement</option>
|
||||||
|
<option value="refactor">Refactor</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
<strong>Description</strong>
|
||||||
|
<textarea
|
||||||
|
value={essentialDesc}
|
||||||
|
onChange={(e) => setEssentialDesc(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
style={{ width: '100%', marginTop: 4 }}
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={handleSaveEssential}
|
||||||
|
disabled={!essentialTitle.trim() || essentialLoading}
|
||||||
|
>
|
||||||
|
{essentialLoading ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-back" onClick={() => setShowEssentialModal(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export interface Proposal {
|
|||||||
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
|
||||||
|
essentials: Essential[] | null
|
||||||
generated_tasks: GeneratedTask[] | null
|
generated_tasks: GeneratedTask[] | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string | null
|
updated_at: string | null
|
||||||
@@ -153,6 +154,20 @@ export interface GeneratedTask {
|
|||||||
task_subtype: string | null
|
task_subtype: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EssentialType = 'feature' | 'improvement' | 'refactor'
|
||||||
|
|
||||||
|
export interface Essential {
|
||||||
|
id: number
|
||||||
|
essential_code: string
|
||||||
|
proposal_id: number
|
||||||
|
type: EssentialType
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
created_by_id: number | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
access_token: string
|
access_token: string
|
||||||
token_type: string
|
token_type: string
|
||||||
|
|||||||
Reference in New Issue
Block a user