| | | 1 | | import { useEffect, useMemo, useRef, useState } from "react"; |
| | | 2 | | import { useFetcher } from "react-router"; |
| | | 3 | | import type { ItemResponse, LocationResponse, RoomResponse } from "~/api/client"; |
| | | 4 | | import { TuiCombobox, type TuiComboboxOption } from "./tui-combobox"; |
| | | 5 | | |
| | | 6 | | type ActionData = |
| | | 7 | | | { ok: true; intent: "update-item"; item: ItemResponse } |
| | | 8 | | | { ok: true; intent: "delete-item" } |
| | | 9 | | | { ok: false; error: string }; |
| | | 10 | | |
| | 0 | 11 | | export function TuiItemEdit({ item, rooms, locations, onCancel, onSaved, onDelete }: { |
| | | 12 | | item: ItemResponse; |
| | | 13 | | rooms: RoomResponse[]; |
| | | 14 | | locations: LocationResponse[]; |
| | | 15 | | onCancel: () => void; |
| | | 16 | | onSaved: (saved: ItemResponse) => void; |
| | | 17 | | onDelete: () => void; |
| | | 18 | | }) { |
| | 0 | 19 | | const fetcher = useFetcher<ActionData>(); |
| | 0 | 20 | | const submitting = fetcher.state !== "idle"; |
| | 0 | 21 | | const [validationError, setValidationError] = useState<string | null>(null); |
| | 0 | 22 | | const [roomId, setRoomId] = useState<number | null>(item.roomId ?? null); |
| | 0 | 23 | | const formRef = useRef<HTMLFormElement>(null); |
| | | 24 | | |
| | 0 | 25 | | const roomOptions = useMemo<TuiComboboxOption[]>(() => { |
| | 0 | 26 | | const opts: TuiComboboxOption[] = []; |
| | 0 | 27 | | for (const loc of locations) { |
| | 0 | 28 | | const locRooms = rooms.filter(r => r.locationId === loc.id); |
| | 0 | 29 | | if (locRooms.length === 0) continue; |
| | 0 | 30 | | opts.push({ kind: "group", label: loc.name ?? "Location" }); |
| | 0 | 31 | | for (const r of locRooms) { |
| | 0 | 32 | | opts.push({ kind: "option", label: r.name ?? "Room", value: r.id! }); |
| | | 33 | | } |
| | | 34 | | } |
| | 0 | 35 | | return opts; |
| | | 36 | | }, [rooms, locations]); |
| | | 37 | | |
| | 0 | 38 | | const actionError = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok |
| | | 39 | | ? fetcher.data.error : null; |
| | 0 | 40 | | const error = validationError ?? actionError; |
| | | 41 | | |
| | 0 | 42 | | const onSavedRef = useRef(onSaved); |
| | 0 | 43 | | useEffect(() => { onSavedRef.current = onSaved; }); |
| | | 44 | | |
| | 0 | 45 | | const wasSubmittingRef = useRef(false); |
| | 0 | 46 | | useEffect(() => { |
| | 0 | 47 | | if (fetcher.state === "submitting") { wasSubmittingRef.current = true; return; } |
| | 0 | 48 | | if (!wasSubmittingRef.current || fetcher.state !== "idle" || !fetcher.data) return; |
| | 0 | 49 | | wasSubmittingRef.current = false; |
| | 0 | 50 | | if (fetcher.data.ok && fetcher.data.intent === "update-item") { |
| | 0 | 51 | | onSavedRef.current(fetcher.data.item); |
| | | 52 | | } |
| | | 53 | | }, [fetcher.state, fetcher.data]); |
| | | 54 | | |
| | 0 | 55 | | function submitForm() { |
| | 0 | 56 | | const form = formRef.current; |
| | 0 | 57 | | if (!form) return; |
| | 0 | 58 | | const fd = new FormData(form); |
| | 0 | 59 | | const name = (fd.get("name") as string ?? "").trim(); |
| | 0 | 60 | | if (!name) { setValidationError("name is required"); return; } |
| | 0 | 61 | | setValidationError(null); |
| | 0 | 62 | | fetcher.submit(fd, { method: "post" }); |
| | | 63 | | } |
| | | 64 | | |
| | 0 | 65 | | function handleKeyDown(e: React.KeyboardEvent<HTMLFormElement>) { |
| | 0 | 66 | | if (e.key === "Escape") { |
| | 0 | 67 | | e.preventDefault(); |
| | 0 | 68 | | onCancel(); |
| | 0 | 69 | | return; |
| | | 70 | | } |
| | 0 | 71 | | if ((e.ctrlKey || e.metaKey) && (e.key === "Enter" || e.key === "s" || e.key === "S")) { |
| | 0 | 72 | | e.preventDefault(); |
| | 0 | 73 | | submitForm(); |
| | | 74 | | } |
| | | 75 | | } |
| | | 76 | | |
| | 0 | 77 | | return ( |
| | | 78 | | <fetcher.Form |
| | | 79 | | method="post" |
| | | 80 | | ref={formRef} |
| | | 81 | | onKeyDown={handleKeyDown} |
| | 0 | 82 | | onSubmit={(e) => { e.preventDefault(); submitForm(); }} |
| | | 83 | | className="tui-panel tui-detail tui-edit" |
| | | 84 | | style={{ flexShrink: 0, height: 200 }} |
| | | 85 | | > |
| | | 86 | | <span className="tui-panel-title">{`─[ edit · #${item.id} ]─`}</span> |
| | | 87 | | |
| | | 88 | | <input type="hidden" name="_intent" value="update-item" /> |
| | | 89 | | <input type="hidden" name="itemId" value={item.id ?? ""} /> |
| | | 90 | | |
| | | 91 | | <div className="tui-detail-grid tui-edit-grid"> |
| | | 92 | | <EditField label="name" name="name" defaultValue={item.name ?? ""} autoFocus required /> |
| | | 93 | | <EditField label="category" name="category" defaultValue={item.category ?? ""} /> |
| | | 94 | | <div className="tui-detail-field tui-edit-row tui-edit-row--combo"> |
| | | 95 | | <span className="tui-detail-label">{"room "}</span> |
| | | 96 | | <span className="tui-detail-colon">:</span>{" "} |
| | | 97 | | <TuiCombobox |
| | | 98 | | name="roomId" |
| | | 99 | | value={roomId} |
| | | 100 | | options={roomOptions} |
| | | 101 | | onChange={setRoomId} |
| | | 102 | | disabled={submitting} |
| | | 103 | | ariaLabel="Room" |
| | | 104 | | /> |
| | | 105 | | </div> |
| | | 106 | | <ReadField label="location" value={resolveLocationName(roomId, rooms, locations)} /> |
| | | 107 | | <EditField label="desc" name="description" defaultValue={item.description ?? ""} fullWidth /> |
| | | 108 | | <div className="tui-detail-notes tui-edit-row"> |
| | | 109 | | <span className="tui-detail-label">notes </span> |
| | | 110 | | <span className="tui-detail-colon">:</span>{" "} |
| | | 111 | | <span className="tui-edit-bracket">[</span> |
| | | 112 | | <input |
| | | 113 | | type="text" |
| | | 114 | | name="notes" |
| | | 115 | | defaultValue={item.notes ?? ""} |
| | | 116 | | className="tui-edit-input" |
| | | 117 | | disabled={submitting} |
| | | 118 | | /> |
| | | 119 | | <span className="tui-edit-bracket">]</span> |
| | | 120 | | </div> |
| | | 121 | | </div> |
| | | 122 | | |
| | | 123 | | {error && <div className="tui-edit-error">! {error}</div>} |
| | | 124 | | |
| | | 125 | | <div className="tui-detail-actions"> |
| | | 126 | | <button type="submit" disabled={submitting} className="tui-detail-action"> |
| | | 127 | | [^S] {submitting ? "saving…" : "save"} |
| | | 128 | | </button> |
| | | 129 | | <span className="tui-detail-sep">·</span> |
| | | 130 | | <button type="button" onClick={onCancel} disabled={submitting} className="tui-detail-action"> |
| | | 131 | | [Esc] cancel |
| | | 132 | | </button> |
| | | 133 | | <span className="tui-detail-sep">·</span> |
| | | 134 | | <button |
| | | 135 | | type="button" |
| | | 136 | | onClick={onDelete} |
| | | 137 | | disabled={submitting} |
| | | 138 | | className="tui-detail-action tui-detail-action--danger" |
| | | 139 | | > |
| | | 140 | | [d] delete |
| | | 141 | | </button> |
| | | 142 | | </div> |
| | | 143 | | </fetcher.Form> |
| | | 144 | | ); |
| | | 145 | | } |
| | | 146 | | |
| | 0 | 147 | | function EditField({ label, name, defaultValue, autoFocus, required, fullWidth }: { |
| | | 148 | | label: string; |
| | | 149 | | name: string; |
| | | 150 | | defaultValue: string; |
| | | 151 | | autoFocus?: boolean; |
| | | 152 | | required?: boolean; |
| | | 153 | | fullWidth?: boolean; |
| | | 154 | | }) { |
| | 0 | 155 | | return ( |
| | | 156 | | <div className={`tui-detail-field tui-edit-row${fullWidth ? " tui-edit-row--full" : ""}`}> |
| | | 157 | | <span className="tui-detail-label">{label.padEnd(8, " ")}</span> |
| | | 158 | | <span className="tui-detail-colon">:</span>{" "} |
| | | 159 | | <span className="tui-edit-bracket">[</span> |
| | | 160 | | <input |
| | | 161 | | type="text" |
| | | 162 | | name={name} |
| | | 163 | | defaultValue={defaultValue} |
| | | 164 | | autoFocus={autoFocus} |
| | | 165 | | required={required} |
| | | 166 | | className="tui-edit-input" |
| | | 167 | | /> |
| | | 168 | | <span className="tui-edit-bracket">]</span> |
| | | 169 | | </div> |
| | | 170 | | ); |
| | | 171 | | } |
| | | 172 | | |
| | 0 | 173 | | function ReadField({ label, value }: { label: string; value: React.ReactNode }) { |
| | 0 | 174 | | return ( |
| | | 175 | | <div className="tui-detail-field"> |
| | | 176 | | <span className="tui-detail-label">{label.padEnd(8, " ")}</span> |
| | | 177 | | <span className="tui-detail-colon">:</span>{" "} |
| | | 178 | | <span className="tui-detail-value">{value}</span> |
| | | 179 | | </div> |
| | | 180 | | ); |
| | | 181 | | } |
| | | 182 | | |
| | 0 | 183 | | function resolveLocationName( |
| | | 184 | | roomId: number | null, |
| | | 185 | | rooms: RoomResponse[], |
| | | 186 | | locations: LocationResponse[], |
| | | 187 | | ): string { |
| | 0 | 188 | | if (roomId == null) return "—"; |
| | 0 | 189 | | const room = rooms.find(r => r.id === roomId); |
| | 0 | 190 | | if (!room || room.locationId == null) return "—"; |
| | 0 | 191 | | const loc = locations.find(l => l.id === room.locationId); |
| | 0 | 192 | | return loc?.name ?? "—"; |
| | | 193 | | } |