/* Stops.jsx — екран Зупинок: live API через window.passengerApi. */ const { useState: useStateS, useEffect: useEffectS, useRef: useRefS } = React; function slugifyCode(name) { // Простий transliteration UA/RU → ASCII + slug + 4-цифровий suffix. const map = { "а":"a","б":"b","в":"v","г":"g","ґ":"g","д":"d","е":"e","є":"ie","ё":"e", "ж":"zh","з":"z","и":"y","і":"i","ї":"yi","й":"y","к":"k","л":"l","м":"m", "н":"n","о":"o","п":"p","р":"r","с":"s","т":"t","у":"u","ф":"f","х":"kh", "ц":"ts","ч":"ch","ш":"sh","щ":"shch","ъ":"","ы":"y","ь":"","э":"e","ю":"iu","я":"ia", }; const lower = (name || "").toLowerCase(); let out = ""; for (const ch of lower) out += (map[ch] !== undefined) ? map[ch] : ch; out = out.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); if (!out) out = "stop"; const suffix = String(Math.floor(1000 + Math.random() * 9000)); return out.slice(0, 40) + "-" + suffix; } /* ── Drawer ────────────────────────────────────────────────── */ function Drawer({ open, onClose, title, width = 440, children, footer }) { const [anim, setAnim] = useStateS(false); useEffectS(() => { if (open) requestAnimationFrame(() => setAnim(true)); else setAnim(false); }, [open]); if (!open && !anim) return null; return ( <>
{title}
{children}
{footer && (
{footer}
)}
); } /* ── Create Stop Form (controlled через ref-handle) ─────────── */ const CreateStopForm = React.forwardRef(function CreateStopForm({ saving }, ref) { const [form, setForm] = useStateS({ name: "", lat: "", lon: "", radius: 60 }); const [errors, setErrors] = useStateS({}); const set = (k, v) => { setForm(f => ({ ...f, [k]: v })); setErrors(e => ({ ...e, [k]: "" })); }; React.useImperativeHandle(ref, () => ({ getFormData() { const code = slugifyCode(form.name); return { code, name: (form.name || "").trim(), lat: parseFloat(form.lat), lon: parseFloat(form.lon), radius: parseInt(form.radius, 10) || 60, }; }, setFieldErrors(errs) { setErrors(errs || {}); }, reset() { setForm({ name: "", lat: "", lon: "", radius: 60 }); setErrors({}); }, })); return (
set("name", e.target.value)} placeholder="напр. Хрещатик А" autoFocus /> {errors.name &&
{errors.name}
} {errors.code &&
{errors.code}
}
🗺
Карта · вкажіть координати нижче
set("lat", e.target.value)} placeholder="50.4501" /> {errors.lat &&
{errors.lat}
}
set("lon", e.target.value)} placeholder="30.5234" /> {errors.lon &&
{errors.lon}
}
{[40, 60, 75, 100, 150].map(r => ( ))}
Зона зарахування пасажира до зупинки
{saving && (
Зберігаю…
)}
); }); /* ── Main Screen ───────────────────────────────────────────── */ function Stops({ onNav }) { const [stops, setStops] = useStateS([]); const [counts, setCounts] = useStateS({ all: 0, draft: 0, published: 0, archived: 0 }); const [loading, setLoading] = useStateS(true); const [error, setError] = useStateS(null); const [search, setSearch] = useStateS(""); const [filter, setFilter] = useStateS("all"); const [drawerOpen, setDrawerOpen] = useStateS(false); const [saving, setSaving] = useStateS(false); const [selected, setSelected] = useStateS(new Set()); const formRef = useRefS(); const reload = async (opts = {}) => { setLoading(true); try { const res = await window.passengerApi.stops.list({ search: opts.search ?? search, status: opts.filter ?? filter, }); setStops(res.items || []); setCounts(res.counts || { all: 0, draft: 0, published: 0, archived: 0 }); setError(null); } catch (e) { setError(e); window.showToast && window.showToast("Не вдалося завантажити зупинки: " + (e.message || ""), "bad"); } finally { setLoading(false); } }; useEffectS(() => { reload({ search, filter }); /* eslint-disable-next-line */ }, [search, filter]); window.passengerApi.usePolling(() => reload({ search, filter }), 30000, [search, filter]); const toggleSelect = (id) => { setSelected(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); }; const handleDelete = async (stop) => { try { await window.passengerApi.stops.remove(stop.id); window.showToast && window.showToast( `Зупинку «${stop.name}» видалено`, "warn", async () => { try { await window.passengerApi.stops.restore(stop.id); await reload(); window.showToast && window.showToast(`«${stop.name}» відновлено`, "ok"); } catch (e) { window.showToast && window.showToast("Не вдалося відновити: " + (e.message || ""), "bad"); } } ); await reload(); } catch (e) { window.showToast && window.showToast("Помилка видалення: " + (e.message || ""), "bad"); } }; const handlePublish = async (stop) => { try { await window.passengerApi.stops.publish(stop.id); window.showToast && window.showToast(`«${stop.name}» опубліковано`, "ok"); await reload(); } catch (e) { window.showToast && window.showToast("Помилка публікації: " + (e.message || ""), "bad"); } }; const handleBulkDelete = async () => { const ids = [...selected]; if (!ids.length) return; try { const res = await window.passengerApi.stops.bulkDelete(ids); setSelected(new Set()); window.showToast && window.showToast(`Видалено ${res.deleted || 0} зупинок`, "warn"); await reload(); } catch (e) { window.showToast && window.showToast("Помилка bulk-delete: " + (e.message || ""), "bad"); } }; const handleSave = async (status) => { if (!formRef.current) return; const data = { ...formRef.current.getFormData(), status }; setSaving(true); try { await window.passengerApi.stops.create(data); window.showToast && window.showToast( status === "published" ? `«${data.name}» опубліковано` : `Чернетку «${data.name}» збережено`, status === "published" ? "ok" : "info" ); formRef.current.reset(); setDrawerOpen(false); await reload(); } catch (e) { if (e.errors) { formRef.current.setFieldErrors(e.errors); } else { window.showToast && window.showToast("Помилка збереження: " + (e.message || ""), "bad"); } } finally { setSaving(false); } }; return ( <> setDrawerOpen(true)}> + Нова зупинка } />
{error && (
Помилка завантаження.
)} {/* Toolbar */}
🔍 setSearch(e.target.value)} placeholder="Пошук за назвою або кодом…" style={{ paddingLeft: 32 }} />
{[ { id: "all", label: `Усі ${counts.all}` }, { id: "published", label: `Опубліковані ${counts.published}` }, { id: "draft", label: `Чернетки ${counts.draft}` }, ].map(f => ( ))}
{selected.size > 0 && (
{selected.size} обрано
)}
{/* Table */}
{loading ? (
Завантаження…
) : stops.length === 0 ? (
📍
{search ? `Нічого не знайдено за «${search}»` : "Зупинок ще немає"}
{search ? "Спробуйте інший запит або скиньте фільтри" : "Додайте першу зупинку"}
{search ? : }
) : ( {stops.map(stop => ( toggleSelect(stop.id)} onDelete={() => handleDelete(stop)} onPublish={() => handlePublish(stop)} /> ))}
0} onChange={e => setSelected(e.target.checked ? new Set(stops.map(s => s.id)) : new Set())} /> Назва Код Статус Радіус Координати
)}
{ if (!saving) setDrawerOpen(false); }} title="Нова зупинка" footer={<> } > ); } function StopRow({ stop, selected, onToggle, onDelete, onPublish }) { const [hovered, setHovered] = useStateS(false); const statusBadge = stop.status === "published" ? ● Опубліковано : stop.status === "archived" ? ▼ Архівна : ○ Чернетка; return ( setHovered(true)} onMouseLeave={() => setHovered(false)}> {stop.name} {stop.code} {statusBadge} {stop.radius} м {Number(stop.lat).toFixed(4)}, {Number(stop.lon).toFixed(4)} {hovered && (
{stop.status === "draft" && ( )}
)} ); } Object.assign(window, { Stops, Drawer });