Add admin order management with full status workflow.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<a href="/admin/" class="admin-nav__link">Главная</a>
|
||||
<a href="/admin/products" class="admin-nav__link">Товары</a>
|
||||
<a href="/admin/categories" class="admin-nav__link">Категории</a>
|
||||
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
|
||||
<a href="/admin/products/new" class="admin-nav__link admin-nav__link--accent">+ Добавить</a>
|
||||
</nav>
|
||||
<div class="admin-header__user">
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{{define "admin_orders.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/admin.css">
|
||||
<link rel="stylesheet" href="/static/css/shop.css">
|
||||
</head>
|
||||
<body class="admin-body">
|
||||
{{template "admin_nav" .}}
|
||||
|
||||
<main class="admin-main container">
|
||||
<h1 class="admin-page-title">Заказы</h1>
|
||||
|
||||
{{if .Success}}
|
||||
<div class="admin-alert admin-alert--ok">{{.Success}}</div>
|
||||
{{end}}
|
||||
|
||||
<section class="admin-section">
|
||||
<table class="admin-table admin-table--orders">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Дата</th>
|
||||
<th>Клиент</th>
|
||||
<th>Сумма</th>
|
||||
<th>Статус</th>
|
||||
<th>Изменить</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Orders}}
|
||||
{{$orderStatus := .Status}}
|
||||
<tr>
|
||||
<td><strong>{{.ID}}</strong></td>
|
||||
<td>{{.CreatedAt.Format "02.01.2006 15:04"}}</td>
|
||||
<td>
|
||||
<div>{{.GuestName}}</div>
|
||||
<div class="admin-table__muted">{{.GuestEmail}}</div>
|
||||
<div class="admin-table__muted">{{.GuestPhone}}</div>
|
||||
</td>
|
||||
<td>{{printf "%.0f" .Total}} ₽</td>
|
||||
<td>
|
||||
<span class="order-status {{orderClass .Status}}">{{orderLabel .Status}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action="/admin/orders/{{.ID}}/status" class="admin-status-form">
|
||||
<select name="status" class="admin-form__input admin-form__input--sm">
|
||||
{{range $.StatusList}}
|
||||
<option value="{{.Code}}" {{if eq .Code (orderNorm $orderStatus)}}selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button type="submit" class="btn btn--primary btn--sm">OK</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="admin-order-details">
|
||||
<td colspan="6">
|
||||
<strong>Адрес:</strong> {{.Address}}
|
||||
{{if .Items}}
|
||||
<ul class="admin-order-items">
|
||||
{{range .Items}}
|
||||
<li>{{.ProductName}} × {{.Quantity}} — {{printf "%.0f" .Price}} ₽</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="6" class="admin-empty">Заказов пока нет</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<div class="admin-status-legend">
|
||||
<h3 class="admin-section__title">Статусы</h3>
|
||||
<div class="admin-status-legend__grid">
|
||||
{{range .StatusList}}
|
||||
<span class="order-status {{orderClass .Code}}">{{.Label}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -178,7 +178,7 @@
|
||||
<span class="order-status {{orderClass .Status}}">{{orderLabel .Status}}</span>
|
||||
</div>
|
||||
|
||||
{{if ne .Status "cancelled"}}
|
||||
{{if not (orderTerminal .Status)}}
|
||||
<div class="order-timeline">
|
||||
{{range orderTimeline}}
|
||||
<div class="order-timeline__step {{if stepCurrent $.Status .}}order-timeline__step--current{{end}} {{if stepDone $.Status .}}order-timeline__step--done{{end}}">
|
||||
|
||||
Reference in New Issue
Block a user