|
|
|
|
@@ -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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|