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
+80 -11
View File
@@ -1,16 +1,21 @@
'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 { BUILD_PART_COST, expandCost, totalQty, upgradeCost } from '@/lib/game/ship-costs'
import type { ShipState, ShipSystemDto } from '@/lib/game/ship'
import type { CrewState } from '@/lib/game/save'
import { buildGate, effectiveCapacityCap, effectiveLevelCap } from '@/lib/game/tech'
import type { TaskType } from '@/lib/game/tasks'
// SHIP SYSTEMS tab — live data. The parent (MissionControl) owns the ship state so the left
// column mirrors actions immediately; this panel applies each action's response directly via
// onChanged (no polling). Buttons are disabled when unaffordable, capped or gated by an
// onChanged (no polling). A card's UPGRADE / EXPAND / BUILD button ARMS the action (#44): the
// crew team builder mounts at the top of the column and its DISPATCH POSTs the ship route with
// the team. Buttons are disabled when unaffordable, capped or gated by an
// unresearched tech, using the same pure helpers the API enforces (lib/game/ship-costs.ts +
// lib/game/tech.ts).
@@ -24,6 +29,32 @@ interface ShipSystemsPanelProps {
export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: ShipSystemsPanelProps) {
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false) // one action at a time — turn-based game
// #44 — the armed ship action (which card + which of UPGRADE/EXPAND/BUILD); the builder above
// the grid picks the team and its DISPATCH performs it.
const [armed, setArmed] = useState<{ type: TaskType; url: string; body: Record<string, unknown>; systemId: 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 action response updates the ship 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])
// #44 — arm an action (toggle: clicking the armed action's button again disarms it).
const arm = useCallback((type: TaskType, url: string, body: Record<string, unknown>, systemId: number) => {
setArmed((a) => (a && a.systemId === systemId && a.type === type ? null : { type, url, body, systemId }))
}, [])
if (!ship) {
return (
@@ -39,11 +70,13 @@ export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: S
async function act(url: string, body: unknown) {
setBusy(true)
setError(null)
setArmed(null)
try {
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const data = (await res.json()) as ShipState & { error?: string }
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`)
onChanged(data) // response is the fresh ship 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 {
@@ -53,6 +86,16 @@ export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: S
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{/* #44 — the armed ship action's crew picker (JC-13: mounted only while an action is
armed); its DISPATCH performs it with the picked team. */}
{armed !== null && crew !== null && (
<TeamBuilder
type={armed.type}
roster={crew.crew}
tasks={crew.tasks}
onQueue={(leadCrewId, helperCrewIds) => void act(armed.url, { ...armed.body, 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>
@@ -60,7 +103,16 @@ export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: S
)}
<div style={{ display: 'grid', gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)', gap: '0.75rem' }}>
{ship.systems.map((system) => (
<SystemCard key={system.id} system={system} resources={resources} parts={parts} busy={busy} researchedSlugs={researchedSlugs} onAct={act} />
<SystemCard
key={system.id}
system={system}
resources={resources}
parts={parts}
busy={busy}
researchedSlugs={researchedSlugs}
armedType={armed !== null && armed.systemId === system.id ? armed.type : null}
onArm={arm}
/>
))}
</div>
</div>
@@ -73,14 +125,16 @@ function SystemCard({
parts,
busy,
researchedSlugs,
onAct,
armedType,
onArm,
}: {
system: ShipSystemDto
resources: number
parts: number
busy: boolean
researchedSlugs: Set<string>
onAct: (url: string, body: unknown) => void
armedType: TaskType | null // #44 — which of this card's actions is the armed target
onArm: (type: TaskType, url: string, body: Record<string, unknown>, systemId: number) => void
}) {
const maxLevel = effectiveLevelCap(system.type, researchedSlugs)
const maxCapacity = effectiveCapacityCap(system.type, researchedSlugs)
@@ -116,16 +170,31 @@ function SystemCard({
<CardFooter className="gap-2">
{system.built ? (
<>
<Button variant="OUTLINE" size="SM" disabled={upgradeDisabled} onClick={() => onAct('/api/ship/upgrade', { systemId: system.id })}>
UPGRADE
<Button
variant="OUTLINE"
size="SM"
disabled={upgradeDisabled}
onClick={() => onArm('SHIP_UPGRADE', '/api/ship/upgrade', { systemId: system.id }, system.id)}
>
{armedType === 'SHIP_UPGRADE' ? 'PICK TEAM' : 'UPGRADE'}
</Button>
<Button variant="OUTLINE" size="SM" disabled={expandDisabled} onClick={() => onAct('/api/ship/expand', { systemId: system.id })}>
EXPAND
<Button
variant="OUTLINE"
size="SM"
disabled={expandDisabled}
onClick={() => onArm('SHIP_EXPAND', '/api/ship/expand', { systemId: system.id }, system.id)}
>
{armedType === 'SHIP_EXPAND' ? 'PICK TEAM' : 'EXPAND'}
</Button>
</>
) : (
<Button variant="OUTLINE" size="SM" disabled={buildDisabled} onClick={() => onAct('/api/ship/build', { type: system.type })}>
BUILD NEW
<Button
variant="OUTLINE"
size="SM"
disabled={buildDisabled}
onClick={() => onArm('SHIP_BUILD', '/api/ship/build', { type: system.type }, system.id)}
>
{armedType === 'SHIP_BUILD' ? 'PICK TEAM' : 'BUILD NEW'}
</Button>
)}
</CardFooter>