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 @@
Главная
Товары
Категории
+ Заказы
+ Добавить
- {{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()
+}