feat(ui): team builder — lead + helpers with live duration preview, wired into the action panels (#44)

This commit is contained in:
2026-10-02 20:34:14 +00:00
parent 07c1508999
commit a199b64348
9 changed files with 374 additions and 62 deletions
+52 -10
View File
@@ -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>