Compare commits

...

13 Commits

17 changed files with 809 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{},
) )
} }
-1
View File
@@ -74,7 +74,6 @@ func UpdateItem(c *gin.Context) {
City: input.City, City: input.City,
Price: input.Price, Price: input.Price,
Category: input.Category, Category: input.Category,
Stock: input.Stock,
}) })
c.JSON(http.StatusOK, item) c.JSON(http.StatusOK, item)
+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)
}
+3
View File
@@ -67,6 +67,9 @@ func main() {
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.PATCH("/items/:id/avatar", handlers.UploadItemAvatar)
admin.POST("/stock/in", handlers.StockIn)
admin.POST("/stock/out", handlers.StockOut)
admin.GET("/stock/movements", handlers.GetStockMovements)
} }
} }
+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"`
}
+13 -1
View File
@@ -48,7 +48,7 @@ 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`); export const updateProfile = (data) => axios.patch(`${API_URL}/profile`, data);
export const getUserInfo = () => axios.get(`${API_URL}/profile`); export const getUserInfo = () => axios.get(`${API_URL}/profile`);
export const uploadItemAvatar = (id, file) => { export const uploadItemAvatar = (id, file) => {
@@ -58,3 +58,15 @@ export const uploadItemAvatar = (id, file) => {
headers: { "Content-Type": "multipart/form-data" }, 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`);
+21 -1
View File
@@ -26,6 +26,26 @@ function ItemFormModal({ item, onClose, onSaved }) {
setForm({ ...form, [field]: e.target.value }); setForm({ ...form, [field]: e.target.value });
const handleSubmit = async () => { 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); setSaving(true);
try { try {
// числовые поля привести к числам // числовые поля привести к числам
@@ -128,7 +148,7 @@ function ItemFormModal({ item, onClose, onSaved }) {
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={saving} disabled={saving}
className="bg-gold text-black px-6 py-2 rounded font-medium flex-grow disabled:opacity-50" className="bg-gold text-black px-6 py-2 rounded font-medium grow disabled:opacity-50"
> >
{saving ? "Сохранение..." : isEdit ? "Сохранить" : "Создать"} {saving ? "Сохранение..." : isEdit ? "Сохранить" : "Создать"}
</button> </button>
+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>
);
}
+24 -16
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,19 +58,27 @@ 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="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.username} value={formData.username}
onChange={handleChange("username")} onChange={handleChange("username")}
@@ -77,7 +86,7 @@ const AuthForm = () => {
<input <input
type="password" type="password"
required required
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" className={inputClass}
placeholder="Пароль" placeholder="Пароль"
value={formData.password} value={formData.password}
onChange={handleChange("password")} onChange={handleChange("password")}
@@ -88,7 +97,7 @@ const AuthForm = () => {
<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")}
@@ -96,7 +105,7 @@ const AuthForm = () => {
<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")}
@@ -104,28 +113,27 @@ const AuthForm = () => {
<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 ? "Зарегистрироваться" : "Войти"}
+7 -2
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import Arrow from "../components/Arrow"; import Arrow from "../components/Arrow";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
export default function CardItem({ item, onAddToCart }) { export default function CardItem({ item, onAddToCart, onNavigate }) {
const navigate = useNavigate(); 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;
@@ -20,11 +20,16 @@ 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 <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" 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={() => navigate(`/items/${item.id}`)} onClick={handleGoToItem}
> >
<img <img
src={item.avatar_url} src={item.avatar_url}
+7 -8
View File
@@ -1,8 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import PageLayout from "../components/PageLayout"; import PageLayout from "../components/PageLayout";
import AdminItems from "../components/AdminItems"; import AdminItems from "../components/AdminItems";
// import AdminOrders from "../components/AdminOrders"; // добавим позже import AdminOrders from "../components/AdminOrders";
// import AdminStaff from "../components/AdminStaff"; // добавим позже import AdminStock from "../components/AdminStock";
import AdminStaff from "../components/AdminStaff";
export default function AdminPage() { export default function AdminPage() {
const [tab, setTab] = useState("items"); const [tab, setTab] = useState("items");
@@ -10,6 +11,7 @@ export default function AdminPage() {
const tabs = [ const tabs = [
{ key: "items", label: "Товары" }, { key: "items", label: "Товары" },
{ key: "orders", label: "Заказы" }, { key: "orders", label: "Заказы" },
{ key: "stock", label: "Склад" },
{ key: "staff", label: "Сотрудники" }, { key: "staff", label: "Сотрудники" },
]; ];
@@ -38,12 +40,9 @@ export default function AdminPage() {
{/* Содержимое активной вкладки */} {/* Содержимое активной вкладки */}
{tab === "items" && <AdminItems />} {tab === "items" && <AdminItems />}
{tab === "orders" && ( {tab === "orders" && <AdminOrders />}
<p className="text-white/50">Раздел заказов в разработке</p> {tab === "stock" && <AdminStock />}
)} {tab === "staff" && <AdminStaff />}
{tab === "staff" && (
<p className="text-white/50">Раздел сотрудников в разработке</p>
)}
</main> </main>
</PageLayout> </PageLayout>
); );
+2 -2
View File
@@ -64,7 +64,7 @@ export default function ItemPage() {
<main className="px-6 md:px-16 grow w-full mb-10 text-white"> <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="flex flex-col md:flex-row gap-8 md:gap-12 mt-6">
{/* ЛЕВО — фото */} {/* ЛЕВО — фото */}
<div className="md:w-1/2"> <div className="md:w-1/4">
<img <img
src={item.avatar_url} src={item.avatar_url}
alt={item.name} alt={item.name}
@@ -73,7 +73,7 @@ export default function ItemPage() {
</div> </div>
{/* ПРАВО — всё инфо в одной колонке */} {/* ПРАВО — всё инфо в одной колонке */}
<div className="md:w-1/2 flex flex-col"> <div className="md:w-3/4 flex flex-col">
<h1 className="text-2xl md:text-4xl font-medium uppercase mb-3"> <h1 className="text-2xl md:text-4xl font-medium uppercase mb-3">
{item.name} {item.name}
</h1> </h1>
+5 -3
View File
@@ -8,7 +8,11 @@ export default function Dashboard() {
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => { useEffect(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsLoaded(true); 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 />
+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>