/* 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
?
:
}
) : (
)}
{ 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 });