Compare commits
2 Commits
45d1353091
...
115f52a8f1
| Author | SHA1 | Date | |
|---|---|---|---|
| 115f52a8f1 | |||
| 2c22cee60b |
@@ -73,6 +73,8 @@ func UpdateItem(c *gin.Context) {
|
|||||||
UnitType: input.UnitType,
|
UnitType: input.UnitType,
|
||||||
City: input.City,
|
City: input.City,
|
||||||
Price: input.Price,
|
Price: input.Price,
|
||||||
|
Category: input.Category,
|
||||||
|
Stock: input.Stock,
|
||||||
})
|
})
|
||||||
|
|
||||||
c.JSON(http.StatusOK, item)
|
c.JSON(http.StatusOK, item)
|
||||||
@@ -156,14 +158,14 @@ func UploadItemAvatar(c *gin.Context) {
|
|||||||
|
|
||||||
resized := imaging.Resize(img, 1200, 0, imaging.Lanczos)
|
resized := imaging.Resize(img, 1200, 0, imaging.Lanczos)
|
||||||
uniqueName := uuid.New().String() + ".jpg"
|
uniqueName := uuid.New().String() + ".jpg"
|
||||||
dstPath := filepath.Join("./uploads/items", uniqueName)
|
dstPath := filepath.Join("./uploads/items/", uniqueName)
|
||||||
|
|
||||||
if err := imaging.Save(resized, dstPath, imaging.JPEGQuality(82)); err != nil {
|
if err := imaging.Save(resized, dstPath, imaging.JPEGQuality(82)); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось сохранить фотографию"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось сохранить фотографию"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
item.AvatarURL = "/uploads/items" + uniqueName
|
item.AvatarURL = uniqueName
|
||||||
database.DB.Save(&item)
|
database.DB.Save(&item)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"avatar_url": item.AvatarURL})
|
c.JSON(http.StatusOK, gin.H{"avatar_url": item.AvatarURL})
|
||||||
|
|||||||
+2
-1
@@ -41,7 +41,7 @@ func main() {
|
|||||||
api.POST("/login", handlers.Login)
|
api.POST("/login", handlers.Login)
|
||||||
protected := api.Group("/")
|
protected := api.Group("/")
|
||||||
api.GET("/items/:id", handlers.GetItem)
|
api.GET("/items/:id", handlers.GetItem)
|
||||||
api.GET("/items/", handlers.GetAllItems)
|
api.GET("/items", handlers.GetAllItems)
|
||||||
api.GET("/getCategories", handlers.GetCategories)
|
api.GET("/getCategories", handlers.GetCategories)
|
||||||
api.POST("/register", handlers.Register)
|
api.POST("/register", handlers.Register)
|
||||||
protected.Use(middleware.AuthRequired())
|
protected.Use(middleware.AuthRequired())
|
||||||
@@ -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.PATCH("/items/:id/avatar", handlers.UploadItemAvatar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
@@ -9,6 +9,7 @@ import Contact from "./pages/Contact";
|
|||||||
import Cart from "./pages/Cart";
|
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";
|
||||||
|
|
||||||
const PrivateRoute = ({ children }) => {
|
const PrivateRoute = ({ children }) => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
@@ -19,6 +20,21 @@ const PrivateRoute = ({ children }) => {
|
|||||||
return children;
|
return children;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const AdminRoute = ({ children }) => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
const user = JSON.parse(localStorage.getItem("user") || "{}");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role !== "Админ") {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@@ -38,6 +54,14 @@ export default function App() {
|
|||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<AdminPage />
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="items/:id" element={<ItemPage />} />
|
<Route path="items/:id" element={<ItemPage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -42,11 +42,19 @@ export const getAllCategories = () => axios.get(`${API_URL}/getCategories`);
|
|||||||
export const getCart = () => axios.get(`${API_URL}/cart/`);
|
export const getCart = () => axios.get(`${API_URL}/cart/`);
|
||||||
export const addToCart = (data) => axios.post(`${API_URL}/cart/`, data);
|
export const addToCart = (data) => axios.post(`${API_URL}/cart/`, data);
|
||||||
export const updateCartItem = (id, data) =>
|
export const updateCartItem = (id, data) =>
|
||||||
axios.post(`${API_URL}/cart/${id}`, data);
|
axios.patch(`${API_URL}/cart/${id}`, data);
|
||||||
export const removeFromCart = (id) => axios.delete(`${API_URL}/cart/${id}`);
|
export const removeFromCart = (id) => axios.delete(`${API_URL}/cart/${id}`);
|
||||||
export const createOrder = (data) => axios.post(`${API_URL}/orders/`, data);
|
export const createOrder = (data) => axios.post(`${API_URL}/orders/`, data);
|
||||||
export const getUserOrders = () => axios.get(`${API_URL}/orders`);
|
export const getUserOrders = () => axios.get(`${API_URL}/orders`);
|
||||||
export const getOrder = (id) => axios.get(`${API_URL}/orders/${id}`);
|
export const getOrder = (id) => axios.get(`${API_URL}/orders/${id}`);
|
||||||
|
|
||||||
export const updateProfile = () => axios.patch(`${API_URL}/profile`);
|
export const updateProfile = (data) => axios.patch(`${API_URL}/profile`);
|
||||||
export const getUserInfo = () => axios.get(`${API_URL}/profile`);
|
export const getUserInfo = () => axios.get(`${API_URL}/profile`);
|
||||||
|
|
||||||
|
export const uploadItemAvatar = (id, file) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("avatar", file);
|
||||||
|
return axios.patch(`${API_URL}/admin/items/${id}/avatar`, formData, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAllItems,
|
||||||
|
deleteItem,
|
||||||
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
uploadItemAvatar,
|
||||||
|
} from "../api/index";
|
||||||
|
|
||||||
|
function ItemFormModal({ item, onClose, onSaved }) {
|
||||||
|
const isEdit = !!item;
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: item?.name || "",
|
||||||
|
category: item?.category || "",
|
||||||
|
city: item?.city || "",
|
||||||
|
price: item?.price || "",
|
||||||
|
stock: item?.stock || "",
|
||||||
|
unit: item?.unit || "кг",
|
||||||
|
unit_type: item?.unit_type || "weight",
|
||||||
|
});
|
||||||
|
const [file, setFile] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (field) => (e) =>
|
||||||
|
setForm({ ...form, [field]: e.target.value });
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// числовые поля привести к числам
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
price: Number(form.price),
|
||||||
|
stock: Number(form.stock),
|
||||||
|
};
|
||||||
|
|
||||||
|
let itemId;
|
||||||
|
if (isEdit) {
|
||||||
|
await updateItem(item.id, payload);
|
||||||
|
itemId = item.id;
|
||||||
|
} else {
|
||||||
|
const res = await addItem(payload);
|
||||||
|
itemId = res.data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// если выбран файл — загрузить картинку
|
||||||
|
if (file) {
|
||||||
|
await uploadItemAvatar(itemId, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSaved();
|
||||||
|
} catch {
|
||||||
|
alert("Не удалось сохранить товар");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-slate-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto text-white">
|
||||||
|
<h2 className="text-xl font-medium mb-4">
|
||||||
|
{isEdit ? "Редактировать товар" : "Новый товар"}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<input
|
||||||
|
className="bg-white/10 rounded px-3 py-2"
|
||||||
|
placeholder="Название"
|
||||||
|
value={form.name}
|
||||||
|
onChange={handleChange("name")}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="bg-white/10 rounded px-3 py-2"
|
||||||
|
placeholder="Категория"
|
||||||
|
value={form.category}
|
||||||
|
onChange={handleChange("category")}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="bg-white/10 rounded px-3 py-2"
|
||||||
|
placeholder="Город"
|
||||||
|
value={form.city}
|
||||||
|
onChange={handleChange("city")}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="bg-white/10 rounded px-3 py-2"
|
||||||
|
placeholder="Цена"
|
||||||
|
value={form.price}
|
||||||
|
onChange={handleChange("price")}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="bg-white/10 rounded px-3 py-2"
|
||||||
|
placeholder="Остаток"
|
||||||
|
value={form.stock}
|
||||||
|
onChange={handleChange("stock")}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="bg-white/10 rounded px-3 py-2"
|
||||||
|
placeholder="Единица (кг, шт)"
|
||||||
|
value={form.unit}
|
||||||
|
onChange={handleChange("unit")}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="bg-white/10 rounded px-3 py-2"
|
||||||
|
value={form.unit_type}
|
||||||
|
onChange={handleChange("unit_type")}
|
||||||
|
>
|
||||||
|
<option value="weight">На вес</option>
|
||||||
|
<option value="piece">Поштучно</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* картинка */}
|
||||||
|
<div>
|
||||||
|
<label className="text-white/50 text-sm block mb-1">Картинка</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => setFile(e.target.files[0])}
|
||||||
|
className="text-sm text-white/70"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-gold text-black px-6 py-2 rounded font-medium flex-grow disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "Сохранение..." : isEdit ? "Сохранить" : "Создать"}
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="bg-white/10 px-6 py-2 rounded">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Основной компонент =====
|
||||||
|
export default function AdminItems() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
|
|
||||||
|
const loadItems = () => {
|
||||||
|
getAllItems()
|
||||||
|
.then((res) => setItems(res.data))
|
||||||
|
.catch(() => { })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadItems();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = async (id, name) => {
|
||||||
|
if (!confirm(`Удалить товар "${name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await deleteItem(id);
|
||||||
|
setItems((prev) => prev.filter((it) => it.id !== id));
|
||||||
|
} catch {
|
||||||
|
alert("Не удалось удалить товар");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
const openEdit = (item) => {
|
||||||
|
setEditingItem(item);
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
const handleSaved = () => {
|
||||||
|
setShowModal(false);
|
||||||
|
loadItems();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <p className="text-white/60">Загрузка...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={openCreate}
|
||||||
|
className="mb-6 bg-gold text-black px-6 py-2 rounded-md font-medium uppercase text-sm"
|
||||||
|
>
|
||||||
|
+ Добавить товар
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm text-left">
|
||||||
|
<thead className="text-white/50 uppercase text-xs border-b border-white/10">
|
||||||
|
<tr>
|
||||||
|
<th className="py-3 pr-4">Фото</th>
|
||||||
|
<th className="py-3 pr-4">Название</th>
|
||||||
|
<th className="py-3 pr-4">Категория</th>
|
||||||
|
<th className="py-3 pr-4">Цена</th>
|
||||||
|
<th className="py-3 pr-4">Остаток</th>
|
||||||
|
<th className="py-3 pr-4">Тип</th>
|
||||||
|
<th className="py-3 pr-4">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
className="border-b border-white/5 hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<img
|
||||||
|
src={item.avatar_url}
|
||||||
|
alt=""
|
||||||
|
className="w-12 h-12 object-cover rounded"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 font-medium">{item.name}</td>
|
||||||
|
<td className="py-3 pr-4 text-white/70">{item.category}</td>
|
||||||
|
<td className="py-3 pr-4">{item.price} ₽</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
{item.stock} {item.unit}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 text-white/70">
|
||||||
|
{item.unit_type === "piece" ? "шт" : "вес"}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(item)}
|
||||||
|
className="text-blue-400 hover:text-blue-300 text-sm"
|
||||||
|
>
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(item.id, item.name)}
|
||||||
|
className="text-red-400 hover:text-red-300 text-sm"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<ItemFormModal
|
||||||
|
item={editingItem}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import PageLayout from "../components/PageLayout";
|
||||||
|
import AdminItems from "../components/AdminItems";
|
||||||
|
// import AdminOrders from "../components/AdminOrders"; // добавим позже
|
||||||
|
// import AdminStaff from "../components/AdminStaff"; // добавим позже
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const [tab, setTab] = useState("items");
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: "items", label: "Товары" },
|
||||||
|
{ key: "orders", label: "Заказы" },
|
||||||
|
{ key: "staff", label: "Сотрудники" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<main className="px-6 md:px-16 grow w-full mb-10 text-white">
|
||||||
|
<h1 className="text-2xl md:text-4xl font-medium uppercase mb-8">
|
||||||
|
Админ-панель
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Вкладки */}
|
||||||
|
<div className="flex gap-2 mb-8 border-b border-white/10">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={`px-4 py-2 text-sm uppercase transition-colors ${tab === t.key
|
||||||
|
? "text-white border-b-2 border-gold"
|
||||||
|
: "text-white/50 hover:text-white/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Содержимое активной вкладки */}
|
||||||
|
{tab === "items" && <AdminItems />}
|
||||||
|
{tab === "orders" && (
|
||||||
|
<p className="text-white/50">Раздел заказов — в разработке</p>
|
||||||
|
)}
|
||||||
|
{tab === "staff" && (
|
||||||
|
<p className="text-white/50">Раздел сотрудников — в разработке</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user