feat:add admin order page
This commit is contained in:
@@ -10,6 +10,7 @@ import Cart from "./pages/Cart";
|
||||
import Profile from "./pages/Profile";
|
||||
import ItemPage from "./pages/ItemPage";
|
||||
import AdminPage from "./pages/AdminPage";
|
||||
import AdminOrderDetail from "./pages/AdminOrderPage";
|
||||
|
||||
const PrivateRoute = ({ children }) => {
|
||||
const token = localStorage.getItem("token");
|
||||
@@ -62,6 +63,14 @@ export default function App() {
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/orders/:id"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminOrderDetail />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="items/:id" element={<ItemPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -71,3 +71,4 @@ export const stockOut = (data) =>
|
||||
export const getStockMovements = () =>
|
||||
axios.get(`${API_URL}/admin/stock/movements`);
|
||||
export const getTopItems = () => axios.get(`${API_URL}/admin/items/top`);
|
||||
export const getAdminOrder = (id) => axios.get(`${API_URL}/admin/orders/${id}`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getAllOrders, updateOrderStatus } from "../api/index";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
const STATUSES = [
|
||||
{ value: "new", label: "Новый" },
|
||||
@@ -34,6 +35,8 @@ export default function AdminOrders() {
|
||||
}
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (loading) return <p className="text-white/60">Загрузка...</p>;
|
||||
|
||||
return (
|
||||
@@ -44,7 +47,8 @@ export default function AdminOrders() {
|
||||
orders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className="bg-white/5 rounded-lg p-4 border border-white/10"
|
||||
className="bg-white/5 rounded-lg p-4 border border-white/10 hover:bg-white/10"
|
||||
onClick={() => navigate(`/admin/orders/${order.id}`)}
|
||||
>
|
||||
{/* шапка: номер + селект статуса */}
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getAdminOrder } from "../api";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
|
||||
// Статусы заказа — ПОДСТАВЬ свои, если в БД они другие
|
||||
const STATUSES = [
|
||||
{ value: "new", label: "Новый" },
|
||||
{ value: "prepared", label: "Собран" },
|
||||
{ value: "shipped", label: "Отправлен" },
|
||||
{ value: "delivered", label: "Доставлен" },
|
||||
{ value: "canceled", label: "Отменён" },
|
||||
];
|
||||
|
||||
export default function AdminOrderDetail({ orderId }) {
|
||||
const [order, setOrder] = useState(null);
|
||||
const [status, setStatus] = useState("");
|
||||
const [items, setItems] = useState([]); // редактируемая копия позиций
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Загружаем заказ при монтировании / смене orderId
|
||||
useEffect(() => {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
getAdminOrder(id)
|
||||
.then((res) => {
|
||||
setOrder(res.data);
|
||||
setStatus(res.data.status);
|
||||
setItems((res.data.items ?? []).map((it) => ({ ...it })));
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.response?.data?.error ?? "Не удалось загрузить заказ");
|
||||
})
|
||||
.finally(() => { });
|
||||
}, [orderId]);
|
||||
|
||||
function handleQuantityChange(itemId, value) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.item_id === itemId
|
||||
? { ...it, quantity: value === "" ? "" : parseFloat(value) }
|
||||
: it,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Пересчёт итога на лету
|
||||
const total = items.reduce((sum, it) => {
|
||||
const q = typeof it.quantity === "number" ? it.quantity : 0;
|
||||
return sum + it.price * q;
|
||||
}, 0);
|
||||
|
||||
if (!loading) {
|
||||
return <div className="p-6 text-slate-300">Загрузка заказа…</div>;
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<p className="text-red-400">{error || "Заказ не найден"}</p>
|
||||
<button
|
||||
onClick={() => navigate("/admin")}
|
||||
className="mt-4 text-slate-300 hover:text-white"
|
||||
>
|
||||
← Назад к заказам
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const created = order.created_at
|
||||
? new Date(order.created_at).toLocaleString("ru-RU")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-4 sm:p-6">
|
||||
{/* Шапка */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button
|
||||
onClick={() => navigate("/admin")}
|
||||
className="text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
← Заказы
|
||||
</button>
|
||||
<h1 className="text-lg font-semibold text-white">
|
||||
Заказ #{order.order_id}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-500/10 border border-red-500/30 px-4 py-2 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Мета заказа */}
|
||||
<div className="rounded-xl bg-slate-800 p-4 mb-4 space-y-3">
|
||||
{order.address && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-400">Адрес</span>
|
||||
<span className="text-slate-200">{order.address}</span>
|
||||
</div>
|
||||
)}
|
||||
{created && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-400">Создан</span>
|
||||
<span className="text-slate-200">{created}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Редактируемый статус */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400 text-sm">Статус</span>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="bg-slate-700 text-white rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-sky-500"
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<option
|
||||
key={s.value}
|
||||
value={s.value}
|
||||
className="bg-slate-800 text-white"
|
||||
>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Позиции с редактируемым весом */}
|
||||
<div className="rounded-xl bg-slate-800 divide-y divide-slate-700 mb-4">
|
||||
{items.map((it) => (
|
||||
<div key={it.item_id} className="flex items-center gap-3 p-4">
|
||||
<div className="grow min-w-0">
|
||||
<p className="text-white truncate">{it.name}</p>
|
||||
<p className="text-slate-400 text-sm">
|
||||
{it.price} ₽ / {it.unit}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
value={it.quantity}
|
||||
onChange={(e) =>
|
||||
handleQuantityChange(it.item_id, e.target.value)
|
||||
}
|
||||
className="w-20 bg-slate-700 text-white rounded-lg px-2 py-1.5 text-right outline-none focus:ring-2 focus:ring-sky-500"
|
||||
/>
|
||||
<span className="text-slate-400 text-sm w-8">{it.unit}</span>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 w-24 text-right text-slate-200">
|
||||
{(
|
||||
it.price * (typeof it.quantity === "number" ? it.quantity : 0)
|
||||
).toFixed(2)}{" "}
|
||||
₽
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Итог */}
|
||||
<div className="flex justify-between items-center px-4 mb-6">
|
||||
<span className="text-slate-400">Итого</span>
|
||||
<span className="text-xl font-semibold text-white">
|
||||
{total.toFixed(2)} ₽
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Сохранить */}
|
||||
<button
|
||||
disabled={saving}
|
||||
className="w-full bg-sky-600 hover:bg-sky-500 disabled:opacity-50 text-white rounded-xl py-3 font-medium transition-colors"
|
||||
>
|
||||
{saving ? "Сохранение…" : "Сохранить изменения"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user