HarborForge.Frontend: proposal/essential tests on current branch head #11
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import api from '@/services/api'
|
||||
import type { Proposal, Milestone } from '@/types'
|
||||
import type { Proposal, Milestone, Essential, EssentialType } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
import CopyableCode from '@/components/CopyableCode'
|
||||
|
||||
@@ -24,6 +24,18 @@ export default function ProposalDetailPage() {
|
||||
const [editDescription, setEditDescription] = useState('')
|
||||
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 = () => {
|
||||
if (!projectId) return
|
||||
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')))
|
||||
}
|
||||
|
||||
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 () => {
|
||||
if (!selectedMilestone || !projectId) return
|
||||
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>
|
||||
|
||||
const statusBadgeClass = (s: string) => {
|
||||
@@ -150,6 +232,56 @@ export default function ProposalDetailPage() {
|
||||
<p style={{ whiteSpace: 'pre-wrap' }}>{proposal.description || 'No description'}</p>
|
||||
</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) */}
|
||||
{proposal.status === 'accepted' && proposal.generated_tasks && proposal.generated_tasks.length > 0 && (
|
||||
<div className="section">
|
||||
@@ -278,6 +410,56 @@ export default function ProposalDetailPage() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ export interface Proposal {
|
||||
created_by_id: number | null
|
||||
created_by_username: string | null
|
||||
feat_task_id: string | null
|
||||
essentials: Essential[] | null
|
||||
generated_tasks: GeneratedTask[] | null
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
@@ -153,6 +154,20 @@ export interface GeneratedTask {
|
||||
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 {
|
||||
access_token: string
|
||||
token_type: string
|
||||
|
||||
Reference in New Issue
Block a user