435 lines
17 KiB
JavaScript
435 lines
17 KiB
JavaScript
import { useState, useEffect, useRef } from "react";
|
||
import styles from "./Adminpage.module.scss";
|
||
|
||
// ─── PASSWORD — change this ───────────────────────────────────────────────────
|
||
const ADMIN_PASSWORD = "shumoff2024";
|
||
|
||
// ─── LOGIN ────────────────────────────────────────────────────────────────────
|
||
|
||
function LoginScreen({ onLogin }) {
|
||
const [input, setInput] = useState("");
|
||
const [error, setError] = useState(false);
|
||
const [shake, setShake] = useState(false);
|
||
|
||
function handleSubmit(e) {
|
||
e.preventDefault();
|
||
if (input === ADMIN_PASSWORD) {
|
||
onLogin();
|
||
} else {
|
||
setError(true);
|
||
setShake(true);
|
||
setTimeout(() => setShake(false), 500);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className={styles.loginWrapper}>
|
||
<div className={`${styles.loginCard} ${shake ? styles.shake : ""}`}>
|
||
<div className={styles.loginLogo}>⚙️</div>
|
||
<h1 className={styles.loginTitle}>Панель администратора</h1>
|
||
<p className={styles.loginSub}>Введите пароль для доступа</p>
|
||
<form onSubmit={handleSubmit} className={styles.loginForm}>
|
||
<input
|
||
type="password"
|
||
className={`${styles.loginInput} ${error ? styles.inputError : ""}`}
|
||
placeholder="Пароль"
|
||
value={input}
|
||
onChange={(e) => { setInput(e.target.value); setError(false); }}
|
||
autoFocus
|
||
/>
|
||
{error && <p className={styles.errorMsg}>Неверный пароль. Попробуйте ещё раз.</p>}
|
||
<button type="submit" className={styles.loginBtn}>Войти</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── TABS ─────────────────────────────────────────────────────────────────────
|
||
|
||
const ADMIN_TABS = [
|
||
{ id: "products", label: "📦 Товары и цены" },
|
||
{ id: "bodytypes", label: "🚗 Типы кузова" },
|
||
];
|
||
|
||
// ─── ADMIN PANEL ─────────────────────────────────────────────────────────────
|
||
|
||
function AdminPanel({ onLogout }) {
|
||
const [data, setData] = useState(null);
|
||
const [tab, setTab] = useState("products");
|
||
const [zone, setZone] = useState(null);
|
||
const [bodyType, setBodyType] = useState(null);
|
||
const [pkg, setPkg] = useState(null);
|
||
const [saved, setSaved] = useState(false);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
|
||
// Load data.json from /public
|
||
useEffect(() => {
|
||
fetch("/frontend-api/data")
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
setData(d);
|
||
setZone(Object.keys(d.zones)[0]);
|
||
setBodyType(d.bodyTypes[0]?.id);
|
||
setPkg(d.packages[0]);
|
||
setLoading(false);
|
||
})
|
||
.catch((e) => { setError(e.message); setLoading(false); });
|
||
}, []);
|
||
|
||
// ── helpers ────────────────────────────────────────────────
|
||
function updateData(fn) {
|
||
setData((prev) => {
|
||
const next = JSON.parse(JSON.stringify(prev));
|
||
fn(next);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
// Products tab
|
||
function getProducts() {
|
||
return data?.zones?.[zone]?.products?.[bodyType]?.[pkg] || [];
|
||
}
|
||
|
||
function setProducts(products) {
|
||
updateData((d) => {
|
||
if (!d.zones[zone].products[bodyType]) d.zones[zone].products[bodyType] = {};
|
||
d.zones[zone].products[bodyType][pkg] = products;
|
||
});
|
||
}
|
||
|
||
function updateProduct(idx, field, val) {
|
||
const ps = JSON.parse(JSON.stringify(getProducts()));
|
||
if (field === "price" || field === "qty") ps[idx][field] = Number(val) || 0;
|
||
else ps[idx][field] = val;
|
||
setProducts(ps);
|
||
}
|
||
|
||
function deleteProduct(idx) {
|
||
const ps = JSON.parse(JSON.stringify(getProducts()));
|
||
ps.splice(idx, 1);
|
||
setProducts(ps);
|
||
}
|
||
|
||
function addProduct() {
|
||
const ps = JSON.parse(JSON.stringify(getProducts()));
|
||
ps.push({ name: "Новый товар", price: 0, qty: 1, unit: "Л" });
|
||
setProducts(ps);
|
||
}
|
||
|
||
// Body types tab
|
||
function updateBodyType(idx, field, val) {
|
||
updateData((d) => { d.bodyTypes[idx][field] = val; });
|
||
}
|
||
|
||
function deleteBodyType(idx) {
|
||
const bt = data.bodyTypes[idx];
|
||
updateData((d) => {
|
||
d.bodyTypes.splice(idx, 1);
|
||
// remove products for this body type in all zones
|
||
Object.values(d.zones).forEach((z) => { delete z.products[bt.id]; });
|
||
});
|
||
if (bodyType === bt.id) setBodyType(data.bodyTypes[0]?.id);
|
||
}
|
||
|
||
function addBodyType() {
|
||
const newId = `body_${Date.now()}`;
|
||
updateData((d) => {
|
||
d.bodyTypes.push({ id: newId, label: "Новый тип", icon: "🚗" });
|
||
});
|
||
}
|
||
|
||
// Zone label
|
||
function updateZoneLabel(val) {
|
||
updateData((d) => { d.zones[zone].label = val; });
|
||
}
|
||
|
||
function handleImageUpload(e, idx) {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (event) => {
|
||
updateBodyType(idx, "image", event.target.result);
|
||
updateBodyType(idx, "icon", null); // Eski icon verisini temizle
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
// ── Save = download updated data.json ──────────────────────
|
||
// Since there's no backend, admin downloads the JSON and replaces public/data.json
|
||
async function handleSave() {
|
||
const res = await fetch("/frontend-api/data", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(data),
|
||
});
|
||
const result = await res.json();
|
||
if (!result.ok) { alert("Hata: " + result.error); return; }
|
||
setSaved(true);
|
||
setTimeout(() => setSaved(false), 3000);
|
||
}
|
||
|
||
// ── Render ─────────────────────────────────────────────────
|
||
if (loading) return <div className={styles.loadingScreen}>Загрузка...</div>;
|
||
if (error) return <div className={styles.loadingScreen}>Ошибка: {error}</div>;
|
||
|
||
const products = getProducts();
|
||
|
||
return (
|
||
<div className={styles.adminWrapper}>
|
||
|
||
{/* Header */}
|
||
<header className={styles.adminHeader}>
|
||
<div className={styles.adminHeaderLeft}>
|
||
<span className={styles.adminHeaderIcon}>⚙️</span>
|
||
<div>
|
||
<h1 className={styles.adminHeaderTitle}>Панель администратора</h1>
|
||
<p className={styles.adminHeaderSub}>Управление товарами, ценами и типами кузова</p>
|
||
</div>
|
||
</div>
|
||
<div className={styles.adminHeaderRight}>
|
||
{saved && (
|
||
<span className={styles.savedBadge}>
|
||
✓ Сохранено
|
||
</span>
|
||
)}
|
||
<button className={styles.btnSaveHeader} onClick={handleSave}>
|
||
💾 Сохранить
|
||
</button>
|
||
<button className={styles.btnLogout} onClick={onLogout}>Выйти</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Tab bar */}
|
||
<div className={styles.tabBar}>
|
||
{ADMIN_TABS.map((t) => (
|
||
<button
|
||
key={t.id}
|
||
className={`${styles.tabBtn} ${tab === t.id ? styles.tabActive : ""}`}
|
||
onClick={() => setTab(t.id)}
|
||
>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── PRODUCTS TAB ─────────────────────────────────────── */}
|
||
{tab === "products" && (
|
||
<div className={styles.adminBody}>
|
||
|
||
{/* Sidebar: zones */}
|
||
<aside className={styles.sidebar}>
|
||
<p className={styles.sidebarLabel}>Зоны</p>
|
||
{Object.entries(data.zones).map(([zid, z]) => (
|
||
<button
|
||
key={zid}
|
||
className={`${styles.sidebarItem} ${zone === zid ? styles.sidebarActive : ""}`}
|
||
onClick={() => setZone(zid)}
|
||
>
|
||
{z.label}
|
||
</button>
|
||
))}
|
||
</aside>
|
||
|
||
{/* Main content */}
|
||
<main className={styles.content}>
|
||
|
||
{/* Zone label */}
|
||
<div className={styles.fieldRow}>
|
||
<label className={styles.fieldLabel}>Название зоны</label>
|
||
<input
|
||
className={styles.fieldInput}
|
||
value={data.zones[zone]?.label || ""}
|
||
onChange={(e) => updateZoneLabel(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Body type selector */}
|
||
<div className={styles.bodyTypeRow}>
|
||
<span className={styles.fieldLabel}>Тип кузова</span>
|
||
<div className={styles.bodyTypePills}>
|
||
{data.bodyTypes.map((b) => (
|
||
<button
|
||
key={b.id}
|
||
className={`${styles.bodyPill} ${bodyType === b.id ? styles.bodyPillActive : ""}`}
|
||
onClick={() => setBodyType(b.id)}
|
||
>
|
||
{b.image ? <img src={b.image} alt={b.label} className={styles.bodyPillIcon} /> : (b.icon && <span className={styles.bodyPillEmoji}>{b.icon}</span>)}
|
||
{b.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Package tabs */}
|
||
<div className={styles.pkgTabs}>
|
||
{data.packages.map((p) => (
|
||
<button
|
||
key={p}
|
||
className={`${styles.pkgTab} ${pkg === p ? styles.pkgActive : ""}`}
|
||
onClick={() => setPkg(p)}
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Products table */}
|
||
<div className={styles.tableWrapper}>
|
||
<table className={styles.table}>
|
||
<thead>
|
||
<tr>
|
||
<th className={styles.th}>Название товара</th>
|
||
<th className={`${styles.th} ${styles.thNum}`}>Цена (m)</th>
|
||
<th className={`${styles.th} ${styles.thNum}`}>Кол-во</th>
|
||
<th className={`${styles.th} ${styles.thNum}`}>Ед.</th>
|
||
<th className={`${styles.th} ${styles.thNum}`}>Итого</th>
|
||
<th className={styles.th}></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{products.map((p, i) => (
|
||
<tr key={i} className={styles.tr}>
|
||
<td className={styles.td}>
|
||
<input
|
||
className={styles.cellInput}
|
||
value={p.name}
|
||
onChange={(e) => updateProduct(i, "name", e.target.value)}
|
||
/>
|
||
</td>
|
||
<td className={styles.td}>
|
||
<input
|
||
className={`${styles.cellInput} ${styles.cellNum}`}
|
||
type="number" min="0"
|
||
value={p.price}
|
||
onChange={(e) => updateProduct(i, "price", e.target.value)}
|
||
/>
|
||
</td>
|
||
<td className={styles.td}>
|
||
<input
|
||
className={`${styles.cellInput} ${styles.cellNum}`}
|
||
type="number" min="0"
|
||
value={p.qty}
|
||
onChange={(e) => updateProduct(i, "qty", e.target.value)}
|
||
/>
|
||
</td>
|
||
<td className={styles.td}>
|
||
<input
|
||
className={`${styles.cellInput} ${styles.cellNum}`}
|
||
value={p.unit}
|
||
onChange={(e) => updateProduct(i, "unit", e.target.value)}
|
||
/>
|
||
</td>
|
||
<td className={`${styles.td} ${styles.tdTotal}`}>
|
||
{(p.price * p.qty).toLocaleString("ru")} m
|
||
</td>
|
||
<td className={styles.td}>
|
||
<button className={styles.btnDel} onClick={() => deleteProduct(i)}>✕</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{products.length === 0 && (
|
||
<tr>
|
||
<td colSpan={6} className={styles.emptyRow}>
|
||
Нет товаров для этой комбинации
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<button className={styles.btnAdd} onClick={addProduct}>
|
||
+ Добавить товар
|
||
</button>
|
||
|
||
</main>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── BODY TYPES TAB ───────────────────────────────────── */}
|
||
{tab === "bodytypes" && (
|
||
<div className={styles.bodyTypesPage}>
|
||
<div className={styles.bodyTypesHeader}>
|
||
<h2 className={styles.bodyTypesTitle}>Типы кузова</h2>
|
||
<p className={styles.bodyTypesSub}>
|
||
Добавляйте, удаляйте и редактируйте типы кузова. Изменения применяются
|
||
ко всем зонам и пакетам.
|
||
</p>
|
||
</div>
|
||
|
||
<div className={styles.bodyTypesGrid}>
|
||
{data.bodyTypes.map((b, i) => (
|
||
<div key={b.id} className={styles.bodyTypeCard}>
|
||
<div className={styles.imageUploader}>
|
||
{b.image ? (
|
||
<img src={b.image} alt="Preview" className={styles.iconPreview} />
|
||
) : (
|
||
b.icon && <span className={styles.iconPreview}>{b.icon}</span>
|
||
)}
|
||
<label className={styles.uploadLabel}>
|
||
<span>{b.image || b.icon ? 'Изменить' : 'Загрузить'}</span>
|
||
<input
|
||
type="file"
|
||
accept="image/png, image/jpeg, image/svg+xml"
|
||
onChange={(e) => handleImageUpload(e, i)}
|
||
style={{ display: 'none' }}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div className={styles.bodyTypeCardBottom}>
|
||
<input
|
||
className={styles.fieldInput}
|
||
value={b.label}
|
||
onChange={(e) => updateBodyType(i, "label", e.target.value)}
|
||
placeholder="Название типа"
|
||
/>
|
||
<button
|
||
className={styles.btnDel}
|
||
onClick={() => {
|
||
if (window.confirm(`Удалить тип "${b.label}"? Все товары для этого типа будут удалены.`)) {
|
||
deleteBodyType(i);
|
||
}
|
||
}}
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* Add new */}
|
||
<button className={styles.bodyTypeAddCard} onClick={addBodyType}>
|
||
<span>+</span>
|
||
<span>Добавить тип</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className={styles.saveHint}>
|
||
<p>
|
||
После всех изменений нажмите <strong>«Сохранить»</strong> в шапке.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Floating save */}
|
||
<div className={styles.floatSave}>
|
||
<button className={styles.btnSaveFloat} onClick={handleSave}>
|
||
💾 Сохранить
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── PAGE EXPORT ─────────────────────────────────────────────────────────────
|
||
|
||
export default function AdminPage() {
|
||
const [authed, setAuthed] = useState(false);
|
||
return authed
|
||
? <AdminPanel onLogout={() => setAuthed(false)} />
|
||
: <LoginScreen onLogin={() => setAuthed(true)} />;
|
||
} |