| | | 1 | | import { useEffect, useRef, useState } from "react"; |
| | | 2 | | import { useFetcher } from "react-router"; |
| | | 3 | | import type { ItemResponse, LocationResponse, RoomResponse } from "~/api/client"; |
| | | 4 | | import { FormField, PanelHeader } from "~/components/panel-ui"; |
| | | 5 | | import { inputStyle } from "~/lib/styles"; |
| | | 6 | | |
| | | 7 | | type ActionData = |
| | | 8 | | | { ok: true; intent: "create-item" | "update-item"; item: ItemResponse } |
| | | 9 | | | { ok: true; intent: "delete-item" } |
| | | 10 | | | { ok: false; error: string }; |
| | | 11 | | |
| | 0 | 12 | | export function ItemEditPanel({ item, rooms, locations, defaultRoomId, onClose, onSaved, onDeleted, onNewRoom }: { |
| | | 13 | | item?: ItemResponse; |
| | | 14 | | rooms: RoomResponse[]; |
| | | 15 | | locations: LocationResponse[]; |
| | | 16 | | defaultRoomId?: number; |
| | | 17 | | onClose: () => void; |
| | | 18 | | onSaved: (saved: ItemResponse) => void; |
| | | 19 | | onDeleted: () => void; |
| | | 20 | | onNewRoom?: (locationId?: number) => void; |
| | | 21 | | }) { |
| | 0 | 22 | | const fetcher = useFetcher<ActionData>(); |
| | 0 | 23 | | const [validationError, setValidationError] = useState<string | null>(null); |
| | 0 | 24 | | const isNew = !item; |
| | 0 | 25 | | const submitting = fetcher.state !== "idle"; |
| | 0 | 26 | | const locationById = Object.fromEntries(locations.map(l => [l.id!, l])); |
| | | 27 | | |
| | | 28 | | // Derive server-side error from fetcher result during render (no setState needed) |
| | 0 | 29 | | const actionError = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok |
| | | 30 | | ? fetcher.data.error : null; |
| | 0 | 31 | | const error = validationError ?? actionError; |
| | | 32 | | |
| | 0 | 33 | | const onSavedRef = useRef(onSaved); |
| | 0 | 34 | | const onDeletedRef = useRef(onDeleted); |
| | 0 | 35 | | useEffect(() => { onSavedRef.current = onSaved; onDeletedRef.current = onDeleted; }); |
| | | 36 | | |
| | | 37 | | // Only call success callbacks — no setState here (error is derived in render) |
| | 0 | 38 | | const fetchedRef = useRef(false); |
| | 0 | 39 | | useEffect(() => { |
| | 0 | 40 | | if (fetcher.state === "submitting") { fetchedRef.current = true; return; } |
| | 0 | 41 | | if (!fetchedRef.current || fetcher.state !== "idle" || !fetcher.data) return; |
| | 0 | 42 | | fetchedRef.current = false; |
| | 0 | 43 | | const data = fetcher.data; |
| | 0 | 44 | | if (!data.ok) return; |
| | 0 | 45 | | if (data.intent === "create-item" || data.intent === "update-item") onSavedRef.current(data.item); |
| | 0 | 46 | | else if (data.intent === "delete-item") onDeletedRef.current(); |
| | | 47 | | }, [fetcher.state, fetcher.data]); |
| | | 48 | | |
| | 0 | 49 | | function handleSubmit(e: React.FormEvent<HTMLFormElement>) { |
| | 0 | 50 | | e.preventDefault(); |
| | 0 | 51 | | const fd = new FormData(e.currentTarget); |
| | 0 | 52 | | const name = (fd.get("name") as string ?? "").trim(); |
| | 0 | 53 | | if (!name) { setValidationError("Name is required."); return; } |
| | 0 | 54 | | setValidationError(null); |
| | 0 | 55 | | fetcher.submit(fd, { method: "post" }); |
| | | 56 | | } |
| | | 57 | | |
| | 0 | 58 | | function handleDelete() { |
| | 0 | 59 | | if (!item?.id || !confirm("Delete this item?")) return; |
| | 0 | 60 | | fetcher.submit( |
| | | 61 | | { _intent: "delete-item", itemId: String(item.id) }, |
| | | 62 | | { method: "post" }, |
| | | 63 | | ); |
| | | 64 | | } |
| | | 65 | | |
| | 0 | 66 | | return ( |
| | | 67 | | <aside className="tui-panel" style={{ |
| | | 68 | | width: 340, borderLeft: "1px solid var(--c-border)", |
| | | 69 | | background: "var(--c-bg-2)", flexShrink: 0, |
| | | 70 | | display: "flex", flexDirection: "column", overflowY: "auto", |
| | | 71 | | }}> |
| | | 72 | | <span className="tui-panel-title">{isNew ? "─[ new item ]─" : `─[ edit · #${item.id} ]─`}</span> |
| | | 73 | | <PanelHeader |
| | | 74 | | label={isNew ? "NEW ITEM" : `EDIT ITEM #${item.id}`} |
| | | 75 | | onClose={onClose} |
| | | 76 | | /> |
| | | 77 | | |
| | | 78 | | <form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 14, padding: "16px 16px 0", |
| | | 79 | | <input type="hidden" name="_intent" value={isNew ? "create-item" : "update-item"} /> |
| | | 80 | | {!isNew && <input type="hidden" name="itemId" value={item.id} />} |
| | | 81 | | |
| | | 82 | | {error && ( |
| | | 83 | | <div style={{ fontSize: 12, color: "#ef4444", padding: "6px 10px", background: "rgba(239,68,68,0.08)", borderR |
| | | 84 | | {error} |
| | | 85 | | </div> |
| | | 86 | | )} |
| | | 87 | | |
| | | 88 | | {isNew && ( |
| | | 89 | | <FormField label="Room"> |
| | | 90 | | <div style={{ display: "flex", gap: 6, alignItems: "center" }}> |
| | | 91 | | <select name="roomId" defaultValue={defaultRoomId ?? rooms[0]?.id ?? ""} style={{ ...inputStyle, flex: 1 } |
| | 0 | 92 | | {locations.map(loc => { |
| | 0 | 93 | | const locRooms = rooms.filter(r => r.locationId === loc.id); |
| | 0 | 94 | | if (locRooms.length === 0) return null; |
| | 0 | 95 | | return ( |
| | | 96 | | <optgroup key={loc.id} label={loc.name ?? "Location"}> |
| | 0 | 97 | | {locRooms.map(r => ( |
| | 0 | 98 | | <option key={r.id} value={r.id}>{r.name}</option> |
| | | 99 | | ))} |
| | | 100 | | </optgroup> |
| | | 101 | | ); |
| | | 102 | | })} |
| | | 103 | | </select> |
| | | 104 | | {onNewRoom && ( |
| | | 105 | | <button |
| | | 106 | | type="button" |
| | 0 | 107 | | onClick={() => onNewRoom()} |
| | | 108 | | title="New room" |
| | | 109 | | style={{ |
| | | 110 | | flexShrink: 0, padding: "7px 10px", borderRadius: 6, fontSize: 13, |
| | | 111 | | border: "1px solid var(--c-border)", background: "var(--c-bg-3)", |
| | | 112 | | color: "var(--c-fg-2)", cursor: "pointer", fontFamily: "inherit", |
| | | 113 | | }} |
| | | 114 | | >+</button> |
| | | 115 | | )} |
| | | 116 | | </div> |
| | | 117 | | </FormField> |
| | | 118 | | )} |
| | | 119 | | |
| | | 120 | | {!isNew && item.roomId != null && ( |
| | | 121 | | <> |
| | | 122 | | {/* Backend's UpdateItemRequest requires RoomId>=1; the read-only display |
| | | 123 | | wouldn't post a value, so carry it through with a hidden input. */} |
| | | 124 | | <input type="hidden" name="roomId" value={item.roomId} /> |
| | | 125 | | <FormField label="Room"> |
| | | 126 | | <div style={{ ...inputStyle, color: "var(--c-fg-2)", cursor: "default" }}> |
| | 0 | 127 | | {(() => { |
| | 0 | 128 | | const room = rooms.find(r => r.id === item.roomId); |
| | 0 | 129 | | const loc = room?.locationId != null ? locationById[room.locationId] : null; |
| | 0 | 130 | | return loc ? `${loc.name} / ${room?.name}` : (room?.name ?? "—"); |
| | | 131 | | })()} |
| | | 132 | | </div> |
| | | 133 | | </FormField> |
| | | 134 | | </> |
| | | 135 | | )} |
| | | 136 | | |
| | | 137 | | <FormField label="Name *"> |
| | | 138 | | <input |
| | | 139 | | name="name" type="text" required autoFocus |
| | | 140 | | defaultValue={item?.name ?? ""} |
| | | 141 | | placeholder="e.g. Vintage Lamp" |
| | | 142 | | style={inputStyle} |
| | | 143 | | /> |
| | | 144 | | </FormField> |
| | | 145 | | |
| | | 146 | | <FormField label="Description"> |
| | | 147 | | <textarea |
| | | 148 | | name="description" rows={2} |
| | | 149 | | defaultValue={item?.description ?? ""} |
| | | 150 | | placeholder="e.g. Brass table lamp, 1980s" |
| | | 151 | | style={{ ...inputStyle, resize: "vertical" }} |
| | | 152 | | /> |
| | | 153 | | </FormField> |
| | | 154 | | |
| | | 155 | | <FormField label="Category"> |
| | | 156 | | <input |
| | | 157 | | name="category" type="text" |
| | | 158 | | defaultValue={item?.category ?? ""} |
| | | 159 | | placeholder="e.g. Electronics, Furniture" |
| | | 160 | | style={inputStyle} |
| | | 161 | | /> |
| | | 162 | | </FormField> |
| | | 163 | | |
| | | 164 | | <FormField label="Notes"> |
| | | 165 | | <textarea |
| | | 166 | | name="notes" rows={3} |
| | | 167 | | defaultValue={item?.notes ?? ""} |
| | | 168 | | placeholder="e.g. Needs new shade" |
| | | 169 | | style={{ ...inputStyle, resize: "vertical" }} |
| | | 170 | | /> |
| | | 171 | | </FormField> |
| | | 172 | | |
| | | 173 | | <div style={{ display: "flex", gap: 8, paddingBottom: 4 }}> |
| | | 174 | | <button |
| | | 175 | | type="submit" disabled={submitting} |
| | | 176 | | style={{ |
| | | 177 | | flex: 1, padding: "8px 14px", borderRadius: 6, |
| | | 178 | | border: "none", background: "var(--c-accent)", color: "#fff", |
| | | 179 | | fontSize: 13, fontWeight: 500, cursor: submitting ? "not-allowed" : "pointer", |
| | | 180 | | opacity: submitting ? 0.7 : 1, fontFamily: "inherit", |
| | | 181 | | }} |
| | | 182 | | > |
| | | 183 | | {submitting ? "Saving…" : (isNew ? "Create item" : "Save changes")} |
| | | 184 | | </button> |
| | | 185 | | <button |
| | | 186 | | type="button" onClick={onClose} disabled={submitting} |
| | | 187 | | style={{ |
| | | 188 | | padding: "8px 14px", borderRadius: 6, |
| | | 189 | | border: "1px solid var(--c-border)", background: "transparent", |
| | | 190 | | color: "var(--c-fg-2)", fontSize: 13, cursor: "pointer", fontFamily: "inherit", |
| | | 191 | | }} |
| | | 192 | | > |
| | | 193 | | Cancel |
| | | 194 | | </button> |
| | | 195 | | </div> |
| | | 196 | | |
| | | 197 | | {!isNew && ( |
| | | 198 | | <div style={{ marginTop: "auto", paddingTop: 16, paddingBottom: 20, borderTop: "1px solid var(--c-border)" }}> |
| | | 199 | | <button |
| | | 200 | | type="button" onClick={handleDelete} disabled={submitting} |
| | | 201 | | style={{ |
| | | 202 | | width: "100%", padding: "7px 14px", borderRadius: 6, |
| | | 203 | | border: "1px solid rgba(239,68,68,0.4)", background: "rgba(239,68,68,0.07)", |
| | | 204 | | color: "#ef4444", fontSize: 13, cursor: submitting ? "not-allowed" : "pointer", |
| | | 205 | | fontFamily: "inherit", |
| | | 206 | | }} |
| | | 207 | | > |
| | | 208 | | Delete item |
| | | 209 | | </button> |
| | | 210 | | </div> |
| | | 211 | | )} |
| | | 212 | | </form> |
| | | 213 | | </aside> |
| | | 214 | | ); |
| | | 215 | | } |