diff --git a/README.md b/README.md index 154e767..71ecb6e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Полнофункциональный интернет-магазин на **Go**, **PostgreSQL 17**, **Docker Compose** и **Caddy**. -**Текущая версия:** `v0.30-beta` +**Текущая версия:** `v1.0` ## Возможности @@ -14,7 +14,7 @@ | Корзина | Добавление, изменение количества, расчёт со скидкой | | Заказы | Оформление гостем или зарегистрированным пользователем | | Личный кабинет | Профиль, история заказов со статус-таймлайном | -| Админка | Товары, категории, скриншоты, редактор Quill, статистика | +| Админка | Товары, категории, заказы, скриншоты, редактор Quill, статистика | ## Стек @@ -44,7 +44,7 @@ bash scripts/redeploy.sh ```bash curl http://localhost/health -# {"status":"ok","version":"v0.30-beta"} +# {"status":"ok","version":"v1.0"} ``` ## Маршруты магазина @@ -59,6 +59,7 @@ curl http://localhost/health | `/register`, `/login` | Регистрация и вход | | `/account` | Личный кабинет | | `/admin/` | Админ-панель | +| `/admin/orders` | Управление заказами и статусами | ## Скидки @@ -73,14 +74,17 @@ curl http://localhost/health | Код | Отображение | |-----|-------------| -| `new` | Новый | -| `confirmed` | Подтверждён | +| `pending` | В ожидании | +| `unpaid` | Не оплачено | +| `paid` | Оплачено | | `processing` | В обработке | | `shipped` | Отправлен | | `delivered` | Доставлен | | `cancelled` | Отменён | +| `refunded` | Возврат | -В личном кабинете — цветной бейдж и таймлайн прогресса. +В личном кабинете — цветной бейдж и таймлайн прогресса. +В админке → **Заказы** — смена статуса из выпадающего списка. ## Админ diff --git a/cmd/shop/main.go b/cmd/shop/main.go index e651246..de9b2d9 100644 --- a/cmd/shop/main.go +++ b/cmd/shop/main.go @@ -66,7 +66,7 @@ func main() { productRepo, categoryRepo, userRepo, cartRepo, orderRepo, statsRepo, userSession, cartSession, tmpl, ) - admin := handlers.NewAdminHandler(adminRepo, productRepo, categoryRepo, statsRepo, storage, adminSession, tmpl) + admin := handlers.NewAdminHandler(adminRepo, productRepo, categoryRepo, orderRepo, statsRepo, storage, adminSession, tmpl) mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static/", handlers.StaticHandler())) @@ -103,6 +103,8 @@ func main() { mux.HandleFunc("GET /admin/categories", admin.CategoryList) mux.HandleFunc("POST /admin/categories", admin.CategoryCreate) mux.HandleFunc("POST /admin/categories/{id}/delete", admin.CategoryDelete) + mux.HandleFunc("GET /admin/orders", admin.OrderList) + mux.HandleFunc("POST /admin/orders/{id}/status", admin.OrderUpdateStatus) addr := ":" + getEnv("APP_PORT", "8080") srv := &http.Server{ diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go index d46002f..e709a44 100644 --- a/internal/handlers/admin.go +++ b/internal/handlers/admin.go @@ -16,6 +16,7 @@ type AdminHandler struct { repo *repository.AdminRepository products *repository.ProductRepository categories *repository.CategoryRepository + orders *repository.OrderRepository stats *repository.StatsRepository storage *upload.Storage session *auth.Session @@ -71,6 +72,7 @@ func NewAdminHandler( adminRepo *repository.AdminRepository, productRepo *repository.ProductRepository, categoryRepo *repository.CategoryRepository, + orderRepo *repository.OrderRepository, statsRepo *repository.StatsRepository, storage *upload.Storage, session *auth.Session, @@ -80,6 +82,7 @@ func NewAdminHandler( repo: adminRepo, products: productRepo, categories: categoryRepo, + orders: orderRepo, stats: statsRepo, storage: storage, session: session, diff --git a/internal/handlers/admin_orders.go b/internal/handlers/admin_orders.go new file mode 100644 index 0000000..42968ee --- /dev/null +++ b/internal/handlers/admin_orders.go @@ -0,0 +1,88 @@ +package handlers + +import ( + "log" + "net/http" + "strconv" + + "shop/internal/models" + "shop/internal/orderstatus" +) + +type adminOrdersData struct { + Title string + Email string + Orders []models.Order + StatusList []orderStatusOption + Success string +} + +type orderStatusOption struct { + Code string + Label string +} + +func statusOptions() []orderStatusOption { + var list []orderStatusOption + for _, code := range orderstatus.AllCodes { + list = append(list, orderStatusOption{Code: code, Label: orderstatus.Label(code)}) + } + return list +} + +func (h *AdminHandler) OrderList(w http.ResponseWriter, r *http.Request) { + email, ok := h.requireAuth(w, r) + if !ok { + return + } + + orders, err := h.orders.All(r.Context(), 200) + if err != nil { + log.Printf("admin orders: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + success := r.URL.Query().Get("ok") + msg := "" + if success == "1" { + msg = "Статус заказа обновлён" + } + + h.render(w, "admin_orders.html", adminOrdersData{ + Title: "Заказы", + Email: email, + Orders: orders, + StatusList: statusOptions(), + Success: msg, + }) +} + +func (h *AdminHandler) OrderUpdateStatus(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireAuth(w, r); !ok { + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + http.NotFound(w, r) + return + } + + status := r.FormValue("status") + if !orderstatus.Valid(status) { + http.Redirect(w, r, "/admin/orders", http.StatusSeeOther) + return + } + + if err := h.orders.UpdateStatus(r.Context(), id, status); err != nil { + log.Printf("order status: %v", err) + } + + http.Redirect(w, r, "/admin/orders?ok=1", http.StatusSeeOther) +} diff --git a/internal/handlers/home.go b/internal/handlers/home.go index 35b731d..1b2c755 100644 --- a/internal/handlers/home.go +++ b/internal/handlers/home.go @@ -42,6 +42,8 @@ func ParseTemplates() (*template.Template, error) { "stepCurrent": func(status, step string) bool { return orderstatus.Step(status) == orderstatus.Step(step) }, + "orderTerminal": orderstatus.IsTerminal, + "orderNorm": orderstatus.Normalize, "catSelected": func(catID int, productCatID *int) bool { return productCatID != nil && *productCatID == catID }, diff --git a/internal/handlers/static/css/admin.css b/internal/handlers/static/css/admin.css index fa7f5a6..5215085 100644 --- a/internal/handlers/static/css/admin.css +++ b/internal/handlers/static/css/admin.css @@ -188,6 +188,55 @@ color: #9ca3af; } +.admin-alert--ok { + background: #d1fae5; + color: #065f46; + border: 1px solid #a7f3d0; +} + +.admin-table__muted { + font-size: 0.8rem; + color: #9ca3af; +} + +.admin-table--orders td { + vertical-align: top; +} + +.admin-order-details td { + background: #f9fafb; + font-size: 0.85rem; + color: #6b7280; + padding-top: 0 !important; +} + +.admin-order-items { + margin: 8px 0 0; + padding-left: 18px; +} + +.admin-status-form { + display: flex; + gap: 8px; + align-items: center; +} + +.admin-form__input--sm { + padding: 8px 10px; + font-size: 0.85rem; + min-width: 140px; +} + +.admin-status-legend { + margin-top: 32px; +} + +.admin-status-legend__grid { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + .admin-hint code { background: #e5e7eb; padding: 2px 6px; diff --git a/internal/handlers/static/css/shop.css b/internal/handlers/static/css/shop.css index afc6cac..c31cfa4 100644 --- a/internal/handlers/static/css/shop.css +++ b/internal/handlers/static/css/shop.css @@ -391,12 +391,16 @@ font-weight: 600; } -.status--new { background: #dbeafe; color: #1d4ed8; } -.status--confirmed { background: #e0e7ff; color: #4338ca; } +.status--pending { background: #fef3c7; color: #b45309; } +.status--unpaid { background: #fee2e2; color: #b91c1c; } +.status--paid { background: #dbeafe; color: #1d4ed8; } +.status--new { background: #fef3c7; color: #b45309; } +.status--confirmed { background: #dbeafe; color: #1d4ed8; } .status--processing { background: #ede9fe; color: #6d28d9; } .status--shipped { background: #ffedd5; color: #c2410c; } .status--delivered { background: #d1fae5; color: #065f46; } .status--cancelled { background: #f3f4f6; color: #6b7280; } +.status--refunded { background: #fce7f3; color: #be185d; } .order-timeline { display: flex; diff --git a/internal/handlers/templates/admin_nav.html b/internal/handlers/templates/admin_nav.html index 9c56ff7..61e9aba 100644 --- a/internal/handlers/templates/admin_nav.html +++ b/internal/handlers/templates/admin_nav.html @@ -9,6 +9,7 @@ Главная Товары Категории + Заказы + Добавить
diff --git a/internal/handlers/templates/admin_orders.html b/internal/handlers/templates/admin_orders.html new file mode 100644 index 0000000..a8fb5a8 --- /dev/null +++ b/internal/handlers/templates/admin_orders.html @@ -0,0 +1,90 @@ +{{define "admin_orders.html"}} + + + + + + {{.Title}} + + + + + + {{template "admin_nav" .}} + +
+

Заказы

+ + {{if .Success}} +
{{.Success}}
+ {{end}} + +
+ + + + + + + + + + + + + {{range .Orders}} + {{$orderStatus := .Status}} + + + + + + + + + + + + {{else}} + + {{end}} + +
#ДатаКлиентСуммаСтатусИзменить
{{.ID}}{{.CreatedAt.Format "02.01.2006 15:04"}} +
{{.GuestName}}
+
{{.GuestEmail}}
+
{{.GuestPhone}}
+
{{printf "%.0f" .Total}} ₽ + {{orderLabel .Status}} + +
+ + +
+
+ Адрес: {{.Address}} + {{if .Items}} +
    + {{range .Items}} +
  • {{.ProductName}} × {{.Quantity}} — {{printf "%.0f" .Price}} ₽
  • + {{end}} +
+ {{end}} +
Заказов пока нет
+
+ +
+

Статусы

+
+ {{range .StatusList}} + {{.Label}} + {{end}} +
+
+
+ + +{{end}} diff --git a/internal/handlers/templates/shop_pages.html b/internal/handlers/templates/shop_pages.html index a20acd0..c7b73cc 100644 --- a/internal/handlers/templates/shop_pages.html +++ b/internal/handlers/templates/shop_pages.html @@ -178,7 +178,7 @@ {{orderLabel .Status}}
- {{if ne .Status "cancelled"}} + {{if not (orderTerminal .Status)}}
{{range orderTimeline}}
diff --git a/internal/orderstatus/status.go b/internal/orderstatus/status.go index 4eadd43..03bfc38 100644 --- a/internal/orderstatus/status.go +++ b/internal/orderstatus/status.go @@ -1,37 +1,58 @@ package orderstatus type Info struct { - Label string - Class string - Step int + Label string + Class string + Step int + Terminal bool } var statuses = map[string]Info{ - "new": {Label: "Новый", Class: "status--new", Step: 1}, - "confirmed": {Label: "Подтверждён", Class: "status--confirmed", Step: 2}, + "pending": {Label: "В ожидании", Class: "status--pending", Step: 1}, + "unpaid": {Label: "Не оплачено", Class: "status--unpaid", Step: 1}, + "paid": {Label: "Оплачено", Class: "status--paid", Step: 2}, "processing": {Label: "В обработке", Class: "status--processing", Step: 3}, "shipped": {Label: "Отправлен", Class: "status--shipped", Step: 4}, "delivered": {Label: "Доставлен", Class: "status--delivered", Step: 5}, - "cancelled": {Label: "Отменён", Class: "status--cancelled", Step: 0}, + "cancelled": {Label: "Отменён", Class: "status--cancelled", Step: 0, Terminal: true}, + "refunded": {Label: "Возврат", Class: "status--refunded", Step: 0, Terminal: true}, + // совместимость со старыми заказами + "new": {Label: "В ожидании", Class: "status--pending", Step: 1}, + "confirmed": {Label: "Оплачено", Class: "status--paid", Step: 2}, } -var Timeline = []string{"new", "confirmed", "processing", "shipped", "delivered"} +// Timeline — основной путь доставки (для таймлайна в кабинете) +var Timeline = []string{"pending", "paid", "processing", "shipped", "delivered"} + +// AllCodes — все статусы для выбора в админке +var AllCodes = []string{ + "pending", "unpaid", "paid", "processing", "shipped", "delivered", "cancelled", "refunded", +} + +func Normalize(code string) string { + if code == "new" { + return "pending" + } + if code == "confirmed" { + return "paid" + } + return code +} func Get(code string) Info { + code = Normalize(code) if s, ok := statuses[code]; ok { return s } - return Info{Label: code, Class: "status--new", Step: 1} + return Info{Label: code, Class: "status--pending", Step: 1} } -func Label(code string) string { - return Get(code).Label -} +func Label(code string) string { return Get(code).Label } +func Class(code string) string { return Get(code).Class } +func Step(code string) int { return Get(code).Step } +func IsTerminal(code string) bool { return Get(code).Terminal } -func Class(code string) string { - return Get(code).Class -} - -func Step(code string) int { - return Get(code).Step +func Valid(code string) bool { + _, ok := statuses[Normalize(code)] + return ok } diff --git a/internal/repository/order.go b/internal/repository/order.go index 0cac53c..76a51f0 100644 --- a/internal/repository/order.go +++ b/internal/repository/order.go @@ -3,6 +3,7 @@ package repository import ( "context" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "shop/internal/models" @@ -25,7 +26,7 @@ func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items err = tx.QueryRow(ctx, ` INSERT INTO orders (user_id, guest_name, guest_email, guest_phone, address, total, status) - VALUES ($1, $2, $3, $4, $5, $6, 'new') + VALUES ($1, $2, $3, $4, $5, $6, 'pending') RETURNING id, created_at `, order.UserID, order.GuestName, order.GuestEmail, order.GuestPhone, order.Address, order.Total, ).Scan(&order.ID, &order.CreatedAt) @@ -91,3 +92,62 @@ func (r *OrderRepository) items(ctx context.Context, orderID int) ([]models.Orde } return items, rows.Err() } + +func (r *OrderRepository) All(ctx context.Context, limit int) ([]models.Order, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, user_id, guest_name, guest_email, guest_phone, address, total, status, created_at + FROM orders ORDER BY created_at DESC LIMIT $1 + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return r.collectOrders(ctx, rows) +} + +func (r *OrderRepository) GetByID(ctx context.Context, id int) (models.Order, error) { + var o models.Order + err := r.pool.QueryRow(ctx, ` + SELECT id, user_id, guest_name, guest_email, guest_phone, address, total, status, created_at + FROM orders WHERE id = $1 + `, id).Scan(&o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status, &o.CreatedAt) + if err != nil { + return models.Order{}, err + } + o.Items, err = r.items(ctx, o.ID) + return o, err +} + +func (r *OrderRepository) UpdateStatus(ctx context.Context, id int, status string) error { + tag, err := r.pool.Exec(ctx, `UPDATE orders SET status = $1 WHERE id = $2`, status, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} + +func (r *OrderRepository) Count(ctx context.Context) (int, error) { + var n int + err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM orders`).Scan(&n) + return n, err +} + +func (r *OrderRepository) collectOrders(ctx context.Context, rows pgx.Rows) ([]models.Order, error) { + var orders []models.Order + for rows.Next() { + var o models.Order + if err := rows.Scan(&o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status, &o.CreatedAt); err != nil { + return nil, err + } + var err error + o.Items, err = r.items(ctx, o.ID) + if err != nil { + return nil, err + } + orders = append(orders, o) + } + return orders, rows.Err() +}