frontend
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import DayColumn from './DayColumn'
|
||||
|
||||
function addDays(date, n) {
|
||||
const d = new Date(date)
|
||||
d.setDate(d.getDate() + n)
|
||||
return d
|
||||
}
|
||||
|
||||
function formatDateRange(monday) {
|
||||
const sunday = addDays(monday, 6)
|
||||
const opts = { month: 'short', day: 'numeric' }
|
||||
const startStr = monday.toLocaleDateString('en-US', opts)
|
||||
const endStr = sunday.toLocaleDateString('en-US', {
|
||||
month: sunday.getMonth() !== monday.getMonth() ? 'short' : undefined,
|
||||
day: 'numeric',
|
||||
})
|
||||
return `${startStr} – ${endStr}`
|
||||
}
|
||||
|
||||
function toDateKey(date) {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function getMondayOfWeek(date) {
|
||||
const d = new Date(date)
|
||||
const day = d.getDay()
|
||||
d.setDate(d.getDate() + (day === 0 ? -6 : 1 - day))
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
|
||||
// Returns the two shifts that form the next/active test window.
|
||||
// Window = Shift 3 of day D → Shift 1 of day D+1
|
||||
function getNextTestWindow() {
|
||||
const now = new Date()
|
||||
const hour = now.getHours()
|
||||
const tod = new Date(now); tod.setHours(0, 0, 0, 0)
|
||||
|
||||
// midnight–1AM: still inside shift 3 that started yesterday
|
||||
if (hour < 1) {
|
||||
return { shift3Date: toDateKey(addDays(tod, -1)), shift1Date: toDateKey(tod) }
|
||||
}
|
||||
// 1AM–5PM: shift 1 finished or in daytime gap — next window starts at 5PM today
|
||||
// 5PM–midnight: currently inside shift 3 of today
|
||||
// Both cases: upcoming/active window is shift 3 today + shift 1 tomorrow
|
||||
return { shift3Date: toDateKey(tod), shift1Date: toDateKey(addDays(tod, 1)) }
|
||||
}
|
||||
|
||||
// scheduleData: { "YYYY-MM-DD": { shift1: [...], shift2: [...], shift3: [...] }, ... }
|
||||
export default function Calendar({ scheduleData = {}, daytimeDateKey = null, weekStart, onWeekChange }) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const activeWindow = getNextTestWindow()
|
||||
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
|
||||
|
||||
// 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)]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 h-full">
|
||||
{/* Week navigation */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onWeekChange(addDays(weekStart, -7))}
|
||||
className="p-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
||||
title="Previous week"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm font-medium text-gray-300 min-w-36 text-center">
|
||||
{formatDateRange(weekStart)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onWeekChange(addDays(weekStart, 7))}
|
||||
className="p-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
||||
title="Next week"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onWeekChange(getMondayOfWeek(new Date()))}
|
||||
className="px-2 py-1 text-xs font-semibold text-gray-300 border border-gray-600 rounded hover:bg-gray-700 hover:text-white transition-colors"
|
||||
title="Jump to this week"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 7-day grid */}
|
||||
<div className="flex gap-1.5 flex-1 overflow-x-auto">
|
||||
{orderedDays.map((date) => {
|
||||
const key = toDateKey(date)
|
||||
const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] }
|
||||
const isToday = date.getTime() === today.getTime()
|
||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6
|
||||
// Which shifts on this date are part of the active test window?
|
||||
const activeShifts = new Set()
|
||||
if (key === activeWindow.shift3Date) activeShifts.add(3)
|
||||
if (key === activeWindow.shift1Date) activeShifts.add(1)
|
||||
return (
|
||||
<DayColumn
|
||||
key={key}
|
||||
date={date}
|
||||
isToday={isToday}
|
||||
activeShifts={activeShifts}
|
||||
shifts={shifts}
|
||||
showShift2={isWeekend || (daytimeDateKey !== null && key === daytimeDateKey)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex gap-4 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" />Failed</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-yellow-700 inline-block" />Invalid</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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 }) {
|
||||
const dayName = DAY_NAMES[date.getDay()]
|
||||
const dayNum = date.getDate()
|
||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6
|
||||
const hasActive = activeShifts.size > 0
|
||||
|
||||
// Shift 2 is visible if: weekend, holiday (showShift2), or daytime testing on
|
||||
const shift2Visible = isWeekend || showShift2
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-w-0 flex-1 rounded-lg border border-gray-700 bg-gray-800/40">
|
||||
{/* Day header — subtle today ring, no full-column highlight */}
|
||||
<div
|
||||
className={`text-center py-1.5 rounded-t-lg ${
|
||||
isToday ? 'bg-gray-600 text-white' : 'bg-gray-700/60 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide leading-none">
|
||||
{dayName}
|
||||
</p>
|
||||
<p className={`text-base font-bold leading-tight ${isToday ? 'text-white' : 'text-gray-100'}`}>
|
||||
{dayNum}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export default function FailedBanner({ failedTests, estimatedMinutes, onDecision }) {
|
||||
if (!failedTests || failedTests.length === 0) return null
|
||||
|
||||
const hours = Math.floor(estimatedMinutes / 60)
|
||||
const mins = estimatedMinutes % 60
|
||||
const timeStr = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-4 px-6 py-3 bg-red-900/70 border-b border-red-700 text-red-100">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-5 h-5 mt-0.5 shrink-0 text-red-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
<span className="font-semibold">{failedTests.length} test{failedTests.length !== 1 ? 's' : ''}</span>
|
||||
{' '}failed last night and require a rerun —{' '}
|
||||
<span className="font-mono text-red-200">{failedTests.join(', ')}</span>
|
||||
</p>
|
||||
<p className="text-sm text-red-300 mt-0.5">
|
||||
Estimated rerun time: <span className="font-semibold text-red-100">{timeStr}</span>
|
||||
{' '}— Rerun these tests during the day today?
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => onDecision(true)}
|
||||
className="px-3 py-1 text-sm font-medium bg-red-700 hover:bg-red-600 text-white rounded-md border border-red-500 transition-colors"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDecision(false)}
|
||||
className="px-3 py-1 text-sm font-medium bg-gray-700 hover:bg-gray-600 text-gray-100 rounded-md border border-gray-500 transition-colors"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
</h1>
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-300 bg-gray-800 border border-gray-600 rounded-md hover:bg-gray-700 hover:text-white transition-colors"
|
||||
title="Settings"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Settings
|
||||
</button>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
function formatCompletionDate(value) {
|
||||
if (!value) return null
|
||||
|
||||
const parsed = new Date(`${value}T00:00:00`)
|
||||
if (Number.isNaN(parsed.getTime())) return String(value)
|
||||
|
||||
return parsed.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export default function RightPanel({
|
||||
completionDate,
|
||||
daytimeEnabled,
|
||||
onDaytimeEnabledChange,
|
||||
topPriority,
|
||||
onTopPriorityChange,
|
||||
lowestPriority,
|
||||
onLowestPriorityChange,
|
||||
onRemakeSchedule,
|
||||
tonightConfigRows = [],
|
||||
loading = false,
|
||||
}) {
|
||||
const formattedCompletionDate = formatCompletionDate(completionDate)
|
||||
|
||||
return (
|
||||
<aside className="flex flex-col gap-4 w-60 shrink-0 pt-8">
|
||||
{/* Estimated Completion */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1">
|
||||
Est. Completion
|
||||
</p>
|
||||
<p className="text-lg font-bold text-white">
|
||||
{formattedCompletionDate ?? <span className="text-gray-500 text-sm font-normal">Not calculated</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Daytime testing toggle */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500">
|
||||
Day Time Testing (Today)
|
||||
</p>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={daytimeEnabled}
|
||||
onClick={() => onDaytimeEnabledChange(!daytimeEnabled)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
||||
daytimeEnabled ? 'bg-blue-600' : 'bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform ${
|
||||
daytimeEnabled ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top priority */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1.5">
|
||||
Top Priority
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={topPriority}
|
||||
onChange={(e) => onTopPriorityChange(e.target.value)}
|
||||
placeholder="P2PRXAX001, COERXBE002…"
|
||||
className="w-full bg-gray-900 border border-gray-600 rounded-md px-2.5 py-1.5 text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500 resize-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remake schedule */}
|
||||
<button
|
||||
onClick={onRemakeSchedule}
|
||||
disabled={loading}
|
||||
className="w-full py-2 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-500 border border-blue-500 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Working…' : 'Remake Schedule'}
|
||||
</button>
|
||||
|
||||
{/* Config for tonight */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1.5">
|
||||
Config for Today
|
||||
</p>
|
||||
{tonightConfigRows.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">No STA placement mapping available for tonight.</p>
|
||||
) : (
|
||||
<div className="max-h-52 overflow-y-auto rounded border border-gray-700">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-900 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left text-gray-400 font-semibold px-2 py-1.5 border-b border-gray-700">STA</th>
|
||||
<th className="text-left text-gray-400 font-semibold px-2 py-1.5 border-b border-gray-700">Testpoint</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tonightConfigRows.map((row) => (
|
||||
<tr key={row.sta} className="odd:bg-gray-950 even:bg-gray-900/70">
|
||||
<td className="text-gray-200 font-semibold px-2 py-1.5 border-b border-gray-800">{row.sta}</td>
|
||||
<td className="text-gray-300 px-2 py-1.5 border-b border-gray-800">{row.testPoints.join(', ')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
function Field({ label, hint, children }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-300 mb-1">
|
||||
{label}
|
||||
{hint && <span className="ml-1.5 text-gray-500 font-normal">{hint}</span>}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const INPUT_CLS =
|
||||
'w-full bg-gray-900 border border-gray-600 rounded-md px-3 py-1.5 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono'
|
||||
|
||||
const TEXTAREA_CLS = INPUT_CLS + ' resize-none'
|
||||
|
||||
export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
||||
const [form, setForm] = useState({ ...settings })
|
||||
|
||||
// Sync if parent settings change while modal is closed
|
||||
useEffect(() => {
|
||||
if (!isOpen) setForm({ ...settings })
|
||||
}, [isOpen, settings])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
function set(key, value) {
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
onSave(form)
|
||||
onClose()
|
||||
}
|
||||
|
||||
function handleBackdropClick(e) {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="relative bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-xl max-h-[90vh] flex flex-col">
|
||||
{/* Modal header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-700">
|
||||
<h2 className="text-base font-semibold text-white">Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-white transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className="flex flex-col gap-5 overflow-y-auto px-5 py-5">
|
||||
|
||||
{/* Section: Paths */}
|
||||
<div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
||||
File Paths
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Field label="P2P / COE CSV">
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLS}
|
||||
placeholder="/path/to/p2p_coe_tests.csv"
|
||||
value={form.p2pCoeCsvPath ?? ''}
|
||||
onChange={(e) => set('p2pCoeCsvPath', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="P3P CSV">
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLS}
|
||||
placeholder="/path/to/p3p_tests.csv"
|
||||
value={form.p3pCsvPath ?? ''}
|
||||
onChange={(e) => set('p3pCsvPath', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="REF Result Directory">
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLS}
|
||||
placeholder="/path/to/ref/results"
|
||||
value={form.refResultDir ?? ''}
|
||||
onChange={(e) => set('refResultDir', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="DUT Result Directory">
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLS}
|
||||
placeholder="/path/to/dut/results"
|
||||
value={form.dutResultDir ?? ''}
|
||||
onChange={(e) => set('dutResultDir', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-700" />
|
||||
|
||||
{/* Section: Test Exclusion */}
|
||||
<div>
|
||||
<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)">
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLS}
|
||||
placeholder="P2PRXAX001, COERXBE002…"
|
||||
value={form.testExclusion ?? ''}
|
||||
onChange={(e) => set('testExclusion', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-700" />
|
||||
|
||||
{/* Section: Holidays */}
|
||||
<div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
||||
Holidays
|
||||
</p>
|
||||
<Field label="Holiday Dates" hint="(comma-separated, YYYY-MM-DD)">
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLS}
|
||||
placeholder="2026-07-04, 2026-12-25…"
|
||||
value={form.holidays ?? ''}
|
||||
onChange={(e) => set('holidays', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-700" />
|
||||
|
||||
{/* Section: Schedule */}
|
||||
<div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
||||
Schedule
|
||||
</p>
|
||||
<Field label="Start Date Override" hint="(YYYY-MM-DD, leave blank for today)">
|
||||
<input
|
||||
type="date"
|
||||
className={INPUT_CLS}
|
||||
value={form.startDateOverride ?? ''}
|
||||
onChange={(e) => set('startDateOverride', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 px-5 py-4 border-t border-gray-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 text-sm text-gray-300 bg-gray-800 border border-gray-600 rounded-md hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-1.5 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-500 border border-blue-500 rounded-md transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import TestCard from './TestCard'
|
||||
|
||||
export default function ShiftSlot({ label, tests = [], visible = true, active = false }) {
|
||||
if (!visible) return null
|
||||
|
||||
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 className="flex flex-col gap-0.5 px-1 min-h-4">
|
||||
{tests.length === 0 ? (
|
||||
<span className="text-[10px] text-gray-600 italic">—</span>
|
||||
) : (
|
||||
tests.map((test) => <TestCard key={`${test.test_id}-${test.device}`} test={test} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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',
|
||||
failed: 'bg-red-800/60 text-red-200 border-red-600',
|
||||
invalid: 'bg-yellow-800/60 text-yellow-200 border-yellow-600',
|
||||
}
|
||||
|
||||
export default function TestCard({ test }) {
|
||||
const style = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div
|
||||
className={`px-1.5 py-0.5 rounded border text-xs font-mono truncate cursor-default select-none ${style}`}
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
{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">
|
||||
<p><span className="text-gray-400">Type:</span> {test.test_type ?? '—'}</p>
|
||||
<p><span className="text-gray-400">Rotation:</span> {test.rotation ?? '—'}</p>
|
||||
<p><span className="text-gray-400">Device:</span> {test.device ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user