165 lines
5.5 KiB
TypeScript
165 lines
5.5 KiB
TypeScript
import { Switch } from '@astryxdesign/core/Switch';
|
|
import { DEFAULT_SLOTS, slotProblems, type DeliverySlot, type SlotKey } from '@/api/deliverySlots';
|
|
import { Note } from './drawerKit';
|
|
|
|
/**
|
|
* The three delivery windows a branch offers, as an editable block.
|
|
*
|
|
* Used in two places that look the same to a shopkeeper and are quite different
|
|
* underneath: onboarding, where the windows are part of a form that creates a
|
|
* branch, and the shop profile, where they are saved on their own. This
|
|
* component owns neither the saving nor the fetching — it is handed a value and
|
|
* reports changes, so both callers can decide what "save" means for them.
|
|
*
|
|
* ── Three fixed rows, not a list ────────────────────────────────────────────
|
|
*
|
|
* There is no add or remove. A shop has a morning, an afternoon and an evening,
|
|
* and the app has exactly three places to show them. Building this as a generic
|
|
* schedule editor would offer a shopkeeper a fourth window that nothing can
|
|
* render, and the cost of that discovery lands on a customer who picked it.
|
|
*
|
|
* ── Why each row has an on/off ──────────────────────────────────────────────
|
|
*
|
|
* A shop that does not do evenings needs a way to say so that is not deleting
|
|
* the row. Orders already placed against a window still have to resolve to
|
|
* something with a name, so a window is switched off and kept, never removed.
|
|
*/
|
|
export function DeliverySlotsEditor({
|
|
slots,
|
|
onChange,
|
|
isDisabled,
|
|
/**
|
|
* Shown above the rows, and only when a caller asks for it.
|
|
*
|
|
* No default: onboarding explains what a window is because it is new to the
|
|
* person reading, while the shop profile does not, because they are editing
|
|
* something they already set. A paragraph that cannot be turned off is one
|
|
* every screen has to live with.
|
|
*/
|
|
intro,
|
|
}: {
|
|
slots: DeliverySlot[];
|
|
onChange: (slots: DeliverySlot[]) => void;
|
|
isDisabled?: boolean;
|
|
intro?: string;
|
|
}) {
|
|
const problems = slotProblems(slots);
|
|
|
|
function update(key: SlotKey, patch: Partial<DeliverySlot>) {
|
|
onChange(slots.map((slot) => (slot.slotkey === key ? { ...slot, ...patch } : slot)));
|
|
}
|
|
|
|
// Ordered by the constant, not by whatever order the server returned, so the
|
|
// rows do not reshuffle between a fresh branch and a saved one.
|
|
const ordered = DEFAULT_SLOTS.map(
|
|
(fallback) => slots.find((slot) => slot.slotkey === fallback.slotkey) ?? fallback,
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
{intro ? <Note>{intro}</Note> : null}
|
|
|
|
<div style={{ display: 'grid', gap: 4, marginTop: 10 }}>
|
|
{ordered.map((slot) => (
|
|
<SlotRow
|
|
key={slot.slotkey}
|
|
slot={slot}
|
|
isDisabled={Boolean(isDisabled)}
|
|
onChange={(patch) => update(slot.slotkey, patch)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Mirrors the server's own checks so a mistake is caught while the
|
|
shopkeeper's hands are still on it, rather than as a 409 after save. */}
|
|
{problems.length > 0 ? (
|
|
<ul
|
|
style={{
|
|
margin: '10px 0 0',
|
|
paddingLeft: 18,
|
|
fontSize: 11.5,
|
|
color: 'var(--color-error, #d64545)',
|
|
}}
|
|
>
|
|
{problems.map((problem) => (
|
|
<li key={problem}>{problem}</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SlotRow({
|
|
slot,
|
|
onChange,
|
|
isDisabled,
|
|
}: {
|
|
slot: DeliverySlot;
|
|
onChange: (patch: Partial<DeliverySlot>) => void;
|
|
isDisabled: boolean;
|
|
}) {
|
|
const isOff = slot.status !== 'active';
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
padding: '7px 0',
|
|
// A window that is off stays legible but stops competing for attention.
|
|
opacity: isOff ? 0.55 : 1,
|
|
}}
|
|
>
|
|
<input
|
|
value={slot.name}
|
|
onChange={(event) => onChange({ name: event.target.value })}
|
|
placeholder={slot.slotkey}
|
|
disabled={isDisabled}
|
|
aria-label={`Name for the ${slot.slotkey} window`}
|
|
style={{ ...inputStyle, flex: 1, minWidth: 0 }}
|
|
/>
|
|
|
|
<input
|
|
type="time"
|
|
value={slot.starttime}
|
|
onChange={(event) => onChange({ starttime: event.target.value })}
|
|
disabled={isDisabled || isOff}
|
|
aria-label={`Start of the ${slot.slotkey} window`}
|
|
style={{ ...inputStyle, width: 104 }}
|
|
/>
|
|
<span style={{ color: 'var(--color-ink-4)', fontSize: 12 }}>to</span>
|
|
<input
|
|
type="time"
|
|
value={slot.endtime}
|
|
onChange={(event) => onChange({ endtime: event.target.value })}
|
|
disabled={isDisabled || isOff}
|
|
aria-label={`End of the ${slot.slotkey} window — also when it stops taking orders`}
|
|
style={{ ...inputStyle, width: 104 }}
|
|
/>
|
|
|
|
{/* Label hidden: the row's name field says which window this is, and
|
|
repeating "Offer the morning window" three times down a block of three
|
|
is the same sentence three times. */}
|
|
<Switch
|
|
label={`Offer the ${slot.slotkey} window`}
|
|
isLabelHidden
|
|
value={!isOff}
|
|
onChange={(on: boolean) => onChange({ status: on ? 'active' : 'inactive' })}
|
|
isDisabled={isDisabled}
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
padding: '7px 9px',
|
|
fontSize: 13,
|
|
border: '1px solid var(--color-line, #d7dce5)',
|
|
borderRadius: 7,
|
|
background: 'var(--color-surface, #fff)',
|
|
color: 'var(--color-ink-1)',
|
|
};
|