Compare commits
9 Commits
4110083019
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1efebbb34 | ||
|
|
c87aea47ce | ||
|
|
e6f1d6198f | ||
|
|
dc85b62bdb | ||
|
|
48a32d50fd | ||
|
|
c39bde0b10 | ||
|
|
f9c54b5172 | ||
|
|
a3929bec8d | ||
|
|
459b60c9aa |
@@ -43,6 +43,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
push: true
|
push: true
|
||||||
|
build-args: |
|
||||||
|
GITEA_SHA=${{ gitea.sha }}
|
||||||
tags: |
|
tags: |
|
||||||
gitea.gitea.chedius.ru/${{ gitea.repository_owner }}/delivery-tracker/frontend:latest
|
gitea.gitea.chedius.ru/${{ gitea.repository_owner }}/delivery-tracker/frontend:latest
|
||||||
gitea.gitea.chedius.ru/${{ gitea.repository_owner }}/delivery-tracker/frontend:${{ gitea.sha }}
|
gitea.gitea.chedius.ru/${{ gitea.repository_owner }}/delivery-tracker/frontend:${{ gitea.sha }}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
REGISTRY="gitea.chedius.ru/chedius"
|
REGISTRY="gitea.chedius.ru/chedius"
|
||||||
PLATFORM="linux/amd64"
|
PLATFORM="linux/amd64"
|
||||||
|
COMMIT_SHA=$(git rev-parse --short=12 HEAD 2>/dev/null || echo dev)
|
||||||
|
|
||||||
echo "🔨 Building Docker images..."
|
echo "🔨 Building Docker images..."
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ docker build --platform $PLATFORM -t $REGISTRY/delivery-tracker/backend:latest .
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Build frontend
|
# Build frontend
|
||||||
docker build --platform $PLATFORM -t $REGISTRY/delivery-tracker/frontend:latest ./frontend || {
|
docker build --platform $PLATFORM --build-arg GITEA_SHA=$COMMIT_SHA -t $REGISTRY/delivery-tracker/frontend:latest ./frontend || {
|
||||||
echo "❌ Frontend build failed"
|
echo "❌ Frontend build failed"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|||||||
24
Makefile
24
Makefile
@@ -1,15 +1,17 @@
|
|||||||
# Delivery Tracker - Local Build & Deploy
|
# Delivery Tracker - Local Build & Deploy
|
||||||
REGISTRY = gitea.chedius.ru/chedius
|
REGISTRY = gitea.chedius.ru/chedius
|
||||||
PLATFORM = linux/amd64
|
PLATFORM = linux/amd64
|
||||||
|
COMMIT_SHA := $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo dev)
|
||||||
|
VERSION_PART ?= patch
|
||||||
|
|
||||||
# Build and push both services
|
# Build and push both services
|
||||||
.PHONY: all build push deploy install-hooks
|
.PHONY: all build push deploy install-hooks version version-patch version-minor version-major check-release-message release
|
||||||
|
|
||||||
all: build push
|
all: build push
|
||||||
|
|
||||||
build:
|
build:
|
||||||
docker build --platform $(PLATFORM) -t $(REGISTRY)/delivery-tracker/backend:latest ./backend
|
docker build --platform $(PLATFORM) -t $(REGISTRY)/delivery-tracker/backend:latest ./backend
|
||||||
docker build --platform $(PLATFORM) -t $(REGISTRY)/delivery-tracker/frontend:latest ./frontend
|
docker build --platform $(PLATFORM) --build-arg GITEA_SHA=$(COMMIT_SHA) -t $(REGISTRY)/delivery-tracker/frontend:latest ./frontend
|
||||||
|
|
||||||
push:
|
push:
|
||||||
docker push $(REGISTRY)/delivery-tracker/backend:latest
|
docker push $(REGISTRY)/delivery-tracker/backend:latest
|
||||||
@@ -36,9 +38,23 @@ install-hooks:
|
|||||||
chmod +x .githooks/*
|
chmod +x .githooks/*
|
||||||
@echo "✅ Git hooks installed from .githooks/"
|
@echo "✅ Git hooks installed from .githooks/"
|
||||||
|
|
||||||
# Full release: commit, push, and auto-build via hook
|
version:
|
||||||
release:
|
@node -p "require('./frontend/package.json').version"
|
||||||
|
|
||||||
|
version-patch:
|
||||||
|
cd frontend && npm version patch --no-git-tag-version
|
||||||
|
|
||||||
|
version-minor:
|
||||||
|
cd frontend && npm version minor --no-git-tag-version
|
||||||
|
|
||||||
|
version-major:
|
||||||
|
cd frontend && npm version major --no-git-tag-version
|
||||||
|
|
||||||
|
check-release-message:
|
||||||
@if [ -z "$(MSG)" ]; then echo "Usage: make release MSG='commit message'"; exit 1; fi
|
@if [ -z "$(MSG)" ]; then echo "Usage: make release MSG='commit message'"; exit 1; fi
|
||||||
|
|
||||||
|
# Full release: commit, push, and auto-build via hook
|
||||||
|
release: check-release-message version-$(VERSION_PART)
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "$(MSG)" || true
|
git commit -m "$(MSG)" || true
|
||||||
git push
|
git push
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/chedius/delivery-tracker/internal/auth"
|
"github.com/chedius/delivery-tracker/internal/auth"
|
||||||
db "github.com/chedius/delivery-tracker/internal/db/sqlc"
|
db "github.com/chedius/delivery-tracker/internal/db/sqlc"
|
||||||
"github.com/chedius/delivery-tracker/internal/delivery"
|
"github.com/chedius/delivery-tracker/internal/delivery"
|
||||||
|
"github.com/chedius/delivery-tracker/internal/ws"
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -42,7 +43,8 @@ func main() {
|
|||||||
|
|
||||||
queries := db.New(pool)
|
queries := db.New(pool)
|
||||||
_, authHandler := initAuth(queries)
|
_, authHandler := initAuth(queries)
|
||||||
h := delivery.NewHandler(queries)
|
hub := ws.NewHub()
|
||||||
|
h := delivery.NewHandler(queries, hub)
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
@@ -61,6 +63,7 @@ func main() {
|
|||||||
|
|
||||||
r.POST("/api/auth/register", authHandler.Register)
|
r.POST("/api/auth/register", authHandler.Register)
|
||||||
r.POST("/api/auth/login", authHandler.Login)
|
r.POST("/api/auth/login", authHandler.Login)
|
||||||
|
r.GET("/api/ws", ws.HandleWS(hub, []byte(os.Getenv("JWT_SECRET"))))
|
||||||
|
|
||||||
authorized := r.Group("/api")
|
authorized := r.Group("/api")
|
||||||
authorized.Use(auth.AuthMiddleware([]byte(os.Getenv("JWT_SECRET"))))
|
authorized.Use(auth.AuthMiddleware([]byte(os.Getenv("JWT_SECRET"))))
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ require (
|
|||||||
|
|
||||||
require github.com/golang-jwt/jwt/v5 v5.3.1
|
require github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
|
||||||
|
require github.com/gorilla/websocket v1.5.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
|||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import "errors"
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
@@ -10,3 +13,11 @@ var (
|
|||||||
ErrCredentialsEmpty = errors.New("username and password cannot be empty")
|
ErrCredentialsEmpty = errors.New("username and password cannot be empty")
|
||||||
ErrPasswordTooShort = errors.New("password must be at least 6 characters long")
|
ErrPasswordTooShort = errors.New("password must be at least 6 characters long")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type AccountLockedError struct {
|
||||||
|
LockedUntil time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e AccountLockedError) Error() string {
|
||||||
|
return "account temporarily locked"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -35,6 +36,16 @@ func (h *Handler) Login(c *gin.Context) {
|
|||||||
|
|
||||||
token, err := h.authService.Login(c.Request.Context(), req.Username, req.Password)
|
token, err := h.authService.Login(c.Request.Context(), req.Username, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
var lockErr AccountLockedError
|
||||||
|
if errors.As(err, &lockErr) {
|
||||||
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||||
|
"error": "Слишком много неверных попыток. Попробуйте через 5 минут",
|
||||||
|
"code": "account_temporarily_locked",
|
||||||
|
"locked_until": lockErr.LockedUntil.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch err {
|
switch err {
|
||||||
case ErrUserNotFound, ErrInvalidCredentials:
|
case ErrUserNotFound, ErrInvalidCredentials:
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||||
|
|||||||
@@ -71,10 +71,33 @@ func (s *Service) Login(ctx context.Context, username, password string) (string,
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if user.LockedUntil.Valid {
|
||||||
|
if user.LockedUntil.Time.After(now) {
|
||||||
|
return "", AccountLockedError{LockedUntil: user.LockedUntil.Time}
|
||||||
|
}
|
||||||
|
if err := s.queries.ResetLoginFailures(ctx, username); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !s.VerifyPassword(user.PasswordHash, password) {
|
if !s.VerifyPassword(user.PasswordHash, password) {
|
||||||
|
failedLogin, err := s.queries.RecordFailedLogin(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if failedLogin.LockedUntil.Valid && failedLogin.LockedUntil.Time.After(now) {
|
||||||
|
return "", AccountLockedError{LockedUntil: failedLogin.LockedUntil.Time}
|
||||||
|
}
|
||||||
return "", ErrInvalidCredentials
|
return "", ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user.FailedLoginAttempts > 0 || user.LockedUntil.Valid {
|
||||||
|
if err := s.queries.ResetLoginFailures(ctx, username); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
token, err := GenerateToken(uuid.UUID(user.ID.Bytes), s.secret, s.expiry)
|
token, err := GenerateToken(uuid.UUID(user.ID.Bytes), s.secret, s.expiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN locked_until,
|
||||||
|
DROP COLUMN failed_login_attempts;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN locked_until TIMESTAMPTZ;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE deliveries DROP COLUMN warehouse_request_source_2;
|
||||||
|
ALTER TABLE deliveries DROP COLUMN warehouse_request_source;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE deliveries ADD COLUMN warehouse_request_source varchar(20);
|
||||||
|
ALTER TABLE deliveries ADD COLUMN warehouse_request_source_2 varchar(20);
|
||||||
@@ -6,16 +6,35 @@ RETURNING *;
|
|||||||
-- name: GetUserByUsername :one
|
-- name: GetUserByUsername :one
|
||||||
SELECT * FROM users WHERE username = $1;
|
SELECT * FROM users WHERE username = $1;
|
||||||
|
|
||||||
|
-- name: ResetLoginFailures :exec
|
||||||
|
UPDATE users
|
||||||
|
SET failed_login_attempts = 0,
|
||||||
|
locked_until = NULL
|
||||||
|
WHERE username = $1;
|
||||||
|
|
||||||
|
-- name: RecordFailedLogin :one
|
||||||
|
UPDATE users
|
||||||
|
SET failed_login_attempts = CASE
|
||||||
|
WHEN failed_login_attempts + 1 >= 3 THEN 3
|
||||||
|
ELSE failed_login_attempts + 1
|
||||||
|
END,
|
||||||
|
locked_until = CASE
|
||||||
|
WHEN failed_login_attempts + 1 >= 3 THEN NOW() + INTERVAL '5 minutes'
|
||||||
|
ELSE locked_until
|
||||||
|
END
|
||||||
|
WHERE username = $1
|
||||||
|
RETURNING failed_login_attempts, locked_until;
|
||||||
|
|
||||||
-- name: GetDeliveriesByDate :many
|
-- name: GetDeliveriesByDate :many
|
||||||
SELECT * FROM deliveries WHERE date = $1;
|
SELECT * FROM deliveries WHERE date = $1;
|
||||||
|
|
||||||
-- name: CreateDelivery :one
|
-- name: CreateDelivery :one
|
||||||
INSERT INTO deliveries (
|
INSERT INTO deliveries (
|
||||||
date, pickup_location, pickup_location_2, product_name, product_name_2,
|
date, pickup_location, pickup_location_2, warehouse_request_source, warehouse_request_source_2, product_name, product_name_2,
|
||||||
customer_name, address, street, house, apartment, entrance, floor,
|
customer_name, address, street, house, apartment, entrance, floor,
|
||||||
phone, additional_phone, has_elevator, service_info, comment
|
phone, additional_phone, has_elevator, service_info, comment
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetDeliveryByID :one
|
-- name: GetDeliveryByID :one
|
||||||
@@ -29,22 +48,24 @@ UPDATE deliveries SET
|
|||||||
date = $1,
|
date = $1,
|
||||||
pickup_location = $2,
|
pickup_location = $2,
|
||||||
pickup_location_2 = $3,
|
pickup_location_2 = $3,
|
||||||
product_name = $4,
|
warehouse_request_source = $4,
|
||||||
product_name_2 = $5,
|
warehouse_request_source_2 = $5,
|
||||||
customer_name = $6,
|
product_name = $6,
|
||||||
address = $7,
|
product_name_2 = $7,
|
||||||
street = $8,
|
customer_name = $8,
|
||||||
house = $9,
|
address = $9,
|
||||||
apartment = $10,
|
street = $10,
|
||||||
entrance = $11,
|
house = $11,
|
||||||
floor = $12,
|
apartment = $12,
|
||||||
phone = $13,
|
entrance = $13,
|
||||||
additional_phone = $14,
|
floor = $14,
|
||||||
has_elevator = $15,
|
phone = $15,
|
||||||
service_info = $16,
|
additional_phone = $16,
|
||||||
comment = $17,
|
has_elevator = $17,
|
||||||
|
service_info = $18,
|
||||||
|
comment = $19,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $18;
|
WHERE id = $20;
|
||||||
|
|
||||||
-- name: GetDeliveryCount :many
|
-- name: GetDeliveryCount :many
|
||||||
SELECT COUNT(*) as count, date FROM deliveries
|
SELECT COUNT(*) as count, date FROM deliveries
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ type Delivery struct {
|
|||||||
Apartment pgtype.Text `db:"apartment" json:"apartment"`
|
Apartment pgtype.Text `db:"apartment" json:"apartment"`
|
||||||
Entrance pgtype.Text `db:"entrance" json:"entrance"`
|
Entrance pgtype.Text `db:"entrance" json:"entrance"`
|
||||||
Floor pgtype.Text `db:"floor" json:"floor"`
|
Floor pgtype.Text `db:"floor" json:"floor"`
|
||||||
|
WarehouseRequestSource pgtype.Text `db:"warehouse_request_source" json:"warehouse_request_source"`
|
||||||
|
WarehouseRequestSource2 pgtype.Text `db:"warehouse_request_source_2" json:"warehouse_request_source_2"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
@@ -37,4 +39,6 @@ type User struct {
|
|||||||
Username string `db:"username" json:"username"`
|
Username string `db:"username" json:"username"`
|
||||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||||
CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"`
|
||||||
|
FailedLoginAttempts int32 `db:"failed_login_attempts" json:"failed_login_attempts"`
|
||||||
|
LockedUntil pgtype.Timestamptz `db:"locked_until" json:"locked_until"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ type Querier interface {
|
|||||||
GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery, error)
|
GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery, error)
|
||||||
GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow, error)
|
GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow, error)
|
||||||
GetUserByUsername(ctx context.Context, username string) (User, error)
|
GetUserByUsername(ctx context.Context, username string) (User, error)
|
||||||
|
RecordFailedLogin(ctx context.Context, username string) (RecordFailedLoginRow, error)
|
||||||
|
ResetLoginFailures(ctx context.Context, username string) error
|
||||||
UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams) error
|
UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams) error
|
||||||
UpdateDeliveryStatus(ctx context.Context, arg UpdateDeliveryStatusParams) error
|
UpdateDeliveryStatus(ctx context.Context, arg UpdateDeliveryStatusParams) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,18 +13,20 @@ import (
|
|||||||
|
|
||||||
const createDelivery = `-- name: CreateDelivery :one
|
const createDelivery = `-- name: CreateDelivery :one
|
||||||
INSERT INTO deliveries (
|
INSERT INTO deliveries (
|
||||||
date, pickup_location, pickup_location_2, product_name, product_name_2,
|
date, pickup_location, pickup_location_2, warehouse_request_source, warehouse_request_source_2, product_name, product_name_2,
|
||||||
customer_name, address, street, house, apartment, entrance, floor,
|
customer_name, address, street, house, apartment, entrance, floor,
|
||||||
phone, additional_phone, has_elevator, service_info, comment
|
phone, additional_phone, has_elevator, service_info, comment
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||||
RETURNING id, date, pickup_location, product_name, address, phone, additional_phone, has_elevator, comment, status, created_at, updated_at, customer_name, service_info, pickup_location_2, product_name_2, street, house, apartment, entrance, floor
|
RETURNING id, date, pickup_location, product_name, address, phone, additional_phone, has_elevator, comment, status, created_at, updated_at, customer_name, service_info, pickup_location_2, product_name_2, street, house, apartment, entrance, floor, warehouse_request_source, warehouse_request_source_2
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateDeliveryParams struct {
|
type CreateDeliveryParams struct {
|
||||||
Date pgtype.Date `db:"date" json:"date"`
|
Date pgtype.Date `db:"date" json:"date"`
|
||||||
PickupLocation string `db:"pickup_location" json:"pickup_location"`
|
PickupLocation string `db:"pickup_location" json:"pickup_location"`
|
||||||
PickupLocation2 pgtype.Text `db:"pickup_location_2" json:"pickup_location_2"`
|
PickupLocation2 pgtype.Text `db:"pickup_location_2" json:"pickup_location_2"`
|
||||||
|
WarehouseRequestSource pgtype.Text `db:"warehouse_request_source" json:"warehouse_request_source"`
|
||||||
|
WarehouseRequestSource2 pgtype.Text `db:"warehouse_request_source_2" json:"warehouse_request_source_2"`
|
||||||
ProductName string `db:"product_name" json:"product_name"`
|
ProductName string `db:"product_name" json:"product_name"`
|
||||||
ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"`
|
ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"`
|
||||||
CustomerName string `db:"customer_name" json:"customer_name"`
|
CustomerName string `db:"customer_name" json:"customer_name"`
|
||||||
@@ -46,6 +48,8 @@ func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams)
|
|||||||
arg.Date,
|
arg.Date,
|
||||||
arg.PickupLocation,
|
arg.PickupLocation,
|
||||||
arg.PickupLocation2,
|
arg.PickupLocation2,
|
||||||
|
arg.WarehouseRequestSource,
|
||||||
|
arg.WarehouseRequestSource2,
|
||||||
arg.ProductName,
|
arg.ProductName,
|
||||||
arg.ProductName2,
|
arg.ProductName2,
|
||||||
arg.CustomerName,
|
arg.CustomerName,
|
||||||
@@ -84,6 +88,8 @@ func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams)
|
|||||||
&i.Apartment,
|
&i.Apartment,
|
||||||
&i.Entrance,
|
&i.Entrance,
|
||||||
&i.Floor,
|
&i.Floor,
|
||||||
|
&i.WarehouseRequestSource,
|
||||||
|
&i.WarehouseRequestSource2,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -91,7 +97,7 @@ func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams)
|
|||||||
const createUser = `-- name: CreateUser :one
|
const createUser = `-- name: CreateUser :one
|
||||||
INSERT INTO users (username, password_hash)
|
INSERT INTO users (username, password_hash)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2)
|
||||||
RETURNING id, username, password_hash, created_at
|
RETURNING id, username, password_hash, created_at, failed_login_attempts, locked_until
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateUserParams struct {
|
type CreateUserParams struct {
|
||||||
@@ -107,6 +113,8 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||||||
&i.Username,
|
&i.Username,
|
||||||
&i.PasswordHash,
|
&i.PasswordHash,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.FailedLoginAttempts,
|
||||||
|
&i.LockedUntil,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -121,7 +129,7 @@ func (q *Queries) DeleteDelivery(ctx context.Context, id pgtype.UUID) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDeliveriesByDate = `-- name: GetDeliveriesByDate :many
|
const getDeliveriesByDate = `-- name: GetDeliveriesByDate :many
|
||||||
SELECT id, date, pickup_location, product_name, address, phone, additional_phone, has_elevator, comment, status, created_at, updated_at, customer_name, service_info, pickup_location_2, product_name_2, street, house, apartment, entrance, floor FROM deliveries WHERE date = $1
|
SELECT id, date, pickup_location, product_name, address, phone, additional_phone, has_elevator, comment, status, created_at, updated_at, customer_name, service_info, pickup_location_2, product_name_2, street, house, apartment, entrance, floor, warehouse_request_source, warehouse_request_source_2 FROM deliveries WHERE date = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetDeliveriesByDate(ctx context.Context, date pgtype.Date) ([]Delivery, error) {
|
func (q *Queries) GetDeliveriesByDate(ctx context.Context, date pgtype.Date) ([]Delivery, error) {
|
||||||
@@ -155,6 +163,8 @@ func (q *Queries) GetDeliveriesByDate(ctx context.Context, date pgtype.Date) ([]
|
|||||||
&i.Apartment,
|
&i.Apartment,
|
||||||
&i.Entrance,
|
&i.Entrance,
|
||||||
&i.Floor,
|
&i.Floor,
|
||||||
|
&i.WarehouseRequestSource,
|
||||||
|
&i.WarehouseRequestSource2,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -167,7 +177,7 @@ func (q *Queries) GetDeliveriesByDate(ctx context.Context, date pgtype.Date) ([]
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDeliveryByID = `-- name: GetDeliveryByID :one
|
const getDeliveryByID = `-- name: GetDeliveryByID :one
|
||||||
SELECT id, date, pickup_location, product_name, address, phone, additional_phone, has_elevator, comment, status, created_at, updated_at, customer_name, service_info, pickup_location_2, product_name_2, street, house, apartment, entrance, floor FROM deliveries WHERE id = $1
|
SELECT id, date, pickup_location, product_name, address, phone, additional_phone, has_elevator, comment, status, created_at, updated_at, customer_name, service_info, pickup_location_2, product_name_2, street, house, apartment, entrance, floor, warehouse_request_source, warehouse_request_source_2 FROM deliveries WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery, error) {
|
func (q *Queries) GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery, error) {
|
||||||
@@ -195,6 +205,8 @@ func (q *Queries) GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery
|
|||||||
&i.Apartment,
|
&i.Apartment,
|
||||||
&i.Entrance,
|
&i.Entrance,
|
||||||
&i.Floor,
|
&i.Floor,
|
||||||
|
&i.WarehouseRequestSource,
|
||||||
|
&i.WarehouseRequestSource2,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -231,7 +243,7 @@ func (q *Queries) GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||||
SELECT id, username, password_hash, created_at FROM users WHERE username = $1
|
SELECT id, username, password_hash, created_at, failed_login_attempts, locked_until FROM users WHERE username = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
|
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
|
||||||
@@ -242,37 +254,81 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
|
|||||||
&i.Username,
|
&i.Username,
|
||||||
&i.PasswordHash,
|
&i.PasswordHash,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.FailedLoginAttempts,
|
||||||
|
&i.LockedUntil,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recordFailedLogin = `-- name: RecordFailedLogin :one
|
||||||
|
UPDATE users
|
||||||
|
SET failed_login_attempts = CASE
|
||||||
|
WHEN failed_login_attempts + 1 >= 3 THEN 3
|
||||||
|
ELSE failed_login_attempts + 1
|
||||||
|
END,
|
||||||
|
locked_until = CASE
|
||||||
|
WHEN failed_login_attempts + 1 >= 3 THEN NOW() + INTERVAL '5 minutes'
|
||||||
|
ELSE locked_until
|
||||||
|
END
|
||||||
|
WHERE username = $1
|
||||||
|
RETURNING failed_login_attempts, locked_until
|
||||||
|
`
|
||||||
|
|
||||||
|
type RecordFailedLoginRow struct {
|
||||||
|
FailedLoginAttempts int32 `db:"failed_login_attempts" json:"failed_login_attempts"`
|
||||||
|
LockedUntil pgtype.Timestamptz `db:"locked_until" json:"locked_until"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) RecordFailedLogin(ctx context.Context, username string) (RecordFailedLoginRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, recordFailedLogin, username)
|
||||||
|
var i RecordFailedLoginRow
|
||||||
|
err := row.Scan(&i.FailedLoginAttempts, &i.LockedUntil)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetLoginFailures = `-- name: ResetLoginFailures :exec
|
||||||
|
UPDATE users
|
||||||
|
SET failed_login_attempts = 0,
|
||||||
|
locked_until = NULL
|
||||||
|
WHERE username = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ResetLoginFailures(ctx context.Context, username string) error {
|
||||||
|
_, err := q.db.Exec(ctx, resetLoginFailures, username)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const updateDelivery = `-- name: UpdateDelivery :exec
|
const updateDelivery = `-- name: UpdateDelivery :exec
|
||||||
UPDATE deliveries SET
|
UPDATE deliveries SET
|
||||||
date = $1,
|
date = $1,
|
||||||
pickup_location = $2,
|
pickup_location = $2,
|
||||||
pickup_location_2 = $3,
|
pickup_location_2 = $3,
|
||||||
product_name = $4,
|
warehouse_request_source = $4,
|
||||||
product_name_2 = $5,
|
warehouse_request_source_2 = $5,
|
||||||
customer_name = $6,
|
product_name = $6,
|
||||||
address = $7,
|
product_name_2 = $7,
|
||||||
street = $8,
|
customer_name = $8,
|
||||||
house = $9,
|
address = $9,
|
||||||
apartment = $10,
|
street = $10,
|
||||||
entrance = $11,
|
house = $11,
|
||||||
floor = $12,
|
apartment = $12,
|
||||||
phone = $13,
|
entrance = $13,
|
||||||
additional_phone = $14,
|
floor = $14,
|
||||||
has_elevator = $15,
|
phone = $15,
|
||||||
service_info = $16,
|
additional_phone = $16,
|
||||||
comment = $17,
|
has_elevator = $17,
|
||||||
|
service_info = $18,
|
||||||
|
comment = $19,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $18
|
WHERE id = $20
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateDeliveryParams struct {
|
type UpdateDeliveryParams struct {
|
||||||
Date pgtype.Date `db:"date" json:"date"`
|
Date pgtype.Date `db:"date" json:"date"`
|
||||||
PickupLocation string `db:"pickup_location" json:"pickup_location"`
|
PickupLocation string `db:"pickup_location" json:"pickup_location"`
|
||||||
PickupLocation2 pgtype.Text `db:"pickup_location_2" json:"pickup_location_2"`
|
PickupLocation2 pgtype.Text `db:"pickup_location_2" json:"pickup_location_2"`
|
||||||
|
WarehouseRequestSource pgtype.Text `db:"warehouse_request_source" json:"warehouse_request_source"`
|
||||||
|
WarehouseRequestSource2 pgtype.Text `db:"warehouse_request_source_2" json:"warehouse_request_source_2"`
|
||||||
ProductName string `db:"product_name" json:"product_name"`
|
ProductName string `db:"product_name" json:"product_name"`
|
||||||
ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"`
|
ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"`
|
||||||
CustomerName string `db:"customer_name" json:"customer_name"`
|
CustomerName string `db:"customer_name" json:"customer_name"`
|
||||||
@@ -295,6 +351,8 @@ func (q *Queries) UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams)
|
|||||||
arg.Date,
|
arg.Date,
|
||||||
arg.PickupLocation,
|
arg.PickupLocation,
|
||||||
arg.PickupLocation2,
|
arg.PickupLocation2,
|
||||||
|
arg.WarehouseRequestSource,
|
||||||
|
arg.WarehouseRequestSource2,
|
||||||
arg.ProductName,
|
arg.ProductName,
|
||||||
arg.ProductName2,
|
arg.ProductName2,
|
||||||
arg.CustomerName,
|
arg.CustomerName,
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package delivery
|
package delivery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
sqlc "github.com/chedius/delivery-tracker/internal/db/sqlc"
|
sqlc "github.com/chedius/delivery-tracker/internal/db/sqlc"
|
||||||
|
"github.com/chedius/delivery-tracker/internal/ws"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -12,6 +15,7 @@ import (
|
|||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
queries *sqlc.Queries
|
queries *sqlc.Queries
|
||||||
|
hub *ws.Hub
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeliveryRequest represents the request body for creating or updating a delivery
|
// DeliveryRequest represents the request body for creating or updating a delivery
|
||||||
@@ -19,6 +23,8 @@ type DeliveryRequest struct {
|
|||||||
Date string `json:"date" binding:"required"` // DD-MM-YYYY
|
Date string `json:"date" binding:"required"` // DD-MM-YYYY
|
||||||
PickupLocation string `json:"pickup_location" binding:"required,oneof=warehouse symbat nursaya galaktika"`
|
PickupLocation string `json:"pickup_location" binding:"required,oneof=warehouse symbat nursaya galaktika"`
|
||||||
PickupLocation2 *string `json:"pickup_location_2" binding:"omitempty,oneof=warehouse symbat nursaya galaktika"`
|
PickupLocation2 *string `json:"pickup_location_2" binding:"omitempty,oneof=warehouse symbat nursaya galaktika"`
|
||||||
|
WarehouseRequestSource *string `json:"warehouse_request_source" binding:"omitempty,oneof=symbat nursaya galaktika"`
|
||||||
|
WarehouseRequestSource2 *string `json:"warehouse_request_source_2" binding:"omitempty,oneof=symbat nursaya galaktika"`
|
||||||
ProductName string `json:"product_name" binding:"required"`
|
ProductName string `json:"product_name" binding:"required"`
|
||||||
ProductName2 *string `json:"product_name_2"`
|
ProductName2 *string `json:"product_name_2"`
|
||||||
CustomerName string `json:"customer_name" binding:"required"`
|
CustomerName string `json:"customer_name" binding:"required"`
|
||||||
@@ -35,8 +41,8 @@ type DeliveryRequest struct {
|
|||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(queries *sqlc.Queries) *Handler {
|
func NewHandler(queries *sqlc.Queries, hub *ws.Hub) *Handler {
|
||||||
return &Handler{queries: queries}
|
return &Handler{queries: queries, hub: hub}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/deliveries/:id
|
// GET /api/deliveries/:id
|
||||||
@@ -97,6 +103,10 @@ func (h *Handler) CreateDelivery(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := normalizeWarehouseRequestSources(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Parse date from DD-MM-YYYY
|
// Parse date from DD-MM-YYYY
|
||||||
t, err := parseDate(req.Date)
|
t, err := parseDate(req.Date)
|
||||||
@@ -109,6 +119,8 @@ func (h *Handler) CreateDelivery(c *gin.Context) {
|
|||||||
Date: pgtype.Date{Time: t, Valid: true},
|
Date: pgtype.Date{Time: t, Valid: true},
|
||||||
PickupLocation: req.PickupLocation,
|
PickupLocation: req.PickupLocation,
|
||||||
PickupLocation2: pgtype.Text{String: derefString(req.PickupLocation2), Valid: req.PickupLocation2 != nil},
|
PickupLocation2: pgtype.Text{String: derefString(req.PickupLocation2), Valid: req.PickupLocation2 != nil},
|
||||||
|
WarehouseRequestSource: pgtype.Text{String: derefString(req.WarehouseRequestSource), Valid: req.WarehouseRequestSource != nil},
|
||||||
|
WarehouseRequestSource2: pgtype.Text{String: derefString(req.WarehouseRequestSource2), Valid: req.WarehouseRequestSource2 != nil},
|
||||||
ProductName: req.ProductName,
|
ProductName: req.ProductName,
|
||||||
ProductName2: pgtype.Text{String: derefString(req.ProductName2), Valid: req.ProductName2 != nil},
|
ProductName2: pgtype.Text{String: derefString(req.ProductName2), Valid: req.ProductName2 != nil},
|
||||||
CustomerName: req.CustomerName,
|
CustomerName: req.CustomerName,
|
||||||
@@ -130,6 +142,7 @@ func (h *Handler) CreateDelivery(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.hub.Broadcast(ws.NewEvent(ws.DeliveryCreated, res))
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Delivery created", "id": res.ID.String()})
|
c.JSON(http.StatusOK, gin.H{"message": "Delivery created", "id": res.ID.String()})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +154,10 @@ func (h *Handler) UpdateDelivery(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := normalizeWarehouseRequestSources(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
@@ -165,6 +182,8 @@ func (h *Handler) UpdateDelivery(c *gin.Context) {
|
|||||||
Date: pgtype.Date{Time: t, Valid: true},
|
Date: pgtype.Date{Time: t, Valid: true},
|
||||||
PickupLocation: req.PickupLocation,
|
PickupLocation: req.PickupLocation,
|
||||||
PickupLocation2: pgtype.Text{String: derefString(req.PickupLocation2), Valid: req.PickupLocation2 != nil},
|
PickupLocation2: pgtype.Text{String: derefString(req.PickupLocation2), Valid: req.PickupLocation2 != nil},
|
||||||
|
WarehouseRequestSource: pgtype.Text{String: derefString(req.WarehouseRequestSource), Valid: req.WarehouseRequestSource != nil},
|
||||||
|
WarehouseRequestSource2: pgtype.Text{String: derefString(req.WarehouseRequestSource2), Valid: req.WarehouseRequestSource2 != nil},
|
||||||
ProductName: req.ProductName,
|
ProductName: req.ProductName,
|
||||||
ProductName2: pgtype.Text{String: derefString(req.ProductName2), Valid: req.ProductName2 != nil},
|
ProductName2: pgtype.Text{String: derefString(req.ProductName2), Valid: req.ProductName2 != nil},
|
||||||
CustomerName: req.CustomerName,
|
CustomerName: req.CustomerName,
|
||||||
@@ -184,6 +203,11 @@ func (h *Handler) UpdateDelivery(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if updated, err := h.queries.GetDeliveryByID(c.Request.Context(), pgtype.UUID{Bytes: parsedID, Valid: true}); err == nil {
|
||||||
|
h.hub.Broadcast(ws.NewEvent(ws.DeliveryUpdated, updated))
|
||||||
|
} else {
|
||||||
|
log.Printf("delivery: failed to fetch updated delivery %s for ws broadcast: %v", id, err)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Delivery updated"})
|
c.JSON(http.StatusOK, gin.H{"message": "Delivery updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +249,7 @@ func (h *Handler) UpdateDeliveryStatus(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.hub.Broadcast(ws.NewEvent(ws.DeliveryStatusChanged, ws.StatusPayload{ID: id, Status: status}))
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Delivery status updated"})
|
c.JSON(http.StatusOK, gin.H{"message": "Delivery status updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +273,7 @@ func (h *Handler) DeleteDelivery(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.hub.Broadcast(ws.NewEvent(ws.DeliveryDeleted, ws.DeletePayload{ID: id}))
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Delivery deleted"})
|
c.JSON(http.StatusOK, gin.H{"message": "Delivery deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,3 +292,19 @@ func derefString(s *string) string {
|
|||||||
}
|
}
|
||||||
return *s
|
return *s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeWarehouseRequestSources(req *DeliveryRequest) error {
|
||||||
|
if req.PickupLocation == "warehouse" && req.WarehouseRequestSource == nil {
|
||||||
|
return errors.New("warehouse_request_source is required when pickup_location is warehouse")
|
||||||
|
}
|
||||||
|
if req.PickupLocation != "warehouse" {
|
||||||
|
req.WarehouseRequestSource = nil
|
||||||
|
}
|
||||||
|
if req.PickupLocation2 != nil && *req.PickupLocation2 == "warehouse" && req.WarehouseRequestSource2 == nil {
|
||||||
|
return errors.New("warehouse_request_source_2 is required when pickup_location_2 is warehouse")
|
||||||
|
}
|
||||||
|
if req.PickupLocation2 == nil || *req.PickupLocation2 != "warehouse" {
|
||||||
|
req.WarehouseRequestSource2 = nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
74
backend/internal/ws/client.go
Normal file
74
backend/internal/ws/client.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
writeWait = 10 * time.Second
|
||||||
|
pongWait = 60 * time.Second
|
||||||
|
pingPeriod = (pongWait * 9) / 10
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
hub *Hub
|
||||||
|
conn *websocket.Conn
|
||||||
|
send chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(hub *Hub, conn *websocket.Conn) *Client {
|
||||||
|
return &Client{
|
||||||
|
hub: hub,
|
||||||
|
conn: conn,
|
||||||
|
send: make(chan []byte, 256),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadPump keeps the connection alive and handles pong frames.
|
||||||
|
// We don't expect real messages from the client (read-only WS).
|
||||||
|
func (c *Client) ReadPump() {
|
||||||
|
defer func() {
|
||||||
|
c.hub.Unregister(c)
|
||||||
|
c.conn.Close()
|
||||||
|
}()
|
||||||
|
c.conn.SetReadLimit(512)
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
|
c.conn.SetPongHandler(func(string) error {
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
for {
|
||||||
|
if _, _, err := c.conn.ReadMessage(); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePump sends messages from hub to the client and pings to keep alive.
|
||||||
|
func (c *Client) WritePump() {
|
||||||
|
ticker := time.NewTicker(pingPeriod)
|
||||||
|
defer func() {
|
||||||
|
ticker.Stop()
|
||||||
|
c.conn.Close()
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-c.send:
|
||||||
|
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
|
if !ok {
|
||||||
|
c.conn.WriteMessage(websocket.CloseMessage, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
|
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
backend/internal/ws/event.go
Normal file
40
backend/internal/ws/event.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EventType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DeliveryCreated EventType = "delivery.created"
|
||||||
|
DeliveryUpdated EventType = "delivery.updated"
|
||||||
|
DeliveryStatusChanged EventType = "delivery.status_changed"
|
||||||
|
DeliveryDeleted EventType = "delivery.deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
Type EventType `json:"type"`
|
||||||
|
Payload any `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusPayload struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeletePayload struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEvent serializes an event to JSON. Returns nil on marshal failure;
|
||||||
|
// callers should treat nil as "skip broadcast".
|
||||||
|
func NewEvent(eventType EventType, payload any) []byte {
|
||||||
|
data, err := json.Marshal(Event{Type: eventType, Payload: payload})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ws: failed to marshal event %s: %v", eventType, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
41
backend/internal/ws/handler.go
Normal file
41
backend/internal/ws/handler.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/chedius/delivery-tracker/internal/auth"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleWS returns a Gin handler that upgrades HTTP to WebSocket
|
||||||
|
// after validating the JWT token from the ?token= query param.
|
||||||
|
func HandleWS(hub *Hub, jwtSecret []byte) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := c.Query("token")
|
||||||
|
if token == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := auth.ParseToken(token, jwtSecret); err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewClient(hub, conn)
|
||||||
|
hub.Register(client)
|
||||||
|
|
||||||
|
go client.WritePump()
|
||||||
|
go client.ReadPump()
|
||||||
|
}
|
||||||
|
}
|
||||||
48
backend/internal/ws/hub.go
Normal file
48
backend/internal/ws/hub.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
type Hub struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
clients map[*Client]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHub() *Hub {
|
||||||
|
return &Hub{
|
||||||
|
clients: make(map[*Client]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Register(c *Client) {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.clients[c] = struct{}{}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Unregister(c *Client) {
|
||||||
|
h.mu.Lock()
|
||||||
|
if _, ok := h.clients[c]; ok {
|
||||||
|
delete(h.clients, c)
|
||||||
|
close(c.send)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Broadcast(msg []byte) {
|
||||||
|
if msg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
for c := range h.clients {
|
||||||
|
select {
|
||||||
|
case c.send <- msg:
|
||||||
|
default:
|
||||||
|
// Client too slow, schedule disconnect
|
||||||
|
go func(c *Client) {
|
||||||
|
h.Unregister(c)
|
||||||
|
c.conn.Close()
|
||||||
|
}(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ FROM node:20-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG GITEA_SHA
|
||||||
|
ARG COMMIT_SHA
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
RUN yarn install --frozen-lockfile
|
RUN yarn install --frozen-lockfile
|
||||||
|
|||||||
@@ -49,6 +49,20 @@ server {
|
|||||||
expires 0;
|
expires 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# WebSocket endpoint — long-lived connections need extended timeouts
|
||||||
|
location = /api/ws {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
# Proxy API requests to backend
|
# Proxy API requests to backend
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8080;
|
proxy_pass http://backend:8080;
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "delivery-tracker",
|
"name": "delivery-tracker",
|
||||||
"version": "0.0.0",
|
"version": "0.0.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "delivery-tracker",
|
"name": "delivery-tracker",
|
||||||
"version": "0.0.0",
|
"version": "0.0.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "delivery-tracker",
|
"name": "delivery-tracker",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from './components/ui/Button';
|
|||||||
import { UpdatePrompt } from './components/ui/UpdatePrompt';
|
import { UpdatePrompt } from './components/ui/UpdatePrompt';
|
||||||
import { useDeliveryStore } from './stores/deliveryStore';
|
import { useDeliveryStore } from './stores/deliveryStore';
|
||||||
import { useAuthStore } from './stores/authStore';
|
import { useAuthStore } from './stores/authStore';
|
||||||
|
import { useWebSocket } from './hooks/useWebSocket';
|
||||||
|
|
||||||
// Lazy load pages for code splitting
|
// Lazy load pages for code splitting
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
@@ -30,6 +31,8 @@ function App() {
|
|||||||
const addDelivery = useDeliveryStore(state => state.addDelivery);
|
const addDelivery = useDeliveryStore(state => state.addDelivery);
|
||||||
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
|
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
|
||||||
|
|
||||||
|
useWebSocket();
|
||||||
|
|
||||||
// Restore auth on mount
|
// Restore auth on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
restoreAuth();
|
restoreAuth();
|
||||||
|
|||||||
@@ -76,16 +76,16 @@ async function fetchApi<T>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
// Handle 401 unauthorized
|
// Handle 401 unauthorized
|
||||||
if (response.status === 401) {
|
if (response.status === 401 && endpoint !== '/api/auth/login') {
|
||||||
handleUnauthorized();
|
handleUnauthorized();
|
||||||
throw new ApiError('Unauthorized', 401);
|
throw new ApiError('Unauthorized', 401);
|
||||||
}
|
}
|
||||||
const errorData = await response.json().catch(() => null);
|
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
errorData?.error || `HTTP ${response.status}`,
|
errorData?.error || `HTTP ${response.status}`,
|
||||||
response.status,
|
response.status,
|
||||||
errorData?.details
|
errorData
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
import { backendDateToFrontend } from '../utils/date';
|
import { backendDateToFrontend } from '../utils/date';
|
||||||
import type { Delivery, PickupLocation, DeliveryStatus } from '../types';
|
import type { Delivery, DeliveryRequestSource, PickupLocation, DeliveryStatus } from '../types';
|
||||||
|
|
||||||
// Types matching backend responses
|
// Types matching backend responses
|
||||||
interface BackendDelivery {
|
export interface BackendDelivery {
|
||||||
id: string;
|
id: string;
|
||||||
date: string; // YYYY-MM-DD from pgtype.Date
|
date: string; // YYYY-MM-DD from pgtype.Date
|
||||||
pickup_location: PickupLocation;
|
pickup_location: PickupLocation;
|
||||||
pickup_location_2: PickupLocation | null;
|
pickup_location_2: PickupLocation | null;
|
||||||
|
warehouse_request_source: DeliveryRequestSource | null;
|
||||||
|
warehouse_request_source_2: DeliveryRequestSource | null;
|
||||||
product_name: string;
|
product_name: string;
|
||||||
product_name_2: string | null;
|
product_name_2: string | null;
|
||||||
customer_name: string;
|
customer_name: string;
|
||||||
@@ -55,12 +57,14 @@ interface UpdateDeliveryResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map backend delivery to frontend delivery
|
// Map backend delivery to frontend delivery
|
||||||
function mapBackendToFrontend(backend: BackendDelivery): Delivery {
|
export function mapBackendToFrontend(backend: BackendDelivery): Delivery {
|
||||||
return {
|
return {
|
||||||
id: backend.id,
|
id: backend.id,
|
||||||
date: backendDateToFrontend(backend.date),
|
date: backendDateToFrontend(backend.date),
|
||||||
pickupLocation: backend.pickup_location,
|
pickupLocation: backend.pickup_location,
|
||||||
pickupLocation2: backend.pickup_location_2 || undefined,
|
pickupLocation2: backend.pickup_location_2 || undefined,
|
||||||
|
warehouseRequestSource: backend.warehouse_request_source || undefined,
|
||||||
|
warehouseRequestSource2: backend.warehouse_request_source_2 || undefined,
|
||||||
productName: backend.product_name,
|
productName: backend.product_name,
|
||||||
productName2: backend.product_name_2 || undefined,
|
productName2: backend.product_name_2 || undefined,
|
||||||
customerName: backend.customer_name,
|
customerName: backend.customer_name,
|
||||||
@@ -115,6 +119,8 @@ export const deliveriesApi = {
|
|||||||
date: data.date,
|
date: data.date,
|
||||||
pickup_location: data.pickupLocation,
|
pickup_location: data.pickupLocation,
|
||||||
pickup_location_2: data.pickupLocation2 || null,
|
pickup_location_2: data.pickupLocation2 || null,
|
||||||
|
warehouse_request_source: data.pickupLocation === 'warehouse' ? data.warehouseRequestSource || null : null,
|
||||||
|
warehouse_request_source_2: data.pickupLocation2 === 'warehouse' ? data.warehouseRequestSource2 || null : null,
|
||||||
product_name: data.productName,
|
product_name: data.productName,
|
||||||
product_name_2: data.productName2 || null,
|
product_name_2: data.productName2 || null,
|
||||||
customer_name: data.customerName,
|
customer_name: data.customerName,
|
||||||
@@ -143,6 +149,8 @@ export const deliveriesApi = {
|
|||||||
date: data.date,
|
date: data.date,
|
||||||
pickup_location: data.pickupLocation,
|
pickup_location: data.pickupLocation,
|
||||||
pickup_location_2: data.pickupLocation2 || null,
|
pickup_location_2: data.pickupLocation2 || null,
|
||||||
|
warehouse_request_source: data.pickupLocation === 'warehouse' ? data.warehouseRequestSource || null : null,
|
||||||
|
warehouse_request_source_2: data.pickupLocation2 === 'warehouse' ? data.warehouseRequestSource2 || null : null,
|
||||||
product_name: data.productName,
|
product_name: data.productName,
|
||||||
product_name_2: data.productName2 || null,
|
product_name_2: data.productName2 || null,
|
||||||
customer_name: data.customerName,
|
customer_name: data.customerName,
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { MapPin, Phone, Store, Calendar, MessageSquare, CheckCircle2, Circle, CheckSquare, User, Wrench } from 'lucide-react';
|
import { MapPin, Phone, Store, Calendar, MessageSquare, CheckCircle2, Circle, CheckSquare, User, Wrench, CalendarPlus } from 'lucide-react';
|
||||||
import type { Delivery } from '../../types';
|
import type { Delivery } from '../../types';
|
||||||
import { pickupLocationLabels } from '../../types';
|
import { formatPickupLocation } from '../../types';
|
||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
import { Card } from '../ui/Card';
|
import { Card } from '../ui/Card';
|
||||||
|
|
||||||
const CITY = 'kokshetau';
|
const CITY = 'kokshetau';
|
||||||
|
const createdAtFormatter = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatCreatedAt = (timestamp: number) => createdAtFormatter.format(new Date(timestamp));
|
||||||
|
|
||||||
interface DeliveryCardProps {
|
interface DeliveryCardProps {
|
||||||
delivery: Delivery;
|
delivery: Delivery;
|
||||||
@@ -26,13 +33,17 @@ export const DeliveryCard = memo(({ delivery, onStatusChange, onEdit, onDelete }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="relative">
|
<Card className="relative">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={delivery.status}
|
status={delivery.status}
|
||||||
onClick={() => onStatusChange(delivery.id)}
|
onClick={() => onStatusChange(delivery.id)}
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-[#75777d]">
|
||||||
|
<CalendarPlus size={14} className="shrink-0" />
|
||||||
|
<span className="truncate">Создано {formatCreatedAt(delivery.createdAt)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
@@ -63,13 +74,13 @@ export const DeliveryCard = memo(({ delivery, onStatusChange, onEdit, onDelete }
|
|||||||
<Store size={16} className="text-[#75777d] mt-0.5 shrink-0" />
|
<Store size={16} className="text-[#75777d] mt-0.5 shrink-0" />
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
<div className="flex items-baseline gap-2 flex-wrap">
|
<div className="flex items-baseline gap-2 flex-wrap">
|
||||||
<span className="text-[#1b1b1d] font-medium">{pickupLocationLabels[delivery.pickupLocation]}</span>
|
<span className="text-[#1b1b1d] font-medium">{formatPickupLocation(delivery.pickupLocation, delivery.warehouseRequestSource)}</span>
|
||||||
<span className="text-[#75777d]">—</span>
|
<span className="text-[#75777d]">—</span>
|
||||||
<span className="text-[#1b1b1d]">{delivery.productName}</span>
|
<span className="text-[#1b1b1d]">{delivery.productName}</span>
|
||||||
</div>
|
</div>
|
||||||
{delivery.pickupLocation2 && (
|
{delivery.pickupLocation2 && (
|
||||||
<div className="flex items-baseline gap-2 flex-wrap">
|
<div className="flex items-baseline gap-2 flex-wrap">
|
||||||
<span className="text-[#1b1b1d] font-medium">{pickupLocationLabels[delivery.pickupLocation2]}</span>
|
<span className="text-[#1b1b1d] font-medium">{formatPickupLocation(delivery.pickupLocation2, delivery.warehouseRequestSource2)}</span>
|
||||||
<span className="text-[#75777d]">—</span>
|
<span className="text-[#75777d]">—</span>
|
||||||
<span className="text-[#1b1b1d]">{delivery.productName2 || '—'}</span>
|
<span className="text-[#1b1b1d]">{delivery.productName2 || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Button, Input, Select, Modal } from '../ui';
|
import { Button, Input, Select, Modal } from '../ui';
|
||||||
import { pickupOptions } from '../../constants/pickup';
|
import { deliveryRequestSourceOptions, pickupOptions } from '../../constants/pickup';
|
||||||
import { formatDateForInput, parseDateFromInput, getTodayFrontend } from '../../utils/date';
|
import { formatDateForInput, parseDateFromInput, getTodayFrontend } from '../../utils/date';
|
||||||
import type { Delivery, PickupLocation, DeliveryStatus } from '../../types';
|
import type { Delivery, DeliveryRequestSource, PickupLocation, DeliveryStatus } from '../../types';
|
||||||
|
|
||||||
interface DeliveryFormProps {
|
interface DeliveryFormProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -18,6 +18,10 @@ const PHONE_REGEX = /^\+7\s?\(?\d{3}\)?\s?\d{3}[\s-]?\d{2}[\s-]?\d{2}$/;
|
|||||||
|
|
||||||
// City is not shown in UI but is included in the saved address (used for 2GIS search).
|
// City is not shown in UI but is included in the saved address (used for 2GIS search).
|
||||||
const CITY_LABEL = 'Кокшетау';
|
const CITY_LABEL = 'Кокшетау';
|
||||||
|
const requestSourceOptions = [
|
||||||
|
{ value: '', label: 'Выберите источник заявки' },
|
||||||
|
...deliveryRequestSourceOptions,
|
||||||
|
];
|
||||||
|
|
||||||
const buildAddressString = (
|
const buildAddressString = (
|
||||||
street: string,
|
street: string,
|
||||||
@@ -38,6 +42,8 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
|||||||
date: defaultDate || getTodayFrontend(),
|
date: defaultDate || getTodayFrontend(),
|
||||||
pickupLocation: 'warehouse' as PickupLocation,
|
pickupLocation: 'warehouse' as PickupLocation,
|
||||||
pickupLocation2: null as PickupLocation | null,
|
pickupLocation2: null as PickupLocation | null,
|
||||||
|
warehouseRequestSource: null as DeliveryRequestSource | null,
|
||||||
|
warehouseRequestSource2: null as DeliveryRequestSource | null,
|
||||||
productName: '',
|
productName: '',
|
||||||
productName2: '',
|
productName2: '',
|
||||||
customerName: '',
|
customerName: '',
|
||||||
@@ -62,6 +68,8 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
|||||||
date: initialData.date,
|
date: initialData.date,
|
||||||
pickupLocation: initialData.pickupLocation,
|
pickupLocation: initialData.pickupLocation,
|
||||||
pickupLocation2: initialData.pickupLocation2 || null,
|
pickupLocation2: initialData.pickupLocation2 || null,
|
||||||
|
warehouseRequestSource: initialData.warehouseRequestSource || null,
|
||||||
|
warehouseRequestSource2: initialData.warehouseRequestSource2 || null,
|
||||||
productName: initialData.productName,
|
productName: initialData.productName,
|
||||||
productName2: initialData.productName2 || '',
|
productName2: initialData.productName2 || '',
|
||||||
customerName: initialData.customerName,
|
customerName: initialData.customerName,
|
||||||
@@ -91,7 +99,25 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
|||||||
|
|
||||||
const isPhoneValid = !formData.phone || validatePhone(formData.phone);
|
const isPhoneValid = !formData.phone || validatePhone(formData.phone);
|
||||||
const isAdditionalPhoneValid = !formData.additionalPhone || validatePhone(formData.additionalPhone);
|
const isAdditionalPhoneValid = !formData.additionalPhone || validatePhone(formData.additionalPhone);
|
||||||
const isFormValid = formData.productName && formData.phone && isPhoneValid && formData.customerName && formData.street && formData.house;
|
const isWarehouseRequestSourceValid = formData.pickupLocation !== 'warehouse' || !!formData.warehouseRequestSource;
|
||||||
|
const isWarehouseRequestSource2Valid = !showSecondPickup || formData.pickupLocation2 !== 'warehouse' || !!formData.warehouseRequestSource2;
|
||||||
|
const isFormValid = formData.productName && formData.phone && isPhoneValid && formData.customerName && formData.street && formData.house && isWarehouseRequestSourceValid && isWarehouseRequestSource2Valid;
|
||||||
|
|
||||||
|
const handlePickupLocationChange = (pickupLocation: PickupLocation) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
pickupLocation,
|
||||||
|
warehouseRequestSource: pickupLocation === 'warehouse' ? formData.warehouseRequestSource : null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePickupLocation2Change = (pickupLocation2: PickupLocation) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
pickupLocation2,
|
||||||
|
warehouseRequestSource2: pickupLocation2 === 'warehouse' ? formData.warehouseRequestSource2 : null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -107,6 +133,8 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
|||||||
date: defaultDate || getTodayFrontend(),
|
date: defaultDate || getTodayFrontend(),
|
||||||
pickupLocation: 'warehouse',
|
pickupLocation: 'warehouse',
|
||||||
pickupLocation2: null,
|
pickupLocation2: null,
|
||||||
|
warehouseRequestSource: null,
|
||||||
|
warehouseRequestSource2: null,
|
||||||
productName: '',
|
productName: '',
|
||||||
productName2: '',
|
productName2: '',
|
||||||
customerName: '',
|
customerName: '',
|
||||||
@@ -165,10 +193,21 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
|||||||
<Select
|
<Select
|
||||||
label="Место загрузки"
|
label="Место загрузки"
|
||||||
value={formData.pickupLocation}
|
value={formData.pickupLocation}
|
||||||
onChange={(e) => setFormData({ ...formData, pickupLocation: e.target.value as PickupLocation })}
|
onChange={(e) => handlePickupLocationChange(e.target.value as PickupLocation)}
|
||||||
options={pickupOptions}
|
options={pickupOptions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{formData.pickupLocation === 'warehouse' && (
|
||||||
|
<Select
|
||||||
|
label="От кого заявка *"
|
||||||
|
value={formData.warehouseRequestSource || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, warehouseRequestSource: e.target.value ? e.target.value as DeliveryRequestSource : null })}
|
||||||
|
options={requestSourceOptions}
|
||||||
|
required
|
||||||
|
error={!isWarehouseRequestSourceValid ? 'Выберите, от кого исходит заявка' : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Название товара"
|
label="Название товара"
|
||||||
value={formData.productName}
|
value={formData.productName}
|
||||||
@@ -287,10 +326,14 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
|||||||
id="hasSecondPickup"
|
id="hasSecondPickup"
|
||||||
checked={showSecondPickup}
|
checked={showSecondPickup}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setShowSecondPickup(e.target.checked);
|
const checked = e.target.checked;
|
||||||
if (!e.target.checked) {
|
setShowSecondPickup(checked);
|
||||||
setFormData({ ...formData, pickupLocation2: null, productName2: '' });
|
setFormData({
|
||||||
}
|
...formData,
|
||||||
|
pickupLocation2: checked ? formData.pickupLocation2 || 'warehouse' : null,
|
||||||
|
warehouseRequestSource2: checked ? formData.warehouseRequestSource2 : null,
|
||||||
|
productName2: checked ? formData.productName2 : '',
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
className="w-4 h-4 text-[#1B263B] border-[#c5c6cd] rounded focus:ring-[#1B263B]"
|
className="w-4 h-4 text-[#1B263B] border-[#c5c6cd] rounded focus:ring-[#1B263B]"
|
||||||
/>
|
/>
|
||||||
@@ -305,9 +348,19 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
|||||||
<Select
|
<Select
|
||||||
label="Место загрузки 2"
|
label="Место загрузки 2"
|
||||||
value={formData.pickupLocation2 || ''}
|
value={formData.pickupLocation2 || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, pickupLocation2: e.target.value as PickupLocation })}
|
onChange={(e) => handlePickupLocation2Change(e.target.value as PickupLocation)}
|
||||||
options={pickupOptions}
|
options={pickupOptions}
|
||||||
/>
|
/>
|
||||||
|
{formData.pickupLocation2 === 'warehouse' && (
|
||||||
|
<Select
|
||||||
|
label="От кого заявка 2 *"
|
||||||
|
value={formData.warehouseRequestSource2 || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, warehouseRequestSource2: e.target.value ? e.target.value as DeliveryRequestSource : null })}
|
||||||
|
options={requestSourceOptions}
|
||||||
|
required
|
||||||
|
error={!isWarehouseRequestSource2Valid ? 'Выберите, от кого исходит заявка' : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
label="Название товара 2"
|
label="Название товара 2"
|
||||||
value={formData.productName2}
|
value={formData.productName2}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { MapPin, Phone } from 'lucide-react';
|
import { MapPin, Phone } from 'lucide-react';
|
||||||
import type { Delivery } from '../../types';
|
import type { Delivery } from '../../types';
|
||||||
import { pickupLocationLabels } from '../../types';
|
import { formatPickupLocation } from '../../types';
|
||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
|
|
||||||
const CITY = 'kokshetau';
|
const CITY = 'kokshetau';
|
||||||
@@ -37,8 +37,8 @@ export const DeliveryRow = memo(({ delivery, onStatusChange, onEdit, onDelete }:
|
|||||||
<td className="px-4 py-3 text-sm text-[#1b1b1d]">{delivery.date}</td>
|
<td className="px-4 py-3 text-sm text-[#1b1b1d]">{delivery.date}</td>
|
||||||
<td className="px-4 py-3 text-sm text-[#1b1b1d]">
|
<td className="px-4 py-3 text-sm text-[#1b1b1d]">
|
||||||
{delivery.pickupLocation2
|
{delivery.pickupLocation2
|
||||||
? `${pickupLocationLabels[delivery.pickupLocation]} + ${pickupLocationLabels[delivery.pickupLocation2]}`
|
? `${formatPickupLocation(delivery.pickupLocation, delivery.warehouseRequestSource)} + ${formatPickupLocation(delivery.pickupLocation2, delivery.warehouseRequestSource2)}`
|
||||||
: pickupLocationLabels[delivery.pickupLocation]}
|
: formatPickupLocation(delivery.pickupLocation, delivery.warehouseRequestSource)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-[#1b1b1d]">
|
<td className="px-4 py-3 text-sm text-[#1b1b1d]">
|
||||||
{delivery.productName}
|
{delivery.productName}
|
||||||
|
|||||||
@@ -1,25 +1,34 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import { useRegisterSW } from 'virtual:pwa-register/react';
|
import { useRegisterSW } from 'virtual:pwa-register/react';
|
||||||
import { RefreshCw } from 'lucide-react';
|
|
||||||
import { Button } from './Button';
|
|
||||||
|
|
||||||
// Check for SW updates every hour and on tab focus/visibility change
|
// Check for SW updates every hour and on tab focus/visibility change
|
||||||
const UPDATE_INTERVAL_MS = 60 * 60 * 1000;
|
const UPDATE_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const buildLabel = `${__APP_VERSION__}-${__APP_COMMIT__}`;
|
||||||
|
|
||||||
export function UpdatePrompt() {
|
export function UpdatePrompt() {
|
||||||
const {
|
const {
|
||||||
needRefresh: [needRefresh],
|
needRefresh: [needRefresh],
|
||||||
updateServiceWorker,
|
updateServiceWorker,
|
||||||
} = useRegisterSW({
|
} = useRegisterSW({
|
||||||
onRegisteredSW(_swUrl, registration) {
|
onRegisteredSW(_swUrl, registration) {
|
||||||
|
console.info('[App] version', {
|
||||||
|
version: __APP_VERSION__,
|
||||||
|
commit: __APP_COMMIT__,
|
||||||
|
builtAt: __APP_BUILT_AT__,
|
||||||
|
serviceWorker: registration ? 'registered' : 'unavailable',
|
||||||
|
});
|
||||||
|
|
||||||
if (!registration) return;
|
if (!registration) return;
|
||||||
|
|
||||||
const checkForUpdate = async () => {
|
const checkForUpdate = async () => {
|
||||||
if (registration.installing || !navigator) return;
|
if (registration.installing || !navigator) return;
|
||||||
if ('connection' in navigator && !navigator.onLine) return;
|
if ('connection' in navigator && !navigator.onLine) return;
|
||||||
try {
|
try {
|
||||||
|
console.debug('[PWA] checking for update', { version: buildLabel });
|
||||||
await registration.update();
|
await registration.update();
|
||||||
} catch {
|
} catch {
|
||||||
// network error — ignore, will retry
|
console.debug('[PWA] update check failed, will retry later', { version: buildLabel });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,23 +42,14 @@ export function UpdatePrompt() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!needRefresh) return null;
|
useEffect(() => {
|
||||||
|
if (needRefresh) {
|
||||||
|
console.info('[PWA] new version detected, applying automatically', {
|
||||||
|
currentVersion: buildLabel,
|
||||||
|
});
|
||||||
|
updateServiceWorker(true);
|
||||||
|
}
|
||||||
|
}, [needRefresh, updateServiceWorker]);
|
||||||
|
|
||||||
return (
|
return null;
|
||||||
<div className="fixed top-0 inset-x-0 z-[60] bg-[#1B263B] text-white shadow-lg">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 py-2 flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<RefreshCw size={16} className="shrink-0" />
|
|
||||||
<span>Доступна новая версия приложения</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => updateServiceWorker(true)}
|
|
||||||
className="bg-white text-[#1B263B] hover:bg-white/90"
|
|
||||||
>
|
|
||||||
Обновить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { PickupLocation } from '../types';
|
import type { DeliveryRequestSource, PickupLocation } from '../types';
|
||||||
import { pickupLocationLabels } from '../types';
|
import { deliveryRequestSourceLabels, pickupLocationLabels } from '../types';
|
||||||
|
|
||||||
export const pickupOptions: { value: PickupLocation; label: string }[] = [
|
export const pickupOptions: { value: PickupLocation; label: string }[] = [
|
||||||
{ value: 'warehouse', label: pickupLocationLabels.warehouse },
|
{ value: 'warehouse', label: pickupLocationLabels.warehouse },
|
||||||
@@ -12,3 +12,9 @@ export const pickupFilterOptions: { value: PickupLocation | 'all'; label: string
|
|||||||
{ value: 'all', label: 'Все места загрузки' },
|
{ value: 'all', label: 'Все места загрузки' },
|
||||||
...pickupOptions,
|
...pickupOptions,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const deliveryRequestSourceOptions: { value: DeliveryRequestSource; label: string }[] = [
|
||||||
|
{ value: 'symbat', label: deliveryRequestSourceLabels.symbat },
|
||||||
|
{ value: 'nursaya', label: deliveryRequestSourceLabels.nursaya },
|
||||||
|
{ value: 'galaktika', label: deliveryRequestSourceLabels.galaktika },
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,83 +1,133 @@
|
|||||||
import { useEffect, useCallback } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { useDeliveryStore } from '../stores/deliveryStore';
|
import { useDeliveryStore } from '../stores/deliveryStore';
|
||||||
import type { Delivery } from '../types';
|
import { mapBackendToFrontend } from '../api/deliveries';
|
||||||
|
import type { BackendDelivery } from '../api/deliveries';
|
||||||
|
|
||||||
type WebSocketEvent =
|
type WsEventType =
|
||||||
| { type: 'delivery.created'; payload: Delivery }
|
| 'delivery.created'
|
||||||
| { type: 'delivery.updated'; payload: Delivery }
|
| 'delivery.updated'
|
||||||
| { type: 'delivery.deleted'; payload: { id: string } };
|
| 'delivery.status_changed'
|
||||||
|
| 'delivery.deleted';
|
||||||
|
|
||||||
type EventHandler = (event: WebSocketEvent) => void;
|
interface WsEvent {
|
||||||
|
type: WsEventType;
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
class MockWebSocket {
|
const MAX_RECONNECT_DELAY = 30_000;
|
||||||
private handlers: EventHandler[] = [];
|
const INITIAL_RECONNECT_DELAY = 1_000;
|
||||||
private isConnected = false;
|
const COUNTS_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
connect() {
|
function getWsUrl(token: string): string {
|
||||||
this.isConnected = true;
|
const apiBase = import.meta.env.VITE_API_URL || '';
|
||||||
console.log('WebSocket connected (mock)');
|
let wsBase: string;
|
||||||
|
|
||||||
|
if (apiBase) {
|
||||||
|
wsBase = apiBase.replace(/^http/, 'ws');
|
||||||
|
} else {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
wsBase = `${protocol}//${window.location.host}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
return `${wsBase}/api/ws?token=${encodeURIComponent(token)}`;
|
||||||
this.isConnected = false;
|
}
|
||||||
console.log('WebSocket disconnected (mock)');
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(handler: EventHandler) {
|
// Debounced counts refresh — multiple WS events in quick succession
|
||||||
this.handlers.push(handler);
|
// trigger only a single API call.
|
||||||
return () => {
|
let countsTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
this.handlers = this.handlers.filter((h) => h !== handler);
|
function refreshCountsDebounced() {
|
||||||
};
|
if (countsTimer) clearTimeout(countsTimer);
|
||||||
}
|
countsTimer = setTimeout(() => {
|
||||||
|
countsTimer = null;
|
||||||
|
useDeliveryStore.getState().fetchDeliveryCounts();
|
||||||
|
}, COUNTS_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
emit(event: WebSocketEvent) {
|
function handleEvent(event: WsEvent) {
|
||||||
if (!this.isConnected) return;
|
const store = useDeliveryStore.getState();
|
||||||
this.handlers.forEach((handler) => handler(event));
|
|
||||||
}
|
|
||||||
|
|
||||||
simulateIncomingEvent(event: WebSocketEvent) {
|
switch (event.type) {
|
||||||
this.emit(event);
|
case 'delivery.created': {
|
||||||
|
const delivery = mapBackendToFrontend(event.payload as BackendDelivery);
|
||||||
|
store.handleWsDeliveryCreated(delivery);
|
||||||
|
refreshCountsDebounced();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delivery.updated': {
|
||||||
|
const delivery = mapBackendToFrontend(event.payload as BackendDelivery);
|
||||||
|
store.handleWsDeliveryUpdated(delivery);
|
||||||
|
refreshCountsDebounced();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delivery.status_changed': {
|
||||||
|
const { id, status } = event.payload as { id: string; status: string };
|
||||||
|
store.handleWsStatusChanged(id, status);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delivery.deleted': {
|
||||||
|
const { id } = event.payload as { id: string };
|
||||||
|
store.handleWsDeliveryDeleted(id);
|
||||||
|
refreshCountsDebounced();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockWebSocket = new MockWebSocket();
|
export function useWebSocket() {
|
||||||
|
const token = useAuthStore(state => state.token);
|
||||||
export const useWebSocket = () => {
|
|
||||||
const { addDelivery, updateDelivery, deleteDelivery } = useDeliveryStore();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mockWebSocket.connect();
|
if (!token) return;
|
||||||
|
|
||||||
const unsubscribe = mockWebSocket.subscribe((event) => {
|
let cancelled = false;
|
||||||
switch (event.type) {
|
let ws: WebSocket | null = null;
|
||||||
case 'delivery.created':
|
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
addDelivery(event.payload);
|
let reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||||
break;
|
|
||||||
case 'delivery.updated':
|
function connect() {
|
||||||
updateDelivery(event.payload.id, event.payload);
|
if (cancelled) return;
|
||||||
break;
|
|
||||||
case 'delivery.deleted':
|
const socket = new WebSocket(getWsUrl(token!));
|
||||||
deleteDelivery(event.payload.id);
|
ws = socket;
|
||||||
break;
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const event: WsEvent = JSON.parse(e.data);
|
||||||
|
handleEvent(event);
|
||||||
|
} catch {
|
||||||
|
// ignore malformed messages
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
// Ignore close events from sockets we no longer track —
|
||||||
|
// protects against stale callbacks after token change.
|
||||||
|
if (cancelled || ws !== socket) return;
|
||||||
|
ws = null;
|
||||||
|
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||||
|
connect();
|
||||||
|
}, reconnectDelay);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
cancelled = true;
|
||||||
mockWebSocket.disconnect();
|
clearTimeout(reconnectTimer);
|
||||||
|
ws?.close();
|
||||||
|
ws = null;
|
||||||
};
|
};
|
||||||
}, [addDelivery, updateDelivery, deleteDelivery]);
|
}, [token]);
|
||||||
|
}
|
||||||
const sendEvent = useCallback((event: WebSocketEvent) => {
|
|
||||||
mockWebSocket.emit(event);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { sendEvent, isConnected: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const simulateIncomingDelivery = (delivery: Delivery) => {
|
|
||||||
mockWebSocket.simulateIncomingEvent({
|
|
||||||
type: 'delivery.created',
|
|
||||||
payload: delivery,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { format, startOfMonth, endOfMonth, eachDayOfInterval, isToday, getDay }
|
|||||||
import { ru } from 'date-fns/locale';
|
import { ru } from 'date-fns/locale';
|
||||||
import { useDeliveryStore } from '../stores/deliveryStore';
|
import { useDeliveryStore } from '../stores/deliveryStore';
|
||||||
import type { Delivery } from '../types';
|
import type { Delivery } from '../types';
|
||||||
import { pickupLocationLabels } from '../types';
|
import { formatPickupLocation } from '../types';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { Card } from '../components/ui/Card';
|
import { Card } from '../components/ui/Card';
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
|||||||
</tr>
|
</tr>
|
||||||
${dayDeliveries.map((d: Delivery) => `
|
${dayDeliveries.map((d: Delivery) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${d.pickupLocation2 ? pickupLocationLabels[d.pickupLocation] + ' + ' + pickupLocationLabels[d.pickupLocation2] : pickupLocationLabels[d.pickupLocation]}</td>
|
<td>${d.pickupLocation2 ? formatPickupLocation(d.pickupLocation, d.warehouseRequestSource) + ' + ' + formatPickupLocation(d.pickupLocation2, d.warehouseRequestSource2) : formatPickupLocation(d.pickupLocation, d.warehouseRequestSource)}</td>
|
||||||
<td>${d.productName}${d.productName2 ? '<br><small>+ ' + d.productName2 + '</small>' : ''}</td>
|
<td>${d.productName}${d.productName2 ? '<br><small>+ ' + d.productName2 + '</small>' : ''}</td>
|
||||||
<td>${d.customerName}</td>
|
<td>${d.customerName}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
|
import { ApiError } from '../api/client';
|
||||||
import { useToastStore } from './toastStore';
|
import { useToastStore } from './toastStore';
|
||||||
import type { User, LoginRequest } from '../types';
|
import type { User, LoginRequest } from '../types';
|
||||||
|
|
||||||
@@ -16,6 +17,28 @@ interface AuthState {
|
|||||||
|
|
||||||
const TOKEN_KEY = 'auth_token';
|
const TOKEN_KEY = 'auth_token';
|
||||||
const USER_KEY = 'auth_user';
|
const USER_KEY = 'auth_user';
|
||||||
|
const LOCKED_ACCOUNT_CODE = 'account_temporarily_locked';
|
||||||
|
|
||||||
|
function getLoginErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
const details = error.details as { code?: string; locked_until?: string } | null;
|
||||||
|
|
||||||
|
if (error.status === 429 && details?.code === LOCKED_ACCOUNT_CODE) {
|
||||||
|
if (details.locked_until) {
|
||||||
|
const lockedUntil = new Date(details.locked_until);
|
||||||
|
const secondsLeft = Math.ceil((lockedUntil.getTime() - Date.now()) / 1000);
|
||||||
|
if (Number.isFinite(secondsLeft) && secondsLeft > 0) {
|
||||||
|
const minutesLeft = Math.ceil(secondsLeft / 60);
|
||||||
|
return `Слишком много неверных попыток. Попробуйте через ${minutesLeft} мин.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Слишком много неверных попыток. Попробуйте через 5 минут';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return error instanceof Error ? error.message : 'Ошибка входа';
|
||||||
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
token: null,
|
token: null,
|
||||||
@@ -45,8 +68,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
useToastStore.getState().addToast('Вход выполнен успешно', 'success');
|
useToastStore.getState().addToast('Вход выполнен успешно', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
const message = error instanceof Error ? error.message : 'Ошибка входа';
|
useToastStore.getState().addToast(getLoginErrorMessage(error), 'error');
|
||||||
useToastStore.getState().addToast(message, 'error');
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface DeliveryState {
|
|||||||
// Data
|
// Data
|
||||||
deliveries: Delivery[];
|
deliveries: Delivery[];
|
||||||
deliveryCounts: Record<string, number>;
|
deliveryCounts: Record<string, number>;
|
||||||
|
currentDate: string | null;
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -24,19 +25,26 @@ interface DeliveryState {
|
|||||||
getDeliveriesByDateRange: (startDate: string, endDate: string) => Delivery[];
|
getDeliveriesByDateRange: (startDate: string, endDate: string) => Delivery[];
|
||||||
getDeliveryCountsByDate: () => Record<string, number>;
|
getDeliveryCountsByDate: () => Record<string, number>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
|
|
||||||
|
// WebSocket event handlers
|
||||||
|
handleWsDeliveryCreated: (delivery: Delivery) => void;
|
||||||
|
handleWsDeliveryUpdated: (delivery: Delivery) => void;
|
||||||
|
handleWsStatusChanged: (id: string, status: string) => void;
|
||||||
|
handleWsDeliveryDeleted: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
||||||
// Initial state
|
// Initial state
|
||||||
deliveries: [],
|
deliveries: [],
|
||||||
deliveryCounts: {},
|
deliveryCounts: {},
|
||||||
|
currentDate: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isLoadingCounts: false,
|
isLoadingCounts: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
// Fetch deliveries for a specific date
|
// Fetch deliveries for a specific date
|
||||||
fetchDeliveriesByDate: async (date: string) => {
|
fetchDeliveriesByDate: async (date: string) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null, currentDate: date });
|
||||||
try {
|
try {
|
||||||
const deliveries = await deliveriesApi.getByDate(date);
|
const deliveries = await deliveriesApi.getByDate(date);
|
||||||
set({ deliveries, isLoading: false });
|
set({ deliveries, isLoading: false });
|
||||||
@@ -174,4 +182,42 @@ export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
// WebSocket event handlers (update local state without refetching)
|
||||||
|
handleWsDeliveryCreated: (delivery: Delivery) => {
|
||||||
|
const { currentDate, deliveries } = get();
|
||||||
|
if (currentDate && delivery.date === currentDate) {
|
||||||
|
if (!deliveries.some(d => d.id === delivery.id)) {
|
||||||
|
set({ deliveries: [...deliveries, delivery] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleWsDeliveryUpdated: (delivery: Delivery) => {
|
||||||
|
const { currentDate, deliveries } = get();
|
||||||
|
const exists = deliveries.some(d => d.id === delivery.id);
|
||||||
|
if (exists) {
|
||||||
|
if (delivery.date === currentDate) {
|
||||||
|
set({ deliveries: deliveries.map(d => d.id === delivery.id ? delivery : d) });
|
||||||
|
} else {
|
||||||
|
set({ deliveries: deliveries.filter(d => d.id !== delivery.id) });
|
||||||
|
}
|
||||||
|
} else if (currentDate && delivery.date === currentDate) {
|
||||||
|
set({ deliveries: [...deliveries, delivery] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleWsStatusChanged: (id: string, status: string) => {
|
||||||
|
set(state => ({
|
||||||
|
deliveries: state.deliveries.map(d =>
|
||||||
|
d.id === id ? { ...d, status: status as DeliveryStatus, updatedAt: Date.now() } : d
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
handleWsDeliveryDeleted: (id: string) => {
|
||||||
|
set(state => ({
|
||||||
|
deliveries: state.deliveries.filter(d => d.id !== id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type PickupLocation = 'warehouse' | 'symbat' | 'nursaya' | 'galaktika';
|
export type PickupLocation = 'warehouse' | 'symbat' | 'nursaya' | 'galaktika';
|
||||||
|
export type DeliveryRequestSource = 'symbat' | 'nursaya' | 'galaktika';
|
||||||
export type DeliveryStatus = 'new' | 'delivered';
|
export type DeliveryStatus = 'new' | 'delivered';
|
||||||
|
|
||||||
export interface Delivery {
|
export interface Delivery {
|
||||||
@@ -6,6 +7,8 @@ export interface Delivery {
|
|||||||
date: string; // DD-MM-YYYY
|
date: string; // DD-MM-YYYY
|
||||||
pickupLocation: PickupLocation;
|
pickupLocation: PickupLocation;
|
||||||
pickupLocation2?: PickupLocation | null;
|
pickupLocation2?: PickupLocation | null;
|
||||||
|
warehouseRequestSource?: DeliveryRequestSource | null;
|
||||||
|
warehouseRequestSource2?: DeliveryRequestSource | null;
|
||||||
productName: string;
|
productName: string;
|
||||||
productName2?: string | null;
|
productName2?: string | null;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
@@ -32,6 +35,21 @@ export const pickupLocationLabels: Record<PickupLocation, string> = {
|
|||||||
galaktika: 'Галактика',
|
galaktika: 'Галактика',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const deliveryRequestSourceLabels: Record<DeliveryRequestSource, string> = {
|
||||||
|
symbat: 'Сымбат',
|
||||||
|
nursaya: 'Нурсая',
|
||||||
|
galaktika: 'Галактика',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatPickupLocation = (
|
||||||
|
pickupLocation: PickupLocation,
|
||||||
|
warehouseRequestSource?: DeliveryRequestSource | null,
|
||||||
|
): string => {
|
||||||
|
if (pickupLocation !== 'warehouse' || !warehouseRequestSource) {
|
||||||
|
return pickupLocationLabels[pickupLocation];
|
||||||
|
}
|
||||||
|
return `${pickupLocationLabels[pickupLocation]} · от ${deliveryRequestSourceLabels[warehouseRequestSource]}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const statusLabels: Record<DeliveryStatus, string> = {
|
export const statusLabels: Record<DeliveryStatus, string> = {
|
||||||
new: 'Новое',
|
new: 'Новое',
|
||||||
|
|||||||
5
frontend/src/vite-env.d.ts
vendored
Normal file
5
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
declare const __APP_COMMIT__: string;
|
||||||
|
declare const __APP_BUILT_AT__: string;
|
||||||
@@ -3,8 +3,22 @@ import react from '@vitejs/plugin-react'
|
|||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
|
const appVersion = process.env.npm_package_version ?? '0.0.0'
|
||||||
|
const appCommit = (
|
||||||
|
process.env.GITEA_SHA ??
|
||||||
|
process.env.GITHUB_SHA ??
|
||||||
|
process.env.COMMIT_SHA ??
|
||||||
|
'dev'
|
||||||
|
).slice(0, 12)
|
||||||
|
const appBuiltAt = new Date().toISOString()
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(appVersion),
|
||||||
|
__APP_COMMIT__: JSON.stringify(appCommit),
|
||||||
|
__APP_BUILT_AT__: JSON.stringify(appBuiltAt),
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
@@ -32,6 +46,7 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user