Adds two sub-path editors under the Playouts screen:
- /app/playouts/{id}/alternate-schedules (Classic playouts)
- /app/playouts/{id}/templates (Block playouts)
Each has a reorderable priority table (up/down/delete) plus a selected-row
detail panel: schedule or template+deco-template pickers, a limit-to-date-range
toggle gating month/day/year selects, and day-of-week / day-of-month / month
multi-select chips with Weekdays/Weekends helpers. Entry points on the playout
card are kind-gated. The Block playout card also gets a default-deco select
wired to PUT /api/playouts/{id}/deco, using the new decoName read field.
DayOfWeek is overridden to day-name strings in the API client (the wire format
is Newtonsoft StringEnumConverter, though the OpenAPI schema types it as number).
Deviations from Blazor: chip multi-select instead of MudSelect, flat grouped
template pickers instead of a group->item cascade, a shorter year range, and the
template calendar preview is omitted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
421 lines
13 KiB
TypeScript
421 lines
13 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { ApiError, request } from './client';
|
|
import type { components } from './generated/v1';
|
|
|
|
export type PlayoutSummary = components['schemas']['PlayoutListItemResponseModel'];
|
|
export type PlayoutDetail = components['schemas']['PlayoutResponseModel'];
|
|
export type PlayoutItem = components['schemas']['PlayoutItemResponseModel'];
|
|
export type PlayoutsPage = components['schemas']['PagedPlayoutsResponseModel'];
|
|
export type PlayoutItemsPage = components['schemas']['PagedPlayoutItemsResponseModel'];
|
|
export type PlayoutChannelState = components['schemas']['ChannelStateResponseModel'];
|
|
export type PlayoutScheduleKind = components['schemas']['PlayoutScheduleKind'];
|
|
export type CreatePlayoutRequest = components['schemas']['CreatePlayoutRequest'];
|
|
export type UpdatePlayoutDetailsRequest = components['schemas']['UpdatePlayoutDetailsRequest'];
|
|
|
|
// The OpenAPI generator types DayOfWeek as `number` (the OpenAPI schema is derived from
|
|
// System.Text.Json metadata), but the MVC pipeline serializes with Newtonsoft + StringEnumConverter,
|
|
// so DayOfWeek is a day-name string on the wire ("Sunday" … "Saturday"). We override the generated
|
|
// `daysOfWeek` shape accordingly on both request and response DTOs.
|
|
export type DayOfWeek =
|
|
| 'Sunday'
|
|
| 'Monday'
|
|
| 'Tuesday'
|
|
| 'Wednesday'
|
|
| 'Thursday'
|
|
| 'Friday'
|
|
| 'Saturday';
|
|
|
|
// Monday-first, matching the Blazor editor's ordering.
|
|
export const DAYS_OF_WEEK: DayOfWeek[] = [
|
|
'Monday',
|
|
'Tuesday',
|
|
'Wednesday',
|
|
'Thursday',
|
|
'Friday',
|
|
'Saturday',
|
|
'Sunday'
|
|
];
|
|
|
|
type WithDayNames<T> = Omit<T, 'daysOfWeek'> & { daysOfWeek: DayOfWeek[] };
|
|
|
|
export type PlayoutAlternateSchedule = WithDayNames<components['schemas']['PlayoutAlternateScheduleResponseModel']>;
|
|
export type PlayoutAlternateScheduleItemRequest = WithDayNames<
|
|
components['schemas']['PlayoutAlternateScheduleItemRequest']
|
|
>;
|
|
export type PlayoutTemplate = WithDayNames<components['schemas']['PlayoutTemplateResponseModel']>;
|
|
export type PlayoutTemplateItemRequest = WithDayNames<components['schemas']['PlayoutTemplateItemRequest']>;
|
|
|
|
// Declared as `type` (not `interface`) so they satisfy the client's `Record<string, unknown>`
|
|
// RequestBody bound, matching the generated request DTOs.
|
|
export type ReplacePlayoutAlternateSchedulesRequest = {
|
|
items: PlayoutAlternateScheduleItemRequest[];
|
|
};
|
|
|
|
export type ReplacePlayoutTemplatesRequest = {
|
|
items: PlayoutTemplateItemRequest[];
|
|
};
|
|
|
|
export interface PlayoutsScreenData {
|
|
channelStates: PlayoutChannelState[];
|
|
items: PlayoutItem[];
|
|
playout: PlayoutDetail | null;
|
|
playouts: PlayoutSummary[];
|
|
selectedPlayoutId: number | null;
|
|
totalCount: number;
|
|
warningsCount: number;
|
|
}
|
|
|
|
type PlayoutsScreenBase = Omit<PlayoutsScreenData, 'items' | 'playout'>;
|
|
|
|
export type PlayoutsScreenQueryState =
|
|
| {
|
|
data: PlayoutsScreenData;
|
|
error: null;
|
|
itemsLoading: boolean;
|
|
refresh: () => void;
|
|
setActivePlayout: (playoutId: number) => void;
|
|
setShowFiller: (showFiller: boolean) => void;
|
|
showFiller: boolean;
|
|
status: 'success';
|
|
}
|
|
| { data: null; error: string; refresh: () => void; status: 'error' }
|
|
| { data: null; error: null; refresh: () => void; status: 'loading' };
|
|
|
|
type PlayoutsScreenState =
|
|
| { data: PlayoutsScreenData; error: null; itemsLoading: boolean; status: 'success' }
|
|
| { data: null; error: string; status: 'error' }
|
|
| { data: null; error: null; status: 'loading' };
|
|
|
|
export function getPlayouts(): Promise<PlayoutsPage> {
|
|
return request<PlayoutsPage>('/api/playouts');
|
|
}
|
|
|
|
export function getPlayout(playoutId: number): Promise<PlayoutDetail> {
|
|
return request<PlayoutDetail>(`/api/playouts/${playoutId}`);
|
|
}
|
|
|
|
export function getPlayoutItems(playoutId: number, showFiller = false): Promise<PlayoutItemsPage> {
|
|
return request<PlayoutItemsPage>(`/api/playouts/${playoutId}/items${showFiller ? '?showFiller=true' : ''}`);
|
|
}
|
|
|
|
export function getPlayoutWarningsCount(): Promise<number> {
|
|
return request<number>('/api/playouts/warnings/count');
|
|
}
|
|
|
|
export function getPlayoutChannelStates(): Promise<PlayoutChannelState[]> {
|
|
return request<PlayoutChannelState[]>('/api/channels/state');
|
|
}
|
|
|
|
export function resetAllPlayouts(): Promise<void> {
|
|
return request<void>('/api/playouts/reset-all', { method: 'POST' });
|
|
}
|
|
|
|
export function createPlayout(body: CreatePlayoutRequest): Promise<PlayoutDetail> {
|
|
return request<PlayoutDetail>('/api/playouts', {
|
|
body,
|
|
method: 'POST'
|
|
});
|
|
}
|
|
|
|
export function updatePlayoutDetails(playoutId: number, body: UpdatePlayoutDetailsRequest): Promise<PlayoutDetail> {
|
|
return request<PlayoutDetail>(`/api/playouts/${playoutId}`, {
|
|
body,
|
|
method: 'PUT'
|
|
});
|
|
}
|
|
|
|
export function updatePlayoutDefaultDeco(playoutId: number, decoId: number | null): Promise<PlayoutDetail> {
|
|
return request<PlayoutDetail>(`/api/playouts/${playoutId}/deco`, {
|
|
body: { decoId },
|
|
method: 'PUT'
|
|
});
|
|
}
|
|
|
|
export function getAlternateSchedules(playoutId: number): Promise<PlayoutAlternateSchedule[]> {
|
|
return request<PlayoutAlternateSchedule[]>(`/api/playouts/${playoutId}/alternate-schedules`);
|
|
}
|
|
|
|
export function replaceAlternateSchedules(
|
|
playoutId: number,
|
|
body: ReplacePlayoutAlternateSchedulesRequest
|
|
): Promise<PlayoutAlternateSchedule[]> {
|
|
return request<PlayoutAlternateSchedule[]>(`/api/playouts/${playoutId}/alternate-schedules`, {
|
|
body,
|
|
method: 'PUT'
|
|
});
|
|
}
|
|
|
|
export function getPlayoutTemplates(playoutId: number): Promise<PlayoutTemplate[]> {
|
|
return request<PlayoutTemplate[]>(`/api/playouts/${playoutId}/templates`);
|
|
}
|
|
|
|
export function replacePlayoutTemplates(
|
|
playoutId: number,
|
|
body: ReplacePlayoutTemplatesRequest
|
|
): Promise<PlayoutTemplate[]> {
|
|
return request<PlayoutTemplate[]>(`/api/playouts/${playoutId}/templates`, {
|
|
body,
|
|
method: 'PUT'
|
|
});
|
|
}
|
|
|
|
export function messageFromPlayoutClientError(error: unknown, fallback = 'Unable to load playout'): string {
|
|
return messageFromPlayoutError(error, fallback);
|
|
}
|
|
|
|
export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState {
|
|
const [state, setState] = useState<PlayoutsScreenState>({
|
|
data: null,
|
|
error: null,
|
|
status: 'loading'
|
|
});
|
|
const [showFiller, setShowFillerState] = useState(false);
|
|
const activeRef = useRef(true);
|
|
const selectedPlayoutIdRef = useRef<number | null>(null);
|
|
const showFillerRef = useRef(false);
|
|
// Snapshot of the last-known base fields (everything but the selected playout's
|
|
// detail/items), kept in sync whenever load()/loadChannelStates() succeed. Reading
|
|
// this lets setActivePlayout/setShowFiller kick off their fetch without doing the
|
|
// fetch from inside a setState updater (impure; StrictMode double-invokes updaters).
|
|
const baseRef = useRef<PlayoutsScreenBase>({
|
|
channelStates: [],
|
|
playouts: [],
|
|
selectedPlayoutId: null,
|
|
totalCount: 0,
|
|
warningsCount: 0
|
|
});
|
|
|
|
useEffect(() => {
|
|
activeRef.current = true;
|
|
|
|
return () => {
|
|
activeRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
const loadSelectedPlayout = useCallback((playoutId: number, base: PlayoutsScreenBase) => {
|
|
baseRef.current = base;
|
|
|
|
Promise.all([getPlayout(playoutId), getPlayoutItems(playoutId, showFillerRef.current)])
|
|
.then(([playout, itemsPage]) => {
|
|
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
|
|
return;
|
|
}
|
|
|
|
setState((current) => {
|
|
// Merge onto whatever channel state the background poll may have landed
|
|
// while this request was in flight, rather than clobbering it with the
|
|
// snapshot captured when the request started.
|
|
const channelStates = current.status === 'success' ? current.data.channelStates : base.channelStates;
|
|
|
|
return {
|
|
data: {
|
|
...base,
|
|
channelStates,
|
|
items: itemsPage.page ?? [],
|
|
playout,
|
|
selectedPlayoutId: playoutId
|
|
},
|
|
error: null,
|
|
itemsLoading: false,
|
|
status: 'success'
|
|
};
|
|
});
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (activeRef.current && selectedPlayoutIdRef.current === playoutId) {
|
|
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
const load = useCallback(() => {
|
|
Promise.all([getPlayouts(), getPlayoutWarningsCount(), getPlayoutChannelStates()])
|
|
.then(([playoutsPage, warningsCount, channelStates]) => {
|
|
if (!activeRef.current) {
|
|
return;
|
|
}
|
|
|
|
const playouts = playoutsPage.page ?? [];
|
|
const selectedPlayoutId = selectedPlayoutIdRef.current && playouts.some((playout) => playout.id === selectedPlayoutIdRef.current)
|
|
? selectedPlayoutIdRef.current
|
|
: playouts[0]?.id ?? null;
|
|
selectedPlayoutIdRef.current = selectedPlayoutId;
|
|
|
|
const base: PlayoutsScreenBase = {
|
|
channelStates,
|
|
playouts,
|
|
selectedPlayoutId,
|
|
totalCount: playoutsPage.totalCount,
|
|
warningsCount
|
|
};
|
|
baseRef.current = base;
|
|
|
|
if (selectedPlayoutId == null) {
|
|
setState({
|
|
data: {
|
|
...base,
|
|
items: [],
|
|
playout: null
|
|
},
|
|
error: null,
|
|
itemsLoading: false,
|
|
status: 'success'
|
|
});
|
|
return;
|
|
}
|
|
|
|
loadSelectedPlayout(selectedPlayoutId, base);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (activeRef.current) {
|
|
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
|
|
}
|
|
});
|
|
}, [loadSelectedPlayout]);
|
|
|
|
const loadChannelStates = useCallback(() => {
|
|
getPlayoutChannelStates()
|
|
.then((channelStates) => {
|
|
if (!activeRef.current) {
|
|
return;
|
|
}
|
|
|
|
baseRef.current = { ...baseRef.current, channelStates };
|
|
|
|
setState((current) => {
|
|
if (current.status !== 'success') {
|
|
return current;
|
|
}
|
|
|
|
return {
|
|
data: { ...current.data, channelStates },
|
|
error: null,
|
|
itemsLoading: current.itemsLoading,
|
|
status: 'success'
|
|
};
|
|
});
|
|
})
|
|
.catch(() => {
|
|
// Keep the monitor visible on background polling failures.
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
|
|
const intervalId = window.setInterval(loadChannelStates, pollMs);
|
|
|
|
return () => {
|
|
window.clearInterval(intervalId);
|
|
};
|
|
}, [load, loadChannelStates, pollMs]);
|
|
|
|
const refresh = useCallback(() => {
|
|
setState({ data: null, error: null, status: 'loading' });
|
|
load();
|
|
}, [load]);
|
|
|
|
const setActivePlayout = useCallback((playoutId: number) => {
|
|
selectedPlayoutIdRef.current = playoutId;
|
|
|
|
setState((current) => {
|
|
if (current.status !== 'success') {
|
|
return current;
|
|
}
|
|
|
|
return {
|
|
data: {
|
|
...current.data,
|
|
items: [],
|
|
playout: null,
|
|
selectedPlayoutId: playoutId
|
|
},
|
|
error: null,
|
|
itemsLoading: true,
|
|
status: 'success'
|
|
};
|
|
});
|
|
|
|
const base: PlayoutsScreenBase = { ...baseRef.current, selectedPlayoutId: playoutId };
|
|
loadSelectedPlayout(playoutId, base);
|
|
}, [loadSelectedPlayout]);
|
|
|
|
const setShowFiller = useCallback((next: boolean) => {
|
|
showFillerRef.current = next;
|
|
setShowFillerState(next);
|
|
|
|
const playoutId = selectedPlayoutIdRef.current;
|
|
|
|
if (playoutId == null) {
|
|
return;
|
|
}
|
|
|
|
setState((current) => {
|
|
if (current.status !== 'success') {
|
|
return current;
|
|
}
|
|
|
|
return { ...current, itemsLoading: true };
|
|
});
|
|
|
|
getPlayoutItems(playoutId, next)
|
|
.then((itemsPage) => {
|
|
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
|
|
return;
|
|
}
|
|
|
|
setState((current) => {
|
|
if (current.status !== 'success') {
|
|
return current;
|
|
}
|
|
|
|
return {
|
|
data: { ...current.data, items: itemsPage.page ?? [] },
|
|
error: null,
|
|
itemsLoading: false,
|
|
status: 'success'
|
|
};
|
|
});
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
|
|
return;
|
|
}
|
|
|
|
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
|
|
});
|
|
}, []);
|
|
|
|
if (state.status === 'success') {
|
|
return {
|
|
data: state.data,
|
|
error: null,
|
|
itemsLoading: state.itemsLoading,
|
|
refresh,
|
|
setActivePlayout,
|
|
setShowFiller,
|
|
showFiller,
|
|
status: 'success'
|
|
};
|
|
}
|
|
|
|
if (state.status === 'error') {
|
|
return { data: null, error: state.error, refresh, status: 'error' };
|
|
}
|
|
|
|
return { data: null, error: null, refresh, status: 'loading' };
|
|
}
|
|
|
|
function messageFromPlayoutError(error: unknown, fallback = 'Unable to load playouts'): string {
|
|
if (error instanceof ApiError) {
|
|
return error.detail ?? error.message;
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
|
|
return fallback;
|
|
}
|