Compare commits
6
Commits
3027684395
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0201933a6b | ||
|
|
c11ad83706 | ||
|
|
8b1f73693d | ||
|
|
e4f57759a6 | ||
|
|
7916c81ece | ||
|
|
f84d20ba20 |
@@ -20,14 +20,15 @@ func GetTopItems(c *gin.Context) {
|
|||||||
results := []TopItem{}
|
results := []TopItem{}
|
||||||
|
|
||||||
err := database.DB.Table("order_items").
|
err := database.DB.Table("order_items").
|
||||||
Select("items.name, SUM(order_items.price * order_items.quantity) AS revenue, order_items.price, SUM(order_items.quantity) as sale_quantity, items.unit").
|
Select("items.name, SUM(order_items.price * order_items.quantity) AS revenue, AVG(order_items.price) as price, SUM(order_items.quantity) as sale_quantity, items.unit").
|
||||||
Joins("JOIN items ON items.id = order_items.item_id").
|
Joins("JOIN items ON items.id = order_items.item_id").
|
||||||
Group("items.name, order_items.price, items.unit").
|
Group("items.name, items.unit").
|
||||||
Order("revenue DESC").
|
Order("revenue DESC").
|
||||||
Limit(5).
|
Limit(5).
|
||||||
Scan(&results).Error
|
Scan(&results).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, results)
|
c.JSON(http.StatusOK, results)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -169,3 +170,66 @@ func UploadItemAvatar(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"avatar_url": item.AvatarURL})
|
c.JSON(http.StatusOK, gin.H{"avatar_url": item.AvatarURL})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetAdminOrder(c *gin.Context) {
|
||||||
|
orderID := c.Param("id")
|
||||||
|
|
||||||
|
type OrderItemDetail struct {
|
||||||
|
ItemID uint `json:"item_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
}
|
||||||
|
type OrderHeader struct {
|
||||||
|
OrderID uint `json:"order_id"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderDetail struct {
|
||||||
|
OrderHeader
|
||||||
|
Items []OrderItemDetail `json:"items"`
|
||||||
|
Total float64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var header OrderHeader
|
||||||
|
|
||||||
|
err := database.DB.Table("orders").
|
||||||
|
Select("orders.id as order_id, orders.status, orders.created_at").
|
||||||
|
Where("orders.id = ?", orderID).
|
||||||
|
Scan(&header).Error
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.OrderID == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "заказ не найден"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []OrderItemDetail
|
||||||
|
|
||||||
|
err = database.DB.Table("order_items").
|
||||||
|
Select("order_items.item_id, items.name, order_items.quantity, order_items.price, items.unit").
|
||||||
|
Joins("JOIN items ON items.id = order_items.item_id").
|
||||||
|
Where("order_items.order_id = ?", orderID).
|
||||||
|
Scan(&items).Error
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order := OrderDetail{
|
||||||
|
OrderHeader: header,
|
||||||
|
Items: items,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, it := range items {
|
||||||
|
order.Total += it.Price * it.Quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, order)
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ func main() {
|
|||||||
admin.POST("/register", handlers.RegisterAdmin)
|
admin.POST("/register", handlers.RegisterAdmin)
|
||||||
admin.GET("/orders", handlers.GetAllOrders)
|
admin.GET("/orders", handlers.GetAllOrders)
|
||||||
admin.PATCH("/orders/:id", handlers.UpdateOrderStatus)
|
admin.PATCH("/orders/:id", handlers.UpdateOrderStatus)
|
||||||
|
admin.GET("/orders/:id", handlers.GetAdminOrder)
|
||||||
admin.GET("/items/top", handlers.GetTopItems)
|
admin.GET("/items/top", handlers.GetTopItems)
|
||||||
admin.PATCH("/items/:id/avatar", handlers.UploadItemAvatar)
|
admin.PATCH("/items/:id/avatar", handlers.UploadItemAvatar)
|
||||||
admin.POST("/stock/in", handlers.StockIn)
|
admin.POST("/stock/in", handlers.StockIn)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Cart from "./pages/Cart";
|
|||||||
import Profile from "./pages/Profile";
|
import Profile from "./pages/Profile";
|
||||||
import ItemPage from "./pages/ItemPage";
|
import ItemPage from "./pages/ItemPage";
|
||||||
import AdminPage from "./pages/AdminPage";
|
import AdminPage from "./pages/AdminPage";
|
||||||
|
import AdminOrderDetail from "./pages/AdminOrderPage";
|
||||||
|
|
||||||
const PrivateRoute = ({ children }) => {
|
const PrivateRoute = ({ children }) => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
@@ -62,6 +63,14 @@ export default function App() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/orders/:id"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<AdminOrderDetail />
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="items/:id" element={<ItemPage />} />
|
<Route path="items/:id" element={<ItemPage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -71,3 +71,4 @@ export const stockOut = (data) =>
|
|||||||
export const getStockMovements = () =>
|
export const getStockMovements = () =>
|
||||||
axios.get(`${API_URL}/admin/stock/movements`);
|
axios.get(`${API_URL}/admin/stock/movements`);
|
||||||
export const getTopItems = () => axios.get(`${API_URL}/admin/items/top`);
|
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 { useState, useEffect } from "react";
|
||||||
import { getAllOrders, updateOrderStatus } from "../api/index";
|
import { getAllOrders, updateOrderStatus } from "../api/index";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
const STATUSES = [
|
const STATUSES = [
|
||||||
{ value: "new", label: "Новый" },
|
{ value: "new", label: "Новый" },
|
||||||
@@ -34,6 +35,8 @@ export default function AdminOrders() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (loading) return <p className="text-white/60">Загрузка...</p>;
|
if (loading) return <p className="text-white/60">Загрузка...</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -44,7 +47,8 @@ export default function AdminOrders() {
|
|||||||
orders.map((order) => (
|
orders.map((order) => (
|
||||||
<div
|
<div
|
||||||
key={order.id}
|
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">
|
<div className="flex justify-between items-center mb-3">
|
||||||
@@ -69,7 +73,7 @@ export default function AdminOrders() {
|
|||||||
<img
|
<img
|
||||||
src={`/static/items/${oi.item?.avatar_url}`}
|
src={`/static/items/${oi.item?.avatar_url}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-20 h-20 object-cover rounded shrink-0"
|
className="w-20 h-20 md:w-40 md:h-40 object-cover rounded shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className="grow">{oi.item?.name}</span>
|
<span className="grow">{oi.item?.name}</span>
|
||||||
<span className="whitespace-nowrap">
|
<span className="whitespace-nowrap">
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import BurgerClose from "./BurgerClose";
|
import BurgerClose from "./BurgerClose";
|
||||||
|
|
||||||
export default function BurgerMenu({ isOpen, onClose }) {
|
export default function BurgerMenu({ isOpen, onClose }) {
|
||||||
|
const raw = localStorage.getItem("user");
|
||||||
|
let user = null;
|
||||||
|
try {
|
||||||
|
user = raw ? JSON.parse(raw) : null;
|
||||||
|
} catch {
|
||||||
|
user = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = user?.role == "Админ";
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -55,6 +64,25 @@ export default function BurgerMenu({ isOpen, onClose }) {
|
|||||||
>
|
>
|
||||||
контакты
|
контакты
|
||||||
</button>
|
</button>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
width="4"
|
||||||
|
height="4"
|
||||||
|
viewBox="0 0 4 4"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle cx="2" cy="2" r="2" fill="white" />
|
||||||
|
</svg>
|
||||||
|
<button
|
||||||
|
className="hover:text-gold duration-200 transition uppercase"
|
||||||
|
onClick={() => navigate("/admin")}
|
||||||
|
>
|
||||||
|
админ
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ import siteLogo from "../assets/Исходники/logo.png";
|
|||||||
|
|
||||||
export default function FooterDesktop() {
|
export default function FooterDesktop() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const raw = localStorage.getItem("user");
|
||||||
|
let user = null;
|
||||||
|
try {
|
||||||
|
user = raw ? JSON.parse(raw) : null;
|
||||||
|
} catch {
|
||||||
|
user = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = user?.role == "Админ";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-light-blue w-full h-27.5 px-16 flex items-center justify-between ">
|
<footer className="bg-light-blue w-full h-27.5 px-16 flex items-center justify-between ">
|
||||||
@@ -51,6 +60,25 @@ export default function FooterDesktop() {
|
|||||||
>
|
>
|
||||||
контакты
|
контакты
|
||||||
</button>
|
</button>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
width="4"
|
||||||
|
height="4"
|
||||||
|
viewBox="0 0 4 4"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle cx="2" cy="2" r="2" fill="white" />
|
||||||
|
</svg>
|
||||||
|
<button
|
||||||
|
className="hover:text-gold duration-200 transition uppercase"
|
||||||
|
onClick={() => navigate("/admin")}
|
||||||
|
>
|
||||||
|
админ
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import siteLogo from "../assets/Исходники/logo.png";
|
|||||||
export default function HeaderDesktop() {
|
export default function HeaderDesktop() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const raw = localStorage.getItem("user");
|
||||||
|
let user = null;
|
||||||
|
try {
|
||||||
|
user = raw ? JSON.parse(raw) : null;
|
||||||
|
} catch {
|
||||||
|
user = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = user?.role == "Админ";
|
||||||
|
|
||||||
const handleProfileClick = () => {
|
const handleProfileClick = () => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -60,6 +70,26 @@ export default function HeaderDesktop() {
|
|||||||
>
|
>
|
||||||
контакты
|
контакты
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
width="4"
|
||||||
|
height="4"
|
||||||
|
viewBox="0 0 4 4"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle cx="2" cy="2" r="2" fill="white" />
|
||||||
|
</svg>
|
||||||
|
<button
|
||||||
|
className="hover:text-gold duration-200 transition uppercase"
|
||||||
|
onClick={() => navigate("/admin")}
|
||||||
|
>
|
||||||
|
админ
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ export default function AdminPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Вкладки */}
|
{/* Вкладки */}
|
||||||
<div className="flex gap-2 mb-8 border-b border-white/10">
|
<div className="flex overflow-x-auto whitespace-nowrap gap-2 mb-8 border-b border-white/10">
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
key={t.key}
|
||||||
|
|||||||
Reference in New Issue
Block a user