Compare commits

..

25 Commits

Author SHA1 Message Date
Deforest6342 0f573cbf9c fix: change bg select admin page 2026-07-01 15:05:52 +03:00
Deforest6342 4089037fb7 feat: add form stock in stock out on front 2026-06-27 21:52:03 +03:00
Deforest6342 46a0fd20db fix: remake admin items 2026-06-26 23:47:32 +03:00
Deforest6342 e55b421ed7 feat: add front page stock movements 2026-06-26 17:53:21 +03:00
Deforest6342 1423451713 feat: add movement on get order 2026-06-26 17:20:29 +03:00
Deforest6342 8000d241b4 feat: add getmovementstock on backend 2026-06-26 17:11:03 +03:00
Deforest6342 7b662bb618 feat: add stock movement on backend 2026-06-26 16:57:36 +03:00
Deforest6342 846844a1f0 feat: 2026-06-26 15:28:32 +03:00
Deforest6342 6822dfe655 fix: change handler order 2026-06-25 18:55:30 +03:00
Deforest6342 22e96d69c3 fix: remake auth form 2026-06-25 18:53:26 +03:00
Deforest6342 2f99653c2c feat: add save sessionStorage to scroll and category on shop 2026-06-24 21:33:39 +03:00
Deforest6342 e4cefc6c41 fix: correct translate in movement 2026-06-24 21:18:03 +03:00
Deforest6342 2e5b8a4dca feat: add validate new item from admin panel 2026-06-24 21:03:27 +03:00
Deforest6342 115f52a8f1 fix: change backend hadlers, API route and uploads photo 2026-06-24 20:47:14 +03:00
Deforest6342 2c22cee60b feat: add admin page 2026-06-24 20:15:20 +03:00
Deforest6342 45d1353091 fix: change movement to items page from img card item 2026-06-24 13:43:16 +03:00
Deforest6342 8725747cb9 fix: split active and complited orders in profile 2026-06-24 13:36:27 +03:00
Deforest6342 903079cfb6 fix: remake button inc + dec 2026-06-24 13:20:23 +03:00
Deforest6342 7ca8967bc3 feat: add page items 2026-06-24 13:13:09 +03:00
Deforest6342 ddad7b577b feat: add img to order state on page profile 2026-06-24 12:17:36 +03:00
Deforest6342 3c24f5b115 feat: add page profile with orders 2026-06-24 12:04:25 +03:00
Deforest6342 a450f32272 feat: add profile page 2026-06-24 10:58:51 +03:00
Deforest6342 4fc2c28d3b fix: change POST user update profile and delete input.role 2026-06-24 10:46:15 +03:00
Deforest6342 e569d63539 feat: add route to page customers and login/register 2026-06-24 10:33:26 +03:00
Deforest6342 1f5a8c6601 fix: change size image items in cart 2026-06-24 10:32:59 +03:00
28 changed files with 1520 additions and 81 deletions
+1
View File
@@ -26,5 +26,6 @@ func InitDB() {
&models.CartItem{}, &models.CartItem{},
&models.Order{}, &models.Order{},
&models.OrderItem{}, &models.OrderItem{},
&models.StockMovement{},
) )
} }
+3 -2
View File
@@ -73,6 +73,7 @@ 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,
}) })
c.JSON(http.StatusOK, item) c.JSON(http.StatusOK, item)
@@ -156,14 +157,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})
+16 -1
View File
@@ -70,6 +70,20 @@ func CreateOrder(c *gin.Context) {
return err return err
} }
for _, oi := range order.Items {
movement := models.StockMovement{
ItemID: oi.ItemID,
Type: "out",
Quantity: oi.Quantity,
Reason: "order",
OrderID: &order.ID,
}
if err := tx.Omit("Item").Create(&movement).Error; err != nil {
return err
}
}
if err := tx.Where("user_id = ? ", userID).Delete(&models.CartItem{}).Error; err != nil { if err := tx.Where("user_id = ? ", userID).Delete(&models.CartItem{}).Error; err != nil {
return err return err
} }
@@ -112,7 +126,7 @@ func GetOrder(c *gin.Context) {
} }
func GetAllOrders(c *gin.Context) { func GetAllOrders(c *gin.Context) {
var orders models.Order var orders []models.Order
if err := database.DB.Preload("Items.Item").Find(&orders).Error; err != nil { if err := database.DB.Preload("Items.Item").Find(&orders).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось вывести заказы"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось вывести заказы"})
@@ -136,6 +150,7 @@ func UpdateOrderStatus(c *gin.Context) {
if req.Status != "new" && req.Status != "packed" && req.Status != "delivered" && req.Status != "cancel" { if req.Status != "new" && req.Status != "packed" && req.Status != "delivered" && req.Status != "cancel" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Неверно указанный статус заказа"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Неверно указанный статус заказа"})
return
} }
result := database.DB.Model(&models.Order{}).Where("id = ? ", orderID).Update("status", req.Status) result := database.DB.Model(&models.Order{}).Where("id = ? ", orderID).Update("status", req.Status)
+134
View File
@@ -0,0 +1,134 @@
package handlers
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"fishfish/database"
"fishfish/models"
)
func StockIn(c *gin.Context) {
var input struct {
ItemID uint `json:"item_id" binding:"required"`
Quantity float64 `json:"quantity" binding:"required"`
Comment string `json:"comment"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Неверные данные"})
return
}
if input.Quantity <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Количество должно быть положительным"})
return
}
err := database.DB.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&models.Item{}).
Where("id = ? ", input.ItemID).
Update("stock", gorm.Expr("stock + ?", input.Quantity))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("товар не найден")
}
movement := models.StockMovement{
ItemID: input.ItemID,
Type: "in",
Quantity: input.Quantity,
Reason: "purchase",
Comment: input.Comment,
}
if err := tx.Create(&movement).Error; err != nil {
return err
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Приход оформлен"})
}
func StockOut(c *gin.Context) {
var input struct {
ItemID uint `json:"item_id" binding:"required"`
Quantity float64 `json:"quantity" binding:"required"`
Comment string `json:"comment"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Неверные данные"})
return
}
if input.Quantity <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Количество должно быть положительным"})
return
}
err := database.DB.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&models.Item{}).
Where("id = ? AND stock >= ? ", input.ItemID, input.Quantity).
Update("stock", gorm.Expr("stock - ?", input.Quantity))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("товар не найден или не достаточно товара")
}
movement := models.StockMovement{
ItemID: input.ItemID,
Type: "out",
Quantity: input.Quantity,
Reason: "sale",
Comment: input.Comment,
}
if err := tx.Create(&movement).Error; err != nil {
return err
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Уход оформлен"})
}
func GetStockMovements(c *gin.Context) {
itemID := c.Query("item_id")
var movements []models.StockMovement
query := database.DB.Preload("Item").Order("created_at desc")
if itemID != "" {
query = query.Where("item_id = ?", itemID)
}
if err := query.Find(&movements).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось получить историю"})
return
}
c.JSON(http.StatusOK, movements)
}
+13 -1
View File
@@ -154,7 +154,6 @@ func UpdateProfile(c *gin.Context) {
FirstName: input.FirstName, FirstName: input.FirstName,
LastName: input.LastName, LastName: input.LastName,
Phone: input.Phone, Phone: input.Phone,
Role: input.Role,
}).Error; err != nil { }).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось обновить данные", "details": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось обновить данные", "details": err.Error()})
return return
@@ -162,3 +161,16 @@ func UpdateProfile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Данные успешно обновлены"}) c.JSON(http.StatusOK, gin.H{"message": "Данные успешно обновлены"})
} }
func GetUserInfo(c *gin.Context) {
userID := c.MustGet("user_id")
var user models.User
if err := database.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка при получение профиля"})
return
}
c.JSON(http.StatusOK, user)
}
+7 -1
View File
@@ -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())
@@ -56,6 +56,8 @@ func main() {
protected.POST("/orders/", handlers.CreateOrder) protected.POST("/orders/", handlers.CreateOrder)
protected.GET("/orders", handlers.GetUserOrders) protected.GET("/orders", handlers.GetUserOrders)
protected.GET("/orders/:id", handlers.GetOrder) protected.GET("/orders/:id", handlers.GetOrder)
protected.PATCH("/profile", handlers.UpdateProfile)
protected.GET("/profile", handlers.GetUserInfo)
} }
admin := protected.Group("/admin") admin := protected.Group("/admin")
@@ -64,6 +66,10 @@ 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)
admin.POST("/stock/in", handlers.StockIn)
admin.POST("/stock/out", handlers.StockOut)
admin.GET("/stock/movements", handlers.GetStockMovements)
} }
} }
+1 -1
View File
@@ -12,7 +12,7 @@ type Order struct {
TotalPrice float64 `json:"total_price" gorm:"not null"` TotalPrice float64 `json:"total_price" gorm:"not null"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Items []OrderItem Items []OrderItem `json:"items"`
} }
type OrderItem struct { type OrderItem struct {
+17
View File
@@ -0,0 +1,17 @@
package models
import (
"time"
)
type StockMovement struct {
ID uint `json:"id" gorm:"primaryKey"`
ItemID uint `json:"item_id"`
Item Item `json:"item" gorm:"foreignKey:ItemID"`
Type string `json:"type"`
Quantity float64 `json:"quantity"`
Reason string `json:"reason" gorm:"not null"`
OrderID *uint `json:"order_id"`
Comment string `json:"comment"`
CreatedAt time.Time `json:"created_at"`
}
-1
View File
@@ -4,5 +4,4 @@ type UpdateUserInput struct {
FirstName string `json:"first_name"` FirstName string `json:"first_name"`
LastName string `json:"last_name"` LastName string `json:"last_name"`
Phone string `json:"phone"` Phone string `json:"phone"`
Role string `json:"role"`
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import "gorm.io/gorm"
type User struct { type User struct {
gorm.Model `json:"-"` gorm.Model `json:"-"`
Username string `gorm:"unique;not null" binding:"required"` Username string `json:"username" gorm:"unique;not null" binding:"required"`
Password string `gorm:"not null" json:"-" binding:"required,min=8"` Password string `gorm:"not null" json:"-" binding:"required,min=8"`
Role string `gorm:"default:'customer'" json:"role"` Role string `gorm:"default:'customer'" json:"role"`
FirstName string `json:"first_name" binding:"required"` FirstName string `json:"first_name" binding:"required"`
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+28 -1
View File
@@ -7,6 +7,9 @@ import Shop from "./pages/Shop";
import NotFoundPage from "./pages/NotFoundPage"; import NotFoundPage from "./pages/NotFoundPage";
import Contact from "./pages/Contact"; import Contact from "./pages/Contact";
import Cart from "./pages/Cart"; import Cart from "./pages/Cart";
import Profile from "./pages/Profile";
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");
@@ -17,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>
@@ -27,6 +45,7 @@ export default function App() {
<Route path="/about" element={<About />} /> <Route path="/about" element={<About />} />
<Route path="/shop" element={<Shop />} /> <Route path="/shop" element={<Shop />} />
<Route path="/contact" element={<Contact />} /> <Route path="/contact" element={<Contact />} />
<Route path="/orders" element={<Profile />} />
<Route <Route
path="/cart" path="/cart"
element={ element={
@@ -35,7 +54,15 @@ export default function App() {
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route
path="/admin"
element={
<AdminRoute>
<AdminPage />
</AdminRoute>
}
/>
<Route path="items/:id" element={<ItemPage />} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
+25 -2
View File
@@ -42,8 +42,31 @@ 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 = (data) => axios.patch(`${API_URL}/profile`, data);
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" },
});
};
export const getAllOrders = () => axios.get(`${API_URL}/admin/orders`);
export const updateOrderStatus = (id, data) =>
axios.patch(`${API_URL}/admin/orders/${id}`, data);
export const registerAdmin = (data) =>
axios.post(`${API_URL}/admin/register`, data);
export const stockIn = (data) => axios.post(`${API_URL}/admin/stock/in`, data);
export const stockOut = (data) =>
axios.post(`${API_URL}/admin/stock/out`, data);
export const getStockMovements = () =>
axios.get(`${API_URL}/admin/stock/movements`);
+282
View File
@@ -0,0 +1,282 @@
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 () => {
if (!form.name.trim()) {
alert("Введите название товара");
return;
}
if (!form.category.trim()) {
alert("Введите категорию");
return;
}
if (!form.price || Number(form.price) <= 0) {
alert("Введите корректную цену");
return;
}
if (form.stock === "" || Number(form.stock) < 0) {
alert("Введите остаток (0 или больше)");
return;
}
if (!isEdit && !file) {
alert("Добавьте картинку товара");
return;
}
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 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>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { useState, useEffect } from "react";
import { getAllOrders, updateOrderStatus } from "../api/index";
const STATUSES = [
{ value: "new", label: "Новый" },
{ value: "packed", label: "Собран" },
{ value: "delivered", label: "Доставлен" },
{ value: "cancel", label: "Отменён" },
];
export default function AdminOrders() {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const loadOrders = () => {
getAllOrders()
.then((res) => setOrders(res.data))
.catch(() => { })
.finally(() => setLoading(false));
};
useEffect(() => {
loadOrders();
}, []);
const handleStatusChange = async (id, newStatus) => {
try {
await updateOrderStatus(id, { status: newStatus });
setOrders((prev) =>
prev.map((o) => (o.id === id ? { ...o, status: newStatus } : o)),
);
} catch {
alert("Не удалось изменить статус");
}
};
if (loading) return <p className="text-white/60">Загрузка...</p>;
return (
<div className="flex flex-col gap-4">
{orders.length === 0 ? (
<p className="text-white/60">Заказов нет</p>
) : (
orders.map((order) => (
<div
key={order.id}
className="bg-white/5 rounded-lg p-4 border border-white/10"
>
{/* шапка: номер + селект статуса */}
<div className="flex justify-between items-center mb-3">
<span className="font-medium text-white">Заказ #{order.id}</span>
<select
value={order.status}
onChange={(e) => handleStatusChange(order.id, e.target.value)}
className="bg-slate-700 text-white text-sm rounded px-3 py-1 border border-white/10"
>
{STATUSES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
{/* товары заказа */}
<div className="flex flex-col gap-2 mb-3 text-sm text-white/70">
{order.items?.map((oi) => (
<div key={oi.id} className="flex items-center gap-3">
<img
src={`/static/items/${oi.item?.avatar_url}`}
alt=""
className="w-20 h-20 object-cover rounded shrink-0"
/>
<span className="grow">{oi.item?.name}</span>
<span className="whitespace-nowrap">
{oi.quantity} {oi.item?.unit} × {oi.price}
</span>
</div>
))}
</div>
{/* подвал: адрес + итог */}
<div className="flex justify-between pt-2 border-t border-white/10 text-sm">
<span className="text-white/50">{order.delivery_address}</span>
<span className="font-medium text-white">
Итого: {order.total_price}
</span>
</div>
</div>
))
)}
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { useState } from "react";
import { registerAdmin } from "../api/index";
const ROLES = [
{ value: "Рабочий", label: "Рабочий" },
{ value: "Админ", label: "Администратор" },
];
export default function AdminStaff() {
const [form, setForm] = useState({
username: "",
password: "",
first_name: "",
last_name: "",
phone: "",
role: "Рабочий",
});
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState(null);
const handleChange = (field) => (e) =>
setForm({ ...form, [field]: e.target.value });
const handleSubmit = async () => {
// валидация
if (!form.username.trim()) {
setMessage({ type: "error", text: "Введите логин" });
return;
}
if (form.password.length < 8) {
setMessage({ type: "error", text: "Пароль минимум 8 символов" });
return;
}
if (!form.first_name.trim() || !form.last_name.trim()) {
setMessage({ type: "error", text: "Введите имя и фамилию" });
return;
}
setBusy(true);
setMessage(null);
try {
await registerAdmin(form);
setMessage({ type: "success", text: "Сотрудник зарегистрирован" });
// очистить форму
setForm({
username: "",
password: "",
first_name: "",
last_name: "",
phone: "",
role: "Рабочий",
});
} catch (err) {
setMessage({
type: "error",
text: err.response?.data?.error || "Не удалось зарегистрировать",
});
} finally {
setBusy(false);
}
};
const inputClass =
"bg-white/5 border border-white/10 rounded px-3 py-2 text-white placeholder-white/40 focus:border-gold focus:outline-none";
return (
<div className="max-w-md">
<h2 className="text-xl font-medium text-white mb-4">
Регистрация сотрудника
</h2>
<div className="flex flex-col gap-3">
<input
className={inputClass}
placeholder="Логин"
value={form.username}
onChange={handleChange("username")}
/>
<input
type="password"
className={inputClass}
placeholder="Пароль (минимум 8 символов)"
value={form.password}
onChange={handleChange("password")}
/>
<input
className={inputClass}
placeholder="Имя"
value={form.first_name}
onChange={handleChange("first_name")}
/>
<input
className={inputClass}
placeholder="Фамилия"
value={form.last_name}
onChange={handleChange("last_name")}
/>
<input
className={inputClass}
placeholder="Телефон (необязательно)"
value={form.phone}
onChange={handleChange("phone")}
/>
{/* роль */}
<select
className={inputClass}
value={form.role}
onChange={handleChange("role")}
>
{ROLES.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
{/* сообщение */}
{message && (
<p
className={`text-sm ${message.type === "success" ? "text-green-400" : "text-red-400"
}`}
>
{message.text}
</p>
)}
<button
onClick={handleSubmit}
disabled={busy}
className="bg-gold text-black px-6 py-2 rounded font-medium uppercase text-sm disabled:opacity-50"
>
{busy ? "Регистрация..." : "Зарегистрировать"}
</button>
</div>
</div>
);
}
+259
View File
@@ -0,0 +1,259 @@
import { useState, useEffect } from "react";
import {
getStockMovements,
stockIn,
stockOut,
getAllItems,
} from "../api/index";
const REASON_LABELS = {
purchase: "Закупка",
order: "Продажа",
writeoff: "Списание",
correction: "Корректировка",
};
export default function AdminStock() {
const [movements, setMovements] = useState([]);
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
// состояние форм
const [inForm, setInForm] = useState({
item_id: "",
quantity: "",
comment: "",
});
const [outForm, setOutForm] = useState({
item_id: "",
quantity: "",
comment: "",
});
const [busy, setBusy] = useState(false);
const loadAll = () => {
Promise.all([getStockMovements(), getAllItems()])
.then(([movRes, itemsRes]) => {
setMovements(movRes.data);
setItems(itemsRes.data);
})
.catch(() => { })
.finally(() => setLoading(false));
};
useEffect(() => {
loadAll();
}, []);
// приход
const handleStockIn = async () => {
if (!inForm.item_id || !inForm.quantity || Number(inForm.quantity) <= 0) {
alert("Выберите товар и укажите количество");
return;
}
setBusy(true);
try {
await stockIn({
item_id: Number(inForm.item_id),
quantity: Number(inForm.quantity),
comment: inForm.comment,
});
setInForm({ item_id: "", quantity: "", comment: "" });
loadAll(); // обновить историю и остатки
} catch {
alert("Не удалось оформить приход");
} finally {
setBusy(false);
}
};
// уход
const handleStockOut = async () => {
if (
!outForm.item_id ||
!outForm.quantity ||
Number(outForm.quantity) <= 0
) {
alert("Выберите товар и укажите количество");
return;
}
setBusy(true);
try {
await stockOut({
item_id: Number(outForm.item_id),
quantity: Number(outForm.quantity),
comment: outForm.comment,
});
setOutForm({ item_id: "", quantity: "", comment: "" });
loadAll();
} catch (err) {
// бэк возвращает "недостаточно товара" при нехватке
alert(err.response?.data?.error || "Не удалось оформить списание");
} finally {
setBusy(false);
}
};
if (loading) return <p className="text-white/60">Загрузка...</p>;
// общий класс инпутов
const inputClass =
"bg-slate-800 border border-white/10 rounded px-3 py-2 text-white placeholder-white/40 focus:border-gold focus:outline-none";
return (
<div className="flex flex-col gap-8">
{/* === Формы === */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Приход */}
<div className="bg-white/5 border border-white/10 rounded-lg p-5">
<h3 className="text-lg font-medium text-green-400 mb-4">
Приход товара
</h3>
<div className="flex flex-col gap-3">
<select
className={inputClass}
value={inForm.item_id}
onChange={(e) =>
setInForm({ ...inForm, item_id: e.target.value })
}
>
<option value="">Выберите товар</option>
{items.map((it) => (
<option key={it.id} value={it.id}>
{it.name} (остаток: {it.stock} {it.unit})
</option>
))}
</select>
<input
type="number"
className={inputClass}
placeholder="Количество"
value={inForm.quantity}
onChange={(e) =>
setInForm({ ...inForm, quantity: e.target.value })
}
/>
<input
className={inputClass}
placeholder="Комментарий (необязательно)"
value={inForm.comment}
onChange={(e) =>
setInForm({ ...inForm, comment: e.target.value })
}
/>
<button
onClick={handleStockIn}
disabled={busy}
className="bg-gold text-black px-6 py-2 rounded font-medium uppercase text-sm disabled:opacity-50"
>
Оприходовать
</button>
</div>
</div>
{/* Уход */}
<div className="bg-white/5 border border-white/10 rounded-lg p-5">
<h3 className="text-lg font-medium text-red-400 mb-4">
Списание товара
</h3>
<div className="flex flex-col gap-3">
<select
className={inputClass}
value={outForm.item_id}
onChange={(e) =>
setOutForm({ ...outForm, item_id: e.target.value })
}
>
<option value="">Выберите товар</option>
{items.map((it) => (
<option key={it.id} value={it.id}>
{it.name} (остаток: {it.stock} {it.unit})
</option>
))}
</select>
<input
type="number"
className={inputClass}
placeholder="Количество"
value={outForm.quantity}
onChange={(e) =>
setOutForm({ ...outForm, quantity: e.target.value })
}
/>
<input
className={inputClass}
placeholder="Комментарий (необязательно)"
value={outForm.comment}
onChange={(e) =>
setOutForm({ ...outForm, comment: e.target.value })
}
/>
<button
onClick={handleStockOut}
disabled={busy}
className="bg-gold text-black px-6 py-2 rounded font-medium uppercase text-sm disabled:opacity-50"
>
Списать
</button>
</div>
</div>
</div>
{/* === История (твоя таблица) === */}
<div>
<h2 className="text-xl font-medium text-white mb-4">
История движений
</h2>
{movements.length === 0 ? (
<p className="text-white/60">Движений нет</p>
) : (
<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>
</tr>
</thead>
<tbody>
{movements.map((m) => (
<tr
key={m.id}
className="border-b border-white/5 hover:bg-white/5 transition-colors"
>
<td className="py-3 pr-4 text-white/70 whitespace-nowrap">
{new Date(m.created_at).toLocaleDateString("ru-RU")}
</td>
<td className="py-3 pr-4 text-white">{m.item?.name}</td>
<td className="py-3 pr-4">
<span
className={
m.type === "in" ? "text-green-400" : "text-red-400"
}
>
{m.type === "in" ? "Приход" : "Уход"}
</span>
</td>
<td className="py-3 pr-4 text-white">
{m.quantity} {m.item?.unit}
</td>
<td className="py-3 pr-4 text-white/70">
{REASON_LABELS[m.reason] || m.reason}
</td>
<td className="py-3 pr-4 text-white/50">
{m.comment || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+63 -55
View File
@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import axios from "axios"; import axios from "axios";
import bgFish from "../assets/Исходники/background.jpg";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
const AuthForm = () => { const AuthForm = () => {
@@ -57,75 +58,82 @@ const AuthForm = () => {
setError(""); setError("");
}; };
// общий класс для всех полей ввода — фирменный стиль
const inputClass =
"block w-full rounded-md bg-white/5 border border-white/10 px-4 py-3 text-white placeholder-white/40 focus:border-gold focus:outline-none transition-colors";
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gray-100 px-4"> <div className="relative flex min-h-screen items-center justify-center px-4 overflow-hidden">
<div className="w-full max-w-md space-y-8 rounded-lg bg-white p-10 shadow-md"> <div
<h2 className="text-center text-3xl font-bold text-gray-900"> className="absolute inset-0 bg-cover bg-center"
{isLogin ? "Вход в FISH FISH" : "Регистрация в FISH FISH"} style={{ backgroundImage: `url(${bgFish})` }}
/>
<div className="absolute inset-0 bg-slate-900/75" />
<div className="relative w-full max-w-md space-y-6 rounded-lg bg-slate-800/60 border border-slate-700/50 p-8 md:p-10">
<h2 className="text-center text-2xl md:text-3xl font-medium uppercase text-white">
{isLogin ? "Вход" : "Регистрация"}
</h2> </h2>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}> <form className="space-y-4" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm space-y-px"> <input
<input type="text"
type="text" required
required className={inputClass}
className="relative block w-full rounded-t-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none sm:text-sm" placeholder="Логин"
placeholder="Логин" value={formData.username}
value={formData.username} onChange={handleChange("username")}
onChange={handleChange("username")} />
/> <input
<input type="password"
type="password" required
required className={inputClass}
className="relative block w-full rounded-b-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none sm:text-sm" placeholder="Пароль"
placeholder="Пароль" value={formData.password}
value={formData.password} onChange={handleChange("password")}
onChange={handleChange("password")} />
/>
{!isLogin && ( {!isLogin && (
<> <>
<input <input
type="text" type="text"
required required
className="relative block w-full rounded-t-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none sm:text-sm" className={inputClass}
placeholder="Имя" placeholder="Имя"
value={formData.first_name} value={formData.first_name}
onChange={handleChange("first_name")} onChange={handleChange("first_name")}
/> />
<input <input
type="text" type="text"
required required
className="relative block w-full rounded-t-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none sm:text-sm" className={inputClass}
placeholder="Фамилия" placeholder="Фамилия"
value={formData.last_name} value={formData.last_name}
onChange={handleChange("last_name")} onChange={handleChange("last_name")}
/> />
<input <input
type="text" type="text"
required required
className="relative block w-full rounded-t-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none sm:text-sm" className={inputClass}
placeholder="Номер телефона" placeholder="Номер телефона"
value={formData.phone} value={formData.phone}
onChange={handleChange("phone")} onChange={handleChange("phone")}
/> />
</> </>
)} )}
</div>
{error && <p className="text-center text-sm text-red-600">{error}</p>} {error && <p className="text-center text-sm text-red-400">{error}</p>}
<button <button
type="submit" type="submit"
className="group relative flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700" className="w-full rounded-md bg-gold text-black px-4 py-3 text-sm font-medium uppercase tracking-wide hover:opacity-90 transition-opacity"
> >
{isLogin ? "Войти" : "Зарегистрироваться"} {isLogin ? "Войти" : "Зарегистрироваться"}
</button> </button>
<p className="text-center text-sm text-gray-600"> <p className="text-center text-sm text-white/60">
{isLogin ? "Нет аккаунта?" : "Уже есть аккаунт?"}{" "} {isLogin ? "Нет аккаунта?" : "Уже есть аккаунт?"}{" "}
<span <span
className="cursor-pointer text-blue-600 hover:underline" className="cursor-pointer text-gold hover:underline"
onClick={toggleMode} onClick={toggleMode}
> >
{isLogin ? "Зарегистрироваться" : "Войти"} {isLogin ? "Зарегистрироваться" : "Войти"}
+12 -2
View File
@@ -1,7 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import Arrow from "../components/Arrow"; import Arrow from "../components/Arrow";
import { useNavigate } from "react-router-dom";
export default function CardItem({ item, onAddToCart }) { export default function CardItem({ item, onAddToCart, onNavigate }) {
const navigate = useNavigate();
const isPiece = item.unit_type === "piece"; const isPiece = item.unit_type === "piece";
const step = isPiece ? 1 : 0.1; const step = isPiece ? 1 : 0.1;
const minValue = isPiece ? 1 : 0.1; const minValue = isPiece ? 1 : 0.1;
@@ -18,9 +20,17 @@ export default function CardItem({ item, onAddToCart }) {
); );
}; };
const handleGoToItem = () => {
if (onNavigate) onNavigate();
navigate(`/items/${item.id}`);
};
return ( return (
<div className="flex flex-col text-white w-full"> <div className="flex flex-col text-white w-full">
<div className="w-full rounded-md overflow-hidden bg-slate-800/60 flex h-[400px] md:h-[600px] flex-col items-center justify-center border border-slate-700/50 select-none"> <div
className="w-full rounded-md overflow-hidden bg-slate-800/60 flex h-[400px] md:h-[600px] flex-col items-center justify-center border border-slate-700/50 select-none cursor-pointer"
onClick={handleGoToItem}
>
<img <img
src={item.avatar_url} src={item.avatar_url}
alt={item.name} alt={item.name}
+13 -1
View File
@@ -4,6 +4,15 @@ import siteLogo from "../assets/Исходники/logo.png";
export default function HeaderDesktop() { export default function HeaderDesktop() {
const navigate = useNavigate(); const navigate = useNavigate();
const handleProfileClick = () => {
const token = localStorage.getItem("token");
if (token) {
navigate("/orders");
} else {
navigate("/login");
}
};
return ( return (
<header className="w-full h-27.5 px-16 flex items-center justify-between "> <header className="w-full h-27.5 px-16 flex items-center justify-between ">
<button <button
@@ -54,7 +63,10 @@ export default function HeaderDesktop() {
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<button className="text-slate-200 hover:text-gold transition-colors duration-200 pr-7"> <button
className="text-slate-200 hover:text-gold transition-colors duration-200 pr-7"
onClick={handleProfileClick}
>
<svg <svg
width="24" width="24"
height="24" height="24"
+12 -1
View File
@@ -6,6 +6,14 @@ import BurgerMenu from "./BurgerMenu";
export default function HeaderMobile() { export default function HeaderMobile() {
const navigate = useNavigate(); const navigate = useNavigate();
const handleProfileClick = () => {
const token = localStorage.getItem("token");
if (token) {
navigate("/orders");
} else {
navigate("/login");
}
};
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -32,7 +40,10 @@ export default function HeaderMobile() {
</button> </button>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<button className="text-slate-200 hover:text-gold transition-colors duration-200 pr-7"> <button
className="text-slate-200 hover:text-gold transition-colors duration-200 pr-7"
onClick={handleProfileClick}
>
<svg <svg
width="24" width="24"
height="24" height="24"
+43
View File
@@ -0,0 +1,43 @@
import { useNavigate } from "react-router-dom";
export default function OrderCard({ order, statusLabels }) {
const navigate = useNavigate();
return (
<div className="bg-white/5 rounded-lg p-4 border border-white/10">
{/* шапка */}
<div className="flex justify-between mb-2">
<span className="font-medium">Заказ #{order.id}</span>
<span className="text-white/60 text-sm uppercase">
{statusLabels[order.status] || order.status}
</span>
</div>
{/* товары */}
<div className="flex flex-col gap-2 mb-3">
{order.items?.map((oi) => (
<div
key={oi.id}
className="flex items-center gap-3 text-sm text-white/70"
>
<img
src={`/static/items/${oi.item?.avatar_url}`}
alt=""
onClick={() => navigate(`/items/${oi.item.id}`)}
className="w-20 h-20 md:w-30 md:h-30 object-cover rounded shrink-0 cursor-pointer"
/>
<span className="grow">{oi.item?.name}</span>
<span className="whitespace-nowrap">
{oi.quantity} {oi.item?.unit} × {oi.price}
</span>
</div>
))}
</div>
{/* подвал */}
<div className="flex justify-between pt-2 border-t border-white/10 text-sm">
<span className="text-white/50">{order.delivery_address}</span>
<span className="font-medium">Итого: {order.total_price} </span>
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { useState } from "react";
import PageLayout from "../components/PageLayout";
import AdminItems from "../components/AdminItems";
import AdminOrders from "../components/AdminOrders";
import AdminStock from "../components/AdminStock";
import AdminStaff from "../components/AdminStaff";
export default function AdminPage() {
const [tab, setTab] = useState("items");
const tabs = [
{ key: "items", label: "Товары" },
{ key: "orders", label: "Заказы" },
{ key: "stock", 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" && <AdminOrders />}
{tab === "stock" && <AdminStock />}
{tab === "staff" && <AdminStaff />}
</main>
</PageLayout>
);
}
+5 -1
View File
@@ -1,7 +1,10 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import PageLayout from "../components/PageLayout"; import PageLayout from "../components/PageLayout";
import { getCart, removeFromCart, createOrder } from "../api/index"; import { getCart, removeFromCart, createOrder } from "../api/index";
import { useNavigate } from "react-router-dom";
export default function Cart() { export default function Cart() {
const navigate = useNavigate();
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -63,7 +66,8 @@ export default function Cart() {
<img <img
src={`/static/items/${ci.item.avatar_url}`} src={`/static/items/${ci.item.avatar_url}`}
alt={ci.item.name} alt={ci.item.name}
className="w-40 h-40 object-cover rounded-md shrink-0" className="w-20 md:w-40 h-20 md:h-40 object-cover rounded-md shrink-0 cursor-pointer"
onClick={() => navigate(`/items/${ci.item.id}`)}
/> />
<div className="grow"> <div className="grow">
<p className="font-medium text-base md:text-lg"> <p className="font-medium text-base md:text-lg">
+152
View File
@@ -0,0 +1,152 @@
import PageLayout from "../components/PageLayout";
import { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import { getItem, addToCart } from "../api/index";
import Arrow from "../components/Arrow";
export default function ItemPage() {
const { id } = useParams();
const [item, setItem] = useState(null);
const [loading, setLoading] = useState(true);
const [quantity, setQuantity] = useState(1);
useEffect(() => {
getItem(id)
.then((res) => setItem(res.data))
.catch(() => { })
.finally(() => setLoading(false));
}, [id]);
const isPiece = item?.unit_type === "piece";
const step = isPiece ? 1 : 0.1;
const minValue = isPiece ? 1 : 0.1;
useEffect(() => {
if (item) setQuantity(isPiece ? 1 : 0.1);
}, [item, isPiece]);
const handleIncrement = () => {
setQuantity((prev) => parseFloat((prev + step).toFixed(3)));
};
const handleDecrement = () => {
setQuantity((prev) =>
prev > minValue ? parseFloat((prev - step).toFixed(3)) : minValue,
);
};
const handleAddToCart = async () => {
try {
await addToCart({ item_id: item.id, quantity });
alert(`${item.name} добавлен в корзину`);
} catch {
alert("Не удалось добавить товар в корзину");
}
};
if (loading) {
return (
<PageLayout>
<p className="text-white p-6 md:p-16">Загрузка...</p>
</PageLayout>
);
}
if (!item) {
return (
<PageLayout>
<p className="text-white p-6 md:p-16">Товар не найден</p>
</PageLayout>
);
}
return (
<PageLayout>
<main className="px-6 md:px-16 grow w-full mb-10 text-white">
<div className="flex flex-col md:flex-row gap-8 md:gap-12 mt-6">
{/* ЛЕВО — фото */}
<div className="md:w-1/4">
<img
src={item.avatar_url}
alt={item.name}
className="w-full h-[300px] md:h-[500px] object-cover rounded-lg"
/>
</div>
{/* ПРАВО — всё инфо в одной колонке */}
<div className="md:w-3/4 flex flex-col">
<h1 className="text-2xl md:text-4xl font-medium uppercase mb-3">
{item.name}
</h1>
<p className="text-2xl md:text-3xl font-medium mb-6">
{item.price}
<span className="text-base text-white/50"> / {item.unit}</span>
</p>
<div className="flex flex-col gap-2 text-white/70 text-sm md:text-base mb-8">
<div className="flex justify-between border-b border-white/10 pb-2">
<span className="text-white/50">Категория</span>
<span>{item.category}</span>
</div>
<div className="flex justify-between border-b border-white/10 pb-2">
<span className="text-white/50">Город</span>
<span>{item.city}</span>
</div>
<div className="flex justify-between border-b border-white/10 pb-2">
<span className="text-white/50">Тип</span>
<span>{isPiece ? "Поштучно" : "На вес"}</span>
</div>
<div className="flex justify-between border-b border-white/10 pb-2">
<span className="text-white/50">В наличии</span>
<span
className={item.stock > 0 ? "text-green-400" : "text-red-400"}
>
{item.stock > 0 ? `${item.stock} ${item.unit}` : "нет"}
</span>
</div>
</div>
<div className="flex items-center gap-6 mb-6">
<span className="text-white/50 text-sm">Количество:</span>
<div className="flex items-center bg-gold rounded-md text-white">
<button
onClick={handleDecrement}
className="w-10 h-10 flex items-center justify-center font-medium text-xl cursor-pointer select-none "
>
-
</button>
<span className="mx-2 font-mono text-sm min-w-20 text-center">
{quantity} {item.unit}
</span>
<button
onClick={handleIncrement}
className="w-10 h-10 flex items-center justify-center font-medium text-xl cursor-pointer select-none "
>
+
</button>
</div>
</div>
<p className="text-lg mb-6">
Стоимость:{" "}
<span className="font-medium">
{parseFloat((quantity * item.price).toFixed(2))}
</span>
</p>
<button
onClick={handleAddToCart}
disabled={item.stock <= 0}
className="group flex items-center gap-4 text-base font-bold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<span>
{item.stock > 0 ? "Добавить в корзину" : "Нет в наличии"}
</span>
<Arrow />
</button>
</div>
</div>
</main>
</PageLayout>
);
}
+6 -4
View File
@@ -8,7 +8,11 @@ export default function Dashboard() {
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => { useEffect(() => {
setIsLoaded(true); requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsLoaded(true);
});
});
}, []); }, []);
return ( return (
@@ -19,9 +23,7 @@ export default function Dashboard() {
> >
<div className="mt-10 md:mt-37.5 text-2xl md:text-6xl font-bold text-white flex flex-col"> <div className="mt-10 md:mt-37.5 text-2xl md:text-6xl font-bold text-white flex flex-col">
<div <div
className={`transition-all duration-1000 ease-out ${isLoaded className={`transition-all duration-1000 ease-out ${isLoaded ? "translate-x-0" : "-translate-x-[100vw]"
? "translate-x-0 opacity-100"
: "-transition-x-full opacity-0"
}`} }`}
> >
Свежие морепродукты <br /> Свежие морепродукты <br />
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import PageLayout from "../components/PageLayout";
import { getUserInfo, getUserOrders } from "../api";
import OrderCard from "../components/OrderCard";
export default function Profile() {
const [user, setUser] = useState(null);
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
Promise.all([getUserInfo(), getUserOrders()])
.then(([userRes, ordersRes]) => {
setUser(userRes.data);
setOrders(ordersRes.data);
})
.catch(() => { })
.finally(() => setLoading(false));
}, []);
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("user");
navigate("/");
};
const statusLabels = {
new: "Новый",
packed: "Собирают",
delivered: "Доставлен",
cancel: "Отменен",
};
if (loading) {
return (
<PageLayout>
<p className="text-white p-6 md:p-16">Загрузка..</p>
</PageLayout>
);
}
const activeOrders = orders.filter(
(o) => o.status === "new" || o.status === "packed",
);
const completedOrders = orders.filter(
(o) => o.status === "delivered" || o.status === "cancel",
);
return (
<PageLayout>
<main className="px-6 md:px-16 grow w-full mb-10 text-white">
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl md:text-4xl font-medium uppercase">
Личный кабинет
</h1>
<button
onClick={handleLogout}
className="text-white/60 hover:text-red-400 transition-colors text-sm uppercase"
>
Выйти
</button>
</div>
<section className="mb-10 bg-white/5 rounded-lg p-6 border border-white/10">
<h2 className="text-white/80 space-y-2">
{user && (
<div className="text-white/80 space-y-2">
<p>
Имя: {user.first_name || "не указано"}{" "}
{user.last_name || "не указано"}
</p>
<p>Телефон: {user.phone || "не указано"}</p>
<p>Логин: {user.username}</p>
</div>
)}
</h2>
</section>
<section className="mb-10">
<h2 className="text-xl font-medium mb-4">Активные заказы</h2>
{activeOrders.length === 0 ? (
<p className="text-white/60">Нет активных заказов</p>
) : (
<div className="flex flex-col gap-4">
{activeOrders.map((order) => (
<OrderCard
key={order.id}
order={order}
statusLabels={statusLabels}
/>
))}
</div>
)}
</section>
<section>
<h2 className="text-xl font-medium mb-4 text-white/50">
Завершённые
</h2>
{completedOrders.length === 0 ? (
<p className="text-white/60">Нет завершённых заказов</p>
) : (
<div className="flex flex-col gap-4">
{completedOrders.map((order) => (
<OrderCard
key={order.id}
order={order}
statusLabels={statusLabels}
/>
))}
</div>
)}
</section>
</main>
</PageLayout>
);
}
+28 -6
View File
@@ -7,9 +7,10 @@ import CardItem from "../components/CardItem";
export default function Shop() { export default function Shop() {
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [activeCategory, setActiveCategory] = useState("все"); const [activeCategory, setActiveCategory] = useState(
() => sessionStorage.getItem("shopCategory") || "все",
);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
Promise.all([api.getAllItems(), api.getAllCategories()]) Promise.all([api.getAllItems(), api.getAllCategories()])
.then(([itemsRes, categoriesRes]) => { .then(([itemsRes, categoriesRes]) => {
@@ -24,16 +25,32 @@ export default function Shop() {
}); });
}, []); }, []);
// восстановление скролла после загрузки товаров
useEffect(() => {
if (!loading && items.length > 0) {
const saved = sessionStorage.getItem("shopScroll");
if (saved) {
window.scrollTo(0, parseInt(saved));
sessionStorage.removeItem("shopScroll");
}
}
}, [loading, items]);
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
return activeCategory === "все" return activeCategory === "все"
? items || [] ? items || []
: (items || []).filter((item) => { : (items || []).filter((item) => {
if (!item || !item.category) return false; if (!item || !item.category) return false;
return item.category.toLowerCase() === activeCategory.toLowerCase(); return item.category.toLowerCase() === activeCategory.toLowerCase();
}); });
}, [items, activeCategory]); }, [items, activeCategory]);
// сохранить позицию и категорию перед уходом на товар
const handleNavigateToItem = () => {
sessionStorage.setItem("shopScroll", window.scrollY);
sessionStorage.setItem("shopCategory", activeCategory);
};
const handleAddToCart = async (product, selectedWeight) => { const handleAddToCart = async (product, selectedWeight) => {
try { try {
await api.addToCart({ await api.addToCart({
@@ -48,11 +65,11 @@ export default function Shop() {
}; };
if (loading) { if (loading) {
return( return (
<PageLayout> <PageLayout>
<p className="text-white grow">Загрузка...</p> <p className="text-white grow">Загрузка...</p>
</PageLayout> </PageLayout>
) );
} }
return ( return (
@@ -68,7 +85,12 @@ export default function Shop() {
/> />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 mb-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 mb-4">
{filteredItems.map((item) => ( {filteredItems.map((item) => (
<CardItem key={item.id} item={item} onAddToCart={handleAddToCart} /> <CardItem
key={item.id}
item={item}
onAddToCart={handleAddToCart}
onNavigate={handleNavigateToItem}
/>
))} ))}
</div> </div>
</main> </main>