feat(ui): team builder — lead + helpers with live duration preview, wired into the action panels (#44)
This commit is contained in:
+52
-10
@@ -1,16 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { TeamBuilder } from '@/components/team-builder'
|
||||
import { totalQty } from '@/lib/game/ship-costs'
|
||||
import type { ShipState } from '@/lib/game/ship'
|
||||
import type { CrewState } from '@/lib/game/save'
|
||||
import type { TechDto } from '@/lib/game/tech'
|
||||
|
||||
// SKILL TREE tab — live data. The parent (MissionControl) owns the tech state + ship state; this panel
|
||||
// applies each research response directly via onResearched (no polling). RESEARCH is enabled only when
|
||||
// the tech is AVAILABLE and its cost is payable from the cargo hold, mirroring what POST /api/research enforces.
|
||||
// applies each research response directly via onResearched (no polling). A card's RESEARCH button ARMS
|
||||
// the tech (#44): the crew team builder mounts at the top of the column and its DISPATCH POSTs
|
||||
// /api/research with the team. The button is enabled only when the tech is AVAILABLE and its cost is
|
||||
// payable from the cargo hold, mirroring what POST /api/research enforces.
|
||||
|
||||
interface SkillTreePanelProps {
|
||||
techs: TechDto[] | null
|
||||
@@ -22,6 +26,27 @@ interface SkillTreePanelProps {
|
||||
export function SkillTreePanel({ techs, ship, narrow, onResearched }: SkillTreePanelProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false) // one action at a time — turn-based game
|
||||
// #44 — the armed tech (last-clicked AVAILABLE card); the builder above the grid picks the
|
||||
// team and its DISPATCH performs the research.
|
||||
const [armedTechId, setArmedTechId] = useState<number | null>(null)
|
||||
const [crew, setCrew] = useState<CrewState | null>(null)
|
||||
|
||||
// JC-14 — the roster + queue the builder mirrors: fetched on mount and after every
|
||||
// successful dispatch (the research response updates the tech state, not the roster).
|
||||
// Keep-old-on-failure, the house .catch(() => {}) pattern.
|
||||
const fetchCrew = useCallback(() => {
|
||||
fetch('/api/crew')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as CrewState
|
||||
})
|
||||
.then((c) => setCrew(c))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCrew()
|
||||
}, [fetchCrew])
|
||||
|
||||
if (!techs || !ship) {
|
||||
return (
|
||||
@@ -34,14 +59,16 @@ export function SkillTreePanel({ techs, ship, narrow, onResearched }: SkillTreeP
|
||||
const resources = totalQty(ship.inventory, 'resource')
|
||||
const parts = totalQty(ship.inventory, 'part')
|
||||
|
||||
async function research(tech: TechDto) {
|
||||
async function research(tech: TechDto, leadCrewId: number, helperCrewIds: number[]) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setArmedTechId(null)
|
||||
try {
|
||||
const res = await fetch('/api/research', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ techId: tech.id }) })
|
||||
const res = await fetch('/api/research', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ techId: tech.id, leadCrewId, helperCrewIds }) })
|
||||
const data = (await res.json()) as TechDto[] & { error?: string }
|
||||
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`)
|
||||
onResearched(data) // response is the fresh tech tree state — apply directly
|
||||
fetchCrew() // JC-14 — the dispatch committed a team; refresh the roster + queue for the builder
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
@@ -51,6 +78,19 @@ export function SkillTreePanel({ techs, ship, narrow, onResearched }: SkillTreeP
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{/* #44 — the armed tech's crew picker (JC-13: mounted only while a tech is armed);
|
||||
its DISPATCH performs the research with the picked team. */}
|
||||
{armedTechId !== null && crew !== null && (
|
||||
<TeamBuilder
|
||||
type="RESEARCH"
|
||||
roster={crew.crew}
|
||||
tasks={crew.tasks}
|
||||
onQueue={(leadCrewId, helperCrewIds) => {
|
||||
const tech = techs.find((t) => t.id === armedTechId)
|
||||
if (tech) void research(tech, leadCrewId, helperCrewIds)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
|
||||
@@ -58,7 +98,7 @@ export function SkillTreePanel({ techs, ship, narrow, onResearched }: SkillTreeP
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)', gap: '0.75rem' }}>
|
||||
{techs.map((tech) => (
|
||||
<TechCard key={tech.slug} tech={tech} resources={resources} parts={parts} busy={busy} onResearch={research} />
|
||||
<TechCard key={tech.slug} tech={tech} resources={resources} parts={parts} busy={busy} armed={armedTechId === tech.id} onArm={(t) => setArmedTechId(armedTechId === t.id ? null : t.id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,13 +110,15 @@ function TechCard({
|
||||
resources,
|
||||
parts,
|
||||
busy,
|
||||
onResearch,
|
||||
armed,
|
||||
onArm,
|
||||
}: {
|
||||
tech: TechDto
|
||||
resources: number
|
||||
parts: number
|
||||
busy: boolean
|
||||
onResearch: (tech: TechDto) => void
|
||||
armed: boolean // #44 — this card's tech is the armed target (the builder is open for it)
|
||||
onArm: (tech: TechDto) => void
|
||||
}) {
|
||||
const affordable = tech.costKind === 'resource' ? resources >= tech.costQty : parts >= tech.costQty
|
||||
const researchDisabled = busy || tech.state !== 'AVAILABLE' || !affordable
|
||||
@@ -101,8 +143,8 @@ function TechCard({
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button variant="OUTLINE" size="SM" disabled={researchDisabled} onClick={() => onResearch(tech)}>
|
||||
RESEARCH
|
||||
<Button variant="OUTLINE" size="SM" disabled={researchDisabled} onClick={() => onArm(tech)}>
|
||||
{armed ? 'PICK TEAM' : 'RESEARCH'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user