knapsack scheduling algorithm
@@ -22,3 +22,5 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
/data
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import Header from './components/Header'
|
||||
import FailedBanner from './components/FailedBanner'
|
||||
import Calendar from './components/Calendar'
|
||||
import RightPanel from './components/RightPanel'
|
||||
import SettingsModal from './components/SettingsModal'
|
||||
import Header from './components/Header'
|
||||
import FailedBanner from './components/FailedBanner'
|
||||
import Calendar from './components/Calendar'
|
||||
import RightPanel from './components/RightPanel'
|
||||
import SettingsModal from './components/SettingsModal'
|
||||
import TestWindowDetailsPanel from './components/TestWindowDetailsPanel'
|
||||
import { api, groupScheduleItems } from './api'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -123,19 +124,37 @@ export default function App() {
|
||||
const [topPriority, setTopPriority] = useState('')
|
||||
const [lowestPriority, setLowestPriority] = useState('')
|
||||
const [startDateOverride, setStartDateOverride] = useState('')
|
||||
const [failedTests, setFailedTests] = useState([])
|
||||
const [scheduleData, setScheduleData] = useState({})
|
||||
const [completionDate, setCompletionDate] = useState(null)
|
||||
const [weekStart, setWeekStart] = useState(() => getMondayOfWeek(new Date()))
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [failedTests, setFailedTests] = useState([])
|
||||
const [scheduleData, setScheduleData] = useState({})
|
||||
const [scheduleWindows, setScheduleWindows] = useState([])
|
||||
const [selectedWindowId, setSelectedWindowId] = useState(null)
|
||||
const [completionDate, setCompletionDate] = useState(null)
|
||||
const [weekStart, setWeekStart] = useState(() => getMondayOfWeek(new Date()))
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const tonightConfigRows = useMemo(() => toTonightConfigRows(scheduleData), [scheduleData])
|
||||
const windowLookup = useMemo(() => {
|
||||
const lookup = new Map()
|
||||
|
||||
for (const windowDetails of scheduleWindows) {
|
||||
for (const segment of windowDetails.segments ?? []) {
|
||||
lookup.set(`${segment.date}::shift${segment.shift_index}`, windowDetails)
|
||||
}
|
||||
}
|
||||
|
||||
return lookup
|
||||
}, [scheduleWindows])
|
||||
const selectedWindow = useMemo(
|
||||
() => scheduleWindows.find((windowDetails) => windowDetails.window_id === selectedWindowId) ?? null,
|
||||
[scheduleWindows, selectedWindowId],
|
||||
)
|
||||
|
||||
// Fetch schedule for the given weekStart (Monday)
|
||||
const fetchSchedule = useCallback(async (start) => {
|
||||
try {
|
||||
const data = await api.getScheduleWeek(toKey(start))
|
||||
setScheduleData(groupScheduleItems(data.items))
|
||||
setScheduleWindows(data.windows ?? [])
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch schedule:', e)
|
||||
}
|
||||
@@ -268,6 +287,20 @@ export default function App() {
|
||||
setFailedTests([])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedWindowId && !selectedWindow) {
|
||||
setSelectedWindowId(null)
|
||||
}
|
||||
}, [selectedWindow, selectedWindowId])
|
||||
|
||||
function handleWindowSelect(windowDetails) {
|
||||
setSelectedWindowId(windowDetails?.window_id ?? null)
|
||||
}
|
||||
|
||||
function handleCloseWindowDetails() {
|
||||
setSelectedWindowId(null)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
@@ -286,13 +319,15 @@ export default function App() {
|
||||
onDecision={handleRerunDecision}
|
||||
/>
|
||||
|
||||
<main className="flex flex-1 gap-4 p-4 overflow-hidden">
|
||||
<main className="relative flex flex-1 gap-4 p-4 overflow-hidden">
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<Calendar
|
||||
scheduleData={scheduleData}
|
||||
daytimeDateKey={daytimeEnabled ? toKey(new Date()) : null}
|
||||
weekStart={weekStart}
|
||||
onWeekChange={setWeekStart}
|
||||
windowLookup={windowLookup}
|
||||
onWindowSelect={handleWindowSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -310,6 +345,12 @@ export default function App() {
|
||||
loading={loading}
|
||||
tonightConfigRows={tonightConfigRows}
|
||||
/>
|
||||
|
||||
<TestWindowDetailsPanel
|
||||
isOpen={Boolean(selectedWindow)}
|
||||
windowDetails={selectedWindow}
|
||||
onClose={handleCloseWindowDetails}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<SettingsModal
|
||||
|
||||
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 423 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 423 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 419 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 212 KiB |
@@ -1,4 +1,5 @@
|
||||
import DayColumn from './DayColumn'
|
||||
import { getDeviceAccentClass } from './TestCard'
|
||||
|
||||
function addDays(date, n) {
|
||||
const d = new Date(date)
|
||||
@@ -47,7 +48,14 @@ function getNextTestWindow() {
|
||||
}
|
||||
|
||||
// scheduleData: { "YYYY-MM-DD": { shift1: [...], shift2: [...], shift3: [...] }, ... }
|
||||
export default function Calendar({ scheduleData = {}, daytimeDateKey = null, weekStart, onWeekChange }) {
|
||||
export default function Calendar({
|
||||
scheduleData = {},
|
||||
daytimeDateKey = null,
|
||||
weekStart,
|
||||
onWeekChange,
|
||||
windowLookup = new Map(),
|
||||
onWindowSelect,
|
||||
}) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
@@ -58,6 +66,18 @@ export default function Calendar({ scheduleData = {}, daytimeDateKey = null, wee
|
||||
// Reorder columns: Sun Mon Tue Wed Thu Fri Sat
|
||||
// weekStart is Monday, so days[0]=Mon ... days[6]=Sun → put Sunday first
|
||||
const orderedDays = [ ...days.slice(0, 7)]
|
||||
const deviceLegendItems = Array.from(
|
||||
new Set(
|
||||
Object.values(scheduleData)
|
||||
.flatMap((day) => [
|
||||
...(day?.shift1 ?? []),
|
||||
...(day?.shift2 ?? []),
|
||||
...(day?.shift3 ?? []),
|
||||
])
|
||||
.map((test) => test?.device)
|
||||
.filter(Boolean),
|
||||
),
|
||||
).sort((a, b) => String(a).localeCompare(String(b)))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 h-full">
|
||||
@@ -112,16 +132,24 @@ export default function Calendar({ scheduleData = {}, daytimeDateKey = null, wee
|
||||
activeShifts={activeShifts}
|
||||
shifts={shifts}
|
||||
showShift2={isWeekend || (daytimeDateKey !== null && key === daytimeDateKey)}
|
||||
windowLookup={windowLookup}
|
||||
onWindowSelect={onWindowSelect}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex gap-4 text-xs text-gray-400">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-gray-500 inline-block" />Pending</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-green-700 inline-block" />Completed</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-red-700 inline-block" />Rerun Required</span>
|
||||
{deviceLegendItems.map((device) => (
|
||||
<span key={device} className="flex items-center gap-1.5">
|
||||
<span className={`w-2.5 h-2.5 rounded-sm inline-block ${getDeviceAccentClass(device)}`} />
|
||||
{device}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,14 +2,24 @@ import ShiftSlot from './ShiftSlot'
|
||||
|
||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
export default function DayColumn({ date, isToday, activeShifts = new Set(), shifts = {}, showShift2 = false }) {
|
||||
export default function DayColumn({
|
||||
date,
|
||||
isToday,
|
||||
activeShifts = new Set(),
|
||||
shifts = {},
|
||||
showShift2 = false,
|
||||
windowLookup = new Map(),
|
||||
onWindowSelect,
|
||||
}) {
|
||||
const dayName = DAY_NAMES[date.getDay()]
|
||||
const dayNum = date.getDate()
|
||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6
|
||||
const hasActive = activeShifts.size > 0
|
||||
const dateKey = date.toISOString().slice(0, 10)
|
||||
|
||||
// Shift 2 is visible if: weekend, holiday (showShift2), or daytime testing on
|
||||
const shift2Visible = isWeekend || showShift2
|
||||
function getWindowForShift(shiftIndex) {
|
||||
return windowLookup.get(`${dateKey}::shift${shiftIndex}`) ?? null
|
||||
}
|
||||
|
||||
const shift2Visible = showShift2 || shifts.shift2.length > 0 || Boolean(getWindowForShift(2))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-w-0 flex-1 rounded-lg border border-gray-700 bg-gray-800/40">
|
||||
@@ -29,9 +39,30 @@ export default function DayColumn({ date, isToday, activeShifts = new Set(), shi
|
||||
|
||||
{/* Shifts */}
|
||||
<div className="flex flex-col flex-1 px-0.5 py-1">
|
||||
<ShiftSlot label="12AM–9AM" tests={shifts.shift1} visible={true} active={activeShifts.has(1)} />
|
||||
<ShiftSlot label="9AM–5PM" tests={shifts.shift2} visible={true} active={activeShifts.has(2)} />
|
||||
<ShiftSlot label="5PM–12AM" tests={shifts.shift3} visible={true} active={activeShifts.has(3)} />
|
||||
<ShiftSlot
|
||||
label="12AM–9AM"
|
||||
tests={shifts.shift1}
|
||||
visible={true}
|
||||
active={activeShifts.has(1)}
|
||||
windowDetails={getWindowForShift(1)}
|
||||
onWindowSelect={onWindowSelect}
|
||||
/>
|
||||
<ShiftSlot
|
||||
label="9AM–5PM"
|
||||
tests={shifts.shift2}
|
||||
visible={true}
|
||||
active={activeShifts.has(2)}
|
||||
windowDetails={getWindowForShift(2)}
|
||||
onWindowSelect={onWindowSelect}
|
||||
/>
|
||||
<ShiftSlot
|
||||
label="5PM–12AM"
|
||||
tests={shifts.shift3}
|
||||
visible={true}
|
||||
active={activeShifts.has(3)}
|
||||
windowDetails={getWindowForShift(3)}
|
||||
onWindowSelect={onWindowSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ export default function Header({ onOpenSettings }) {
|
||||
return (
|
||||
<header className="flex items-center justify-between px-6 py-3 bg-gray-900 border-b border-gray-700 shrink-0">
|
||||
<h1 className="text-white font-semibold text-lg tracking-wide">
|
||||
CGW453 Scheduler
|
||||
NJTH Scheduler
|
||||
</h1>
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
|
||||
@@ -196,12 +196,12 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
||||
Test Exclusion
|
||||
</p>
|
||||
<Field label="Excluded Test IDs" hint="(comma-separated)">
|
||||
<Field label="Exclusions" hint="(comma-separated)">
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLS}
|
||||
placeholder="P2PRXAX001, COERXBE002…"
|
||||
value={form.testExclusion ?? ''}
|
||||
placeholder="SP, BW320,..."
|
||||
value={form.testExclusion?? ''}
|
||||
onChange={(e) => set('testExclusion', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
import TestCard from './TestCard'
|
||||
|
||||
export default function ShiftSlot({ label, tests = [], visible = true, active = false }) {
|
||||
export default function ShiftSlot({
|
||||
label,
|
||||
tests = [],
|
||||
visible = true,
|
||||
active = false,
|
||||
windowDetails = null,
|
||||
onWindowSelect,
|
||||
}) {
|
||||
if (!visible) return null
|
||||
|
||||
const clickable = Boolean(windowDetails && onWindowSelect)
|
||||
|
||||
return (
|
||||
<div className={`border-t pt-1 pb-1.5 ${
|
||||
active ? 'border-blue-500/50 bg-blue-950/20 rounded' : 'border-gray-700/60'
|
||||
}`}>
|
||||
<p className={`text-[10px] font-semibold uppercase tracking-wider mb-1 px-1 ${
|
||||
active ? 'text-blue-400' : 'text-gray-500'
|
||||
}`}>
|
||||
{label}
|
||||
</p>
|
||||
<div
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onClick={clickable ? () => onWindowSelect(windowDetails) : undefined}
|
||||
onKeyDown={clickable ? (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onWindowSelect(windowDetails)
|
||||
}
|
||||
} : undefined}
|
||||
className={`border-t pt-1 pb-1.5 transition-colors ${
|
||||
active ? 'border-blue-500/50 bg-blue-950/20 rounded' : 'border-gray-700/60'
|
||||
} ${clickable ? 'cursor-pointer hover:bg-gray-700/25 focus:outline-none focus:ring-1 focus:ring-cyan-400/70' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-1 mb-1">
|
||||
<p className={`text-[10px] font-semibold uppercase tracking-wider ${
|
||||
active ? 'text-blue-400' : 'text-gray-500'
|
||||
}`}>
|
||||
{label}
|
||||
</p>
|
||||
{clickable && (
|
||||
<span className="text-[9px] font-semibold uppercase tracking-[0.2em] text-cyan-400/80">
|
||||
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-1 min-h-4">
|
||||
{tests.length === 0 ? (
|
||||
<span className="text-[10px] text-gray-600 italic">—</span>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
|
||||
const STATUS_STYLES = {
|
||||
pending: 'bg-gray-600/60 text-gray-200 border-gray-500',
|
||||
completed: 'bg-green-800/60 text-green-200 border-green-600',
|
||||
rerun: 'bg-red-800/60 text-red-200 border-red-600',
|
||||
}
|
||||
|
||||
const DEVICE_ACCENT_CLASSES = [
|
||||
export const DEVICE_ACCENT_CLASSES = [
|
||||
'bg-sky-400',
|
||||
'bg-emerald-400',
|
||||
'bg-amber-400',
|
||||
@@ -15,7 +17,7 @@ const DEVICE_ACCENT_CLASSES = [
|
||||
'bg-fuchsia-400',
|
||||
]
|
||||
|
||||
function getDeviceAccentClass(device) {
|
||||
export function getDeviceAccentClass(device) {
|
||||
if (!device) return 'bg-gray-500'
|
||||
|
||||
let hash = 0
|
||||
@@ -47,9 +49,46 @@ function formatConfig(config) {
|
||||
export default function TestCard({ test }) {
|
||||
const style = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending
|
||||
const deviceAccentClass = getDeviceAccentClass(test.device)
|
||||
const cardRef = useRef(null)
|
||||
const [tooltipPos, setTooltipPos] = useState({ top: '0px', left: '0px' })
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const cardElement = cardRef.current
|
||||
if (!cardElement) return
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
// Position tooltip near the cursor, with 10px offset
|
||||
const top = e.clientY + 10
|
||||
const left = e.clientX + 10
|
||||
|
||||
setTooltipPos({
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHovered(true)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsHovered(false)
|
||||
}
|
||||
|
||||
cardElement.addEventListener('mouseenter', handleMouseEnter)
|
||||
cardElement.addEventListener('mouseleave', handleMouseLeave)
|
||||
cardElement.addEventListener('mousemove', handleMouseMove)
|
||||
|
||||
return () => {
|
||||
cardElement.removeEventListener('mouseenter', handleMouseEnter)
|
||||
cardElement.removeEventListener('mouseleave', handleMouseLeave)
|
||||
cardElement.removeEventListener('mousemove', handleMouseMove)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div ref={cardRef} className="relative">
|
||||
<div
|
||||
className={`relative px-1.5 py-0.5 pl-3 rounded border text-xs font-mono truncate cursor-default select-none ${style}`}
|
||||
style={{ maxWidth: '100%' }}
|
||||
@@ -57,9 +96,14 @@ export default function TestCard({ test }) {
|
||||
<span className={`absolute inset-y-0 left-0 w-1 rounded-l ${deviceAccentClass}`} aria-hidden="true" />
|
||||
{test.test_id}
|
||||
</div>
|
||||
{/* Hover tooltip */}
|
||||
<div className="absolute z-50 bottom-full left-0 mb-1 hidden group-hover:block min-w-max">
|
||||
<div className="bg-gray-800 border border-gray-600 rounded-md px-2.5 py-1.5 text-xs text-gray-200 shadow-lg max-w-md">
|
||||
{/* Hover tooltip — follows cursor */}
|
||||
{isHovered && (
|
||||
<div className="fixed z-50 bg-gray-800 border border-gray-600 rounded-md px-2.5 py-1.5 text-xs text-gray-200 shadow-lg max-w-md pointer-events-none"
|
||||
style={{
|
||||
top: tooltipPos.top,
|
||||
left: tooltipPos.left,
|
||||
}}
|
||||
>
|
||||
<p><span className="text-gray-400">Type:</span> {test.test_type ?? '—'}</p>
|
||||
<p><span className="text-gray-400">Rotation:</span> {test.rotation ?? '—'}</p>
|
||||
<p className="flex items-center gap-1.5">
|
||||
@@ -69,7 +113,7 @@ export default function TestCard({ test }) {
|
||||
</p>
|
||||
<p className="mt-1 pt-1 border-t border-gray-600"><span className="text-gray-400">Config:</span> {formatConfig(test.config)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
const STATUS_STYLES = {
|
||||
pending: 'bg-gray-700 text-gray-200 border-gray-600',
|
||||
completed: 'bg-green-900/60 text-green-200 border-green-700',
|
||||
rerun: 'bg-red-900/60 text-red-200 border-red-700',
|
||||
}
|
||||
|
||||
function formatWindowHeader(windowDetails) {
|
||||
if (!windowDetails?.start_at || !windowDetails?.end_at) return 'Window details'
|
||||
|
||||
const start = new Date(windowDetails.start_at)
|
||||
const end = new Date(windowDetails.end_at)
|
||||
|
||||
const startLabel = start.toLocaleString('en-US', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
}).replace(':00', '').replace(' AM', 'am').replace(' PM', 'pm')
|
||||
|
||||
const endLabel = end.toLocaleString('en-US', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
}).replace(':00', '').replace(' AM', 'am').replace(' PM', 'pm')
|
||||
|
||||
return `${startLabel} - ${endLabel}`
|
||||
}
|
||||
|
||||
function formatHours(minutes) {
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) return '0 hrs'
|
||||
const hours = minutes / 60
|
||||
const rounded = Number.isInteger(hours) ? String(hours) : hours.toFixed(1)
|
||||
return `${rounded} hrs`
|
||||
}
|
||||
|
||||
function formatShiftLabel(test) {
|
||||
const shiftLabels = {
|
||||
1: '12AM–9AM',
|
||||
2: '9AM–5PM',
|
||||
3: '5PM–12AM',
|
||||
}
|
||||
|
||||
const date = test?.scheduled_date
|
||||
const label = shiftLabels[test?.shift_index] ?? 'Unknown shift'
|
||||
return date ? `${date} ${label}` : label
|
||||
}
|
||||
|
||||
function getWindowTestConfig(windowDetails) {
|
||||
if (Array.isArray(windowDetails?.bundle_test_configs)) {
|
||||
return windowDetails.bundle_test_configs
|
||||
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const tests = windowDetails?.tests ?? []
|
||||
const allKeys = new Set()
|
||||
for (const test of tests) {
|
||||
if (Array.isArray(test?.bundle_test_configs)) {
|
||||
for (const key of test.bundle_test_configs) {
|
||||
if (typeof key === 'string' && key.trim()) {
|
||||
allKeys.add(key.trim())
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof test?.test_config === 'string' && test.test_config.trim()) {
|
||||
allKeys.add(test.test_config.trim())
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(allKeys).sort()
|
||||
}
|
||||
|
||||
function formatConfigEntries(config) {
|
||||
if (!config || typeof config !== 'object') return []
|
||||
|
||||
return Object.entries(config)
|
||||
.map(([band, data]) => {
|
||||
if (!data || typeof data !== 'object') return null
|
||||
const testPoint = data.test_point || data['Test Point'] || '—'
|
||||
const sta = data.sta || data.STA || '—'
|
||||
return { band, testPoint, sta }
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function TestRow({ test }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const statusStyle = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending
|
||||
const configEntries = useMemo(() => formatConfigEntries(test.config), [test.config])
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-700 bg-gray-900/70 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
className="w-full px-4 py-3 text-left hover:bg-gray-800/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{test.test_id}</p>
|
||||
<p className="mt-1 text-xs text-gray-400">{test.device ?? '—'} · {formatShiftLabel(test)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`px-2 py-1 rounded-full border text-[11px] font-semibold uppercase tracking-wide ${statusStyle}`}>
|
||||
{test.status ?? 'pending'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{formatHours(test.estimated_minutes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 pt-1 border-t border-gray-800 bg-gray-950/60">
|
||||
<div className="grid grid-cols-2 gap-3 text-xs text-gray-300">
|
||||
<div>
|
||||
<p className="text-gray-500 uppercase tracking-wide text-[11px] mb-1">Type</p>
|
||||
<p>{test.test_type ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500 uppercase tracking-wide text-[11px] mb-1">Rotation</p>
|
||||
<p>{test.rotation ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500 uppercase tracking-wide text-[11px] mb-1">Priority</p>
|
||||
<p>{test.priority ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500 uppercase tracking-wide text-[11px] mb-1">Sequence</p>
|
||||
<p>{test.sequence_in_shift ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-gray-500 uppercase tracking-wide text-[11px] mb-2">Config</p>
|
||||
{configEntries.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">No config details available.</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-gray-800 overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-900/90">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 font-semibold text-gray-400 border-b border-gray-800">Band</th>
|
||||
<th className="text-left px-3 py-2 font-semibold text-gray-400 border-b border-gray-800">Testpoint</th>
|
||||
<th className="text-left px-3 py-2 font-semibold text-gray-400 border-b border-gray-800">STA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{configEntries.map((entry) => (
|
||||
<tr key={`${test.test_id}-${entry.band}`} className="odd:bg-gray-950 even:bg-gray-900/60">
|
||||
<td className="px-3 py-2 text-gray-200 border-b border-gray-800">{entry.band}</td>
|
||||
<td className="px-3 py-2 text-gray-300 border-b border-gray-800">{entry.testPoint}</td>
|
||||
<td className="px-3 py-2 text-gray-300 border-b border-gray-800">{entry.sta}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose }) {
|
||||
const windowTestConfig = useMemo(() => getWindowTestConfig(windowDetails), [windowDetails])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute inset-0 z-40 transition-opacity duration-300 ${
|
||||
isOpen ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
aria-hidden={!isOpen}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-gray-950/45 backdrop-blur-[1px]"
|
||||
aria-label="Close test window details"
|
||||
/>
|
||||
|
||||
<aside
|
||||
className={`absolute inset-y-0 right-0 w-full max-w-[30rem] border-l border-gray-700 bg-gray-950/96 shadow-2xl transition-transform duration-300 ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-gray-800 px-5 py-5">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-cyan-400">Test Window</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">
|
||||
{windowDetails ? formatWindowHeader(windowDetails) : 'Window details'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-gray-700 p-2 text-gray-400 hover:border-gray-500 hover:text-white transition-colors"
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{windowDetails && (
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-2xl border border-gray-800 bg-gray-900/80 px-4 py-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Available Run Time</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-white">{formatHours(windowDetails.available_runtime_minutes)}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-800 bg-gray-900/80 px-4 py-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Estimated Run Time</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-white">{formatHours(windowDetails.estimated_runtime_minutes)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-2xl border border-gray-800 bg-gray-900/80 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-white">Config</p>
|
||||
<span className="rounded-full border border-cyan-700/60 bg-cyan-900/30 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-cyan-200">
|
||||
{windowTestConfig.length > 0 ? windowTestConfig.join(', ') : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
{windowDetails.configRows?.length ? (
|
||||
<div className="mt-3 overflow-hidden rounded-xl border border-gray-800">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-950/90">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold text-gray-400 border-b border-gray-800">STA</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-gray-400 border-b border-gray-800">Testpoint</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{windowDetails.configRows.map((row, index) => (
|
||||
<tr key={`${row.sta}-${index}`} className="odd:bg-gray-950 even:bg-gray-900/60">
|
||||
<td className="px-3 py-2 border-b border-gray-800 font-semibold text-gray-200">{row.sta}</td>
|
||||
<td className="px-3 py-2 border-b border-gray-800 text-gray-300">{row.testPoints.join(', ')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-xs text-gray-500">No STA placement mapping available for this window.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<p className="text-sm font-semibold text-white">Tests Scheduled In This Window</p>
|
||||
<span className="text-xs text-gray-500">{windowDetails.tests?.length ?? 0} tests</span>
|
||||
</div>
|
||||
{windowDetails.tests?.length ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{windowDetails.tests.map((test) => (
|
||||
<TestRow key={`${test.test_id}-${test.device}-${test.scheduled_date}-${test.shift_index}`} test={test} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-gray-800 bg-gray-900/50 px-4 py-6 text-sm text-gray-500">
|
||||
No tests are currently scheduled in this window.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||