Compare commits

..

11 Commits

Author SHA1 Message Date
Egor Pozharov
d1efebbb34 add WebSocket support for real-time delivery updates with JWT authentication and automatic reconnection
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-05-21 15:52:05 +06:00
Egor Pozharov
c87aea47ce align fix
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-05-12 15:18:14 +06:00
Egor Pozharov
e6f1d6198f align fix 2026-05-12 15:17:23 +06:00
Egor Pozharov
dc85b62bdb add creation date display with CalendarPlus icon to DeliveryCard header
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-05-12 15:08:51 +06:00
Egor Pozharov
48a32d50fd add commit SHA build argument to frontend Docker builds in pre-push hook and Makefile, and introduce version bumping targets for release workflow
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-05-05 14:40:27 +06:00
Egor Pozharov
c39bde0b10 add warehouse_request_source and warehouse_request_source_2 fields to deliveries table with validation and normalization logic
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-05-04 17:32:03 +06:00
Egor Pozharov
f9c54b5172 add build version tracking with commit SHA, version number, and build timestamp to frontend
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-05-04 16:08:37 +06:00
Egor Pozharov
a3929bec8d implement account lockout after 3 failed login attempts with 5-minute cooldown period
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-04-29 17:00:37 +06:00
Egor Pozharov
459b60c9aa remove manual update prompt UI and enable automatic silent service worker updates
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-04-21 16:26:01 +06:00
Egor Pozharov
4110083019 refine nginx caching strategy and add service worker update prompt with periodic checks
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-04-21 12:55:01 +06:00
Egor Pozharov
60dea22ced add git hooks for commit validation, auto-suggestions, and Docker build/push automation on git push 2026-04-21 12:54:39 +06:00
46 changed files with 1153 additions and 276 deletions

View File

@@ -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 }}

27
.githooks/commit-msg Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# Commit message hook: validate commit message format
COMMIT_MSG_FILE=$1
MSG=$(head -n1 "$COMMIT_MSG_FILE")
# Skip validation for merge commits
if echo "$MSG" | grep -q "^Merge"; then
exit 0
fi
# Check message length
if [ ${#MSG} -lt 5 ]; then
echo "❌ Commit message too short (min 5 characters)"
exit 1
fi
# Optional: enforce conventional commits format
# Uncomment if you want to enforce prefixes like "feat:", "fix:", etc.
# if ! echo "$MSG" | grep -qE "^(feat|fix|docs|style|refactor|test|chore|backend|frontend|deploy)(\(.+\))?:"; then
# echo "❌ Commit message should follow format: type: description"
# echo " Allowed types: feat, fix, docs, style, refactor, test, chore, backend, frontend, deploy"
# exit 1
# fi
echo "✅ Commit message OK"
exit 0

17
.githooks/post-commit Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
# Post-commit hook: show info after successful commit
COMMIT_HASH=$(git rev-parse --short HEAD)
COMMIT_MSG=$(git log -1 --pretty=%s)
BRANCH=$(git rev-parse --abbrev-ref HEAD)
echo ""
echo "✅ Commit created: $COMMIT_HASH"
echo "📝 Message: $COMMIT_MSG"
echo "🌿 Branch: $BRANCH"
echo ""
# Remind to push if needed
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
echo "💡 Tip: Run 'git push' to trigger deployment"
fi

38
.githooks/pre-push Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
# Pre-push hook: build and push Docker images (blocks until complete)
# This ensures push only succeeds after successful build
REGISTRY="gitea.chedius.ru/chedius"
PLATFORM="linux/amd64"
COMMIT_SHA=$(git rev-parse --short=12 HEAD 2>/dev/null || echo dev)
echo "🔨 Building Docker images..."
# Build backend
docker build --platform $PLATFORM -t $REGISTRY/delivery-tracker/backend:latest ./backend || {
echo "❌ Backend build failed"
exit 1
}
# Build frontend
docker build --platform $PLATFORM --build-arg GITEA_SHA=$COMMIT_SHA -t $REGISTRY/delivery-tracker/frontend:latest ./frontend || {
echo "❌ Frontend build failed"
exit 1
}
echo "📤 Pushing Docker images..."
# Push backend
docker push $REGISTRY/delivery-tracker/backend:latest || {
echo "❌ Backend push failed"
exit 1
}
# Push frontend
docker push $REGISTRY/delivery-tracker/frontend:latest || {
echo "❌ Frontend push failed"
exit 1
}
echo "✅ Docker images built and pushed successfully"
echo "📡 Git push will now proceed..."

46
.githooks/prepare-commit-msg Executable file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
# Prepare commit message hook: auto-generate commit message based on changes
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
# Only suggest message for regular commits (not merge, squash, etc.)
if [ -z "$COMMIT_SOURCE" ] || [ "$COMMIT_SOURCE" = "message" ]; then
# Get list of changed files
CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
if [ -z "$CHANGED_FILES" ]; then
exit 0
fi
# Determine commit type based on changed files
if echo "$CHANGED_FILES" | grep -q "backend/"; then
PREFIX="backend:"
elif echo "$CHANGED_FILES" | grep -q "frontend/"; then
PREFIX="frontend:"
elif echo "$CHANGED_FILES" | grep -q "\.github\|\.gitea\|deploy\|docker"; then
PREFIX="deploy:"
else
PREFIX="chore:"
fi
# Count files
FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l | tr -d ' ')
# Generate suggested message
if [ "$FILE_COUNT" -eq 1 ]; then
FILENAME=$(basename "$CHANGED_FILES")
SUGGESTED_MSG="$PREFIX update $FILENAME"
else
SUGGESTED_MSG="$PREFIX update $FILE_COUNT files"
fi
# If message file is empty or has default template, add suggestion
if [ ! -s "$COMMIT_MSG_FILE" ] || ! grep -v '^#' "$COMMIT_MSG_FILE" | grep -q '[^[:space:]]'; then
echo "$SUGGESTED_MSG" > "$COMMIT_MSG_FILE"
echo "" >> "$COMMIT_MSG_FILE"
echo "# Suggested commit message generated based on changes" >> "$COMMIT_MSG_FILE"
echo "# Changed files:" >> "$COMMIT_MSG_FILE"
echo "$CHANGED_FILES" | sed 's/^/# /' >> "$COMMIT_MSG_FILE"
fi
fi

View File

@@ -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 .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
@@ -30,11 +32,30 @@ update-server:
docker pull $(REGISTRY)/delivery-tracker/frontend:latest docker pull $(REGISTRY)/delivery-tracker/frontend:latest
docker-compose up -d --force-recreate backend frontend docker-compose up -d --force-recreate backend frontend
# Full workflow: commit, build, push # Install git hooks for automation
release: install-hooks:
git config core.hooksPath .githooks
chmod +x .githooks/*
@echo "✅ Git hooks installed from .githooks/"
version:
@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
$(MAKE) build push @echo "✅ Released! Watchtower will deploy within 60 seconds."
@echo "Released! Watchtower will deploy within 60 seconds."

View File

@@ -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"))))

View File

@@ -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

View File

@@ -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=

View File

@@ -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"
}

View File

@@ -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"})

View File

@@ -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

View File

@@ -0,0 +1,3 @@
ALTER TABLE users
DROP COLUMN locked_until,
DROP COLUMN failed_login_attempts;

View File

@@ -0,0 +1,3 @@
ALTER TABLE users
ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0,
ADD COLUMN locked_until TIMESTAMPTZ;

View File

@@ -0,0 +1,2 @@
ALTER TABLE deliveries DROP COLUMN warehouse_request_source_2;
ALTER TABLE deliveries DROP COLUMN warehouse_request_source;

View File

@@ -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);

View File

@@ -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

View File

@@ -9,32 +9,36 @@ import (
) )
type Delivery struct { type Delivery struct {
ID pgtype.UUID `db:"id" json:"id"` ID pgtype.UUID `db:"id" json:"id"`
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"`
ProductName string `db:"product_name" json:"product_name"` ProductName string `db:"product_name" json:"product_name"`
Address string `db:"address" json:"address"` Address string `db:"address" json:"address"`
Phone string `db:"phone" json:"phone"` Phone string `db:"phone" json:"phone"`
AdditionalPhone pgtype.Text `db:"additional_phone" json:"additional_phone"` AdditionalPhone pgtype.Text `db:"additional_phone" json:"additional_phone"`
HasElevator bool `db:"has_elevator" json:"has_elevator"` HasElevator bool `db:"has_elevator" json:"has_elevator"`
Comment pgtype.Text `db:"comment" json:"comment"` Comment pgtype.Text `db:"comment" json:"comment"`
Status string `db:"status" json:"status"` Status string `db:"status" json:"status"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
CustomerName string `db:"customer_name" json:"customer_name"` CustomerName string `db:"customer_name" json:"customer_name"`
ServiceInfo pgtype.Text `db:"service_info" json:"service_info"` ServiceInfo pgtype.Text `db:"service_info" json:"service_info"`
PickupLocation2 pgtype.Text `db:"pickup_location_2" json:"pickup_location_2"` PickupLocation2 pgtype.Text `db:"pickup_location_2" json:"pickup_location_2"`
ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"` ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"`
Street string `db:"street" json:"street"` Street string `db:"street" json:"street"`
House string `db:"house" json:"house"` House string `db:"house" json:"house"`
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 {
ID pgtype.UUID `db:"id" json:"id"` ID pgtype.UUID `db:"id" json:"id"`
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"`
} }

View File

@@ -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
} }

View File

@@ -13,32 +13,34 @@ 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"`
ProductName string `db:"product_name" json:"product_name"` WarehouseRequestSource pgtype.Text `db:"warehouse_request_source" json:"warehouse_request_source"`
ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"` WarehouseRequestSource2 pgtype.Text `db:"warehouse_request_source_2" json:"warehouse_request_source_2"`
CustomerName string `db:"customer_name" json:"customer_name"` ProductName string `db:"product_name" json:"product_name"`
Address string `db:"address" json:"address"` ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"`
Street string `db:"street" json:"street"` CustomerName string `db:"customer_name" json:"customer_name"`
House string `db:"house" json:"house"` Address string `db:"address" json:"address"`
Apartment pgtype.Text `db:"apartment" json:"apartment"` Street string `db:"street" json:"street"`
Entrance pgtype.Text `db:"entrance" json:"entrance"` House string `db:"house" json:"house"`
Floor pgtype.Text `db:"floor" json:"floor"` Apartment pgtype.Text `db:"apartment" json:"apartment"`
Phone string `db:"phone" json:"phone"` Entrance pgtype.Text `db:"entrance" json:"entrance"`
AdditionalPhone pgtype.Text `db:"additional_phone" json:"additional_phone"` Floor pgtype.Text `db:"floor" json:"floor"`
HasElevator bool `db:"has_elevator" json:"has_elevator"` Phone string `db:"phone" json:"phone"`
ServiceInfo pgtype.Text `db:"service_info" json:"service_info"` AdditionalPhone pgtype.Text `db:"additional_phone" json:"additional_phone"`
Comment pgtype.Text `db:"comment" json:"comment"` HasElevator bool `db:"has_elevator" json:"has_elevator"`
ServiceInfo pgtype.Text `db:"service_info" json:"service_info"`
Comment pgtype.Text `db:"comment" json:"comment"`
} }
func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (Delivery, error) { func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (Delivery, error) {
@@ -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,52 +254,96 @@ 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"`
ProductName string `db:"product_name" json:"product_name"` WarehouseRequestSource pgtype.Text `db:"warehouse_request_source" json:"warehouse_request_source"`
ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"` WarehouseRequestSource2 pgtype.Text `db:"warehouse_request_source_2" json:"warehouse_request_source_2"`
CustomerName string `db:"customer_name" json:"customer_name"` ProductName string `db:"product_name" json:"product_name"`
Address string `db:"address" json:"address"` ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"`
Street string `db:"street" json:"street"` CustomerName string `db:"customer_name" json:"customer_name"`
House string `db:"house" json:"house"` Address string `db:"address" json:"address"`
Apartment pgtype.Text `db:"apartment" json:"apartment"` Street string `db:"street" json:"street"`
Entrance pgtype.Text `db:"entrance" json:"entrance"` House string `db:"house" json:"house"`
Floor pgtype.Text `db:"floor" json:"floor"` Apartment pgtype.Text `db:"apartment" json:"apartment"`
Phone string `db:"phone" json:"phone"` Entrance pgtype.Text `db:"entrance" json:"entrance"`
AdditionalPhone pgtype.Text `db:"additional_phone" json:"additional_phone"` Floor pgtype.Text `db:"floor" json:"floor"`
HasElevator bool `db:"has_elevator" json:"has_elevator"` Phone string `db:"phone" json:"phone"`
ServiceInfo pgtype.Text `db:"service_info" json:"service_info"` AdditionalPhone pgtype.Text `db:"additional_phone" json:"additional_phone"`
Comment pgtype.Text `db:"comment" json:"comment"` HasElevator bool `db:"has_elevator" json:"has_elevator"`
ID pgtype.UUID `db:"id" json:"id"` ServiceInfo pgtype.Text `db:"service_info" json:"service_info"`
Comment pgtype.Text `db:"comment" json:"comment"`
ID pgtype.UUID `db:"id" json:"id"`
} }
func (q *Queries) UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams) error { func (q *Queries) UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams) error {
@@ -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,

View File

@@ -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,31 +15,34 @@ 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
type DeliveryRequest struct { 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"`
ProductName string `json:"product_name" binding:"required"` WarehouseRequestSource *string `json:"warehouse_request_source" binding:"omitempty,oneof=symbat nursaya galaktika"`
ProductName2 *string `json:"product_name_2"` WarehouseRequestSource2 *string `json:"warehouse_request_source_2" binding:"omitempty,oneof=symbat nursaya galaktika"`
CustomerName string `json:"customer_name" binding:"required"` ProductName string `json:"product_name" binding:"required"`
Address string `json:"address" binding:"required"` ProductName2 *string `json:"product_name_2"`
Street string `json:"street" binding:"required"` CustomerName string `json:"customer_name" binding:"required"`
House string `json:"house" binding:"required"` Address string `json:"address" binding:"required"`
Apartment *string `json:"apartment"` Street string `json:"street" binding:"required"`
Entrance *string `json:"entrance"` House string `json:"house" binding:"required"`
Floor *string `json:"floor"` Apartment *string `json:"apartment"`
Phone string `json:"phone" binding:"required"` Entrance *string `json:"entrance"`
AdditionalPhone *string `json:"additional_phone"` Floor *string `json:"floor"`
HasElevator bool `json:"has_elevator"` Phone string `json:"phone" binding:"required"`
ServiceInfo *string `json:"service_info"` AdditionalPhone *string `json:"additional_phone"`
Comment string `json:"comment"` HasElevator bool `json:"has_elevator"`
ServiceInfo *string `json:"service_info"`
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)
@@ -106,23 +116,25 @@ func (h *Handler) CreateDelivery(c *gin.Context) {
} }
params := sqlc.CreateDeliveryParams{ params := sqlc.CreateDeliveryParams{
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},
ProductName: req.ProductName, WarehouseRequestSource: pgtype.Text{String: derefString(req.WarehouseRequestSource), Valid: req.WarehouseRequestSource != nil},
ProductName2: pgtype.Text{String: derefString(req.ProductName2), Valid: req.ProductName2 != nil}, WarehouseRequestSource2: pgtype.Text{String: derefString(req.WarehouseRequestSource2), Valid: req.WarehouseRequestSource2 != nil},
CustomerName: req.CustomerName, ProductName: req.ProductName,
Address: req.Address, ProductName2: pgtype.Text{String: derefString(req.ProductName2), Valid: req.ProductName2 != nil},
Street: req.Street, CustomerName: req.CustomerName,
House: req.House, Address: req.Address,
Apartment: pgtype.Text{String: derefString(req.Apartment), Valid: req.Apartment != nil}, Street: req.Street,
Entrance: pgtype.Text{String: derefString(req.Entrance), Valid: req.Entrance != nil}, House: req.House,
Floor: pgtype.Text{String: derefString(req.Floor), Valid: req.Floor != nil}, Apartment: pgtype.Text{String: derefString(req.Apartment), Valid: req.Apartment != nil},
Phone: req.Phone, Entrance: pgtype.Text{String: derefString(req.Entrance), Valid: req.Entrance != nil},
AdditionalPhone: pgtype.Text{String: derefString(req.AdditionalPhone), Valid: req.AdditionalPhone != nil}, Floor: pgtype.Text{String: derefString(req.Floor), Valid: req.Floor != nil},
HasElevator: req.HasElevator, Phone: req.Phone,
ServiceInfo: pgtype.Text{String: derefString(req.ServiceInfo), Valid: req.ServiceInfo != nil}, AdditionalPhone: pgtype.Text{String: derefString(req.AdditionalPhone), Valid: req.AdditionalPhone != nil},
Comment: pgtype.Text{String: req.Comment, Valid: true}, HasElevator: req.HasElevator,
ServiceInfo: pgtype.Text{String: derefString(req.ServiceInfo), Valid: req.ServiceInfo != nil},
Comment: pgtype.Text{String: req.Comment, Valid: true},
} }
res, err := h.queries.CreateDelivery(c.Request.Context(), params) res, err := h.queries.CreateDelivery(c.Request.Context(), params)
if err != nil { if err != nil {
@@ -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")
@@ -161,29 +178,36 @@ func (h *Handler) UpdateDelivery(c *gin.Context) {
} }
if err := h.queries.UpdateDelivery(c.Request.Context(), sqlc.UpdateDeliveryParams{ if err := h.queries.UpdateDelivery(c.Request.Context(), sqlc.UpdateDeliveryParams{
ID: pgtype.UUID{Bytes: parsedID, Valid: true}, ID: pgtype.UUID{Bytes: parsedID, Valid: true},
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},
ProductName: req.ProductName, WarehouseRequestSource: pgtype.Text{String: derefString(req.WarehouseRequestSource), Valid: req.WarehouseRequestSource != nil},
ProductName2: pgtype.Text{String: derefString(req.ProductName2), Valid: req.ProductName2 != nil}, WarehouseRequestSource2: pgtype.Text{String: derefString(req.WarehouseRequestSource2), Valid: req.WarehouseRequestSource2 != nil},
CustomerName: req.CustomerName, ProductName: req.ProductName,
Address: req.Address, ProductName2: pgtype.Text{String: derefString(req.ProductName2), Valid: req.ProductName2 != nil},
Street: req.Street, CustomerName: req.CustomerName,
House: req.House, Address: req.Address,
Apartment: pgtype.Text{String: derefString(req.Apartment), Valid: req.Apartment != nil}, Street: req.Street,
Entrance: pgtype.Text{String: derefString(req.Entrance), Valid: req.Entrance != nil}, House: req.House,
Floor: pgtype.Text{String: derefString(req.Floor), Valid: req.Floor != nil}, Apartment: pgtype.Text{String: derefString(req.Apartment), Valid: req.Apartment != nil},
Phone: req.Phone, Entrance: pgtype.Text{String: derefString(req.Entrance), Valid: req.Entrance != nil},
AdditionalPhone: pgtype.Text{String: derefString(req.AdditionalPhone), Valid: req.AdditionalPhone != nil}, Floor: pgtype.Text{String: derefString(req.Floor), Valid: req.Floor != nil},
HasElevator: req.HasElevator, Phone: req.Phone,
ServiceInfo: pgtype.Text{String: derefString(req.ServiceInfo), Valid: req.ServiceInfo != nil}, AdditionalPhone: pgtype.Text{String: derefString(req.AdditionalPhone), Valid: req.AdditionalPhone != nil},
Comment: pgtype.Text{String: req.Comment, Valid: true}, HasElevator: req.HasElevator,
ServiceInfo: pgtype.Text{String: derefString(req.ServiceInfo), Valid: req.ServiceInfo != nil},
Comment: pgtype.Text{String: req.Comment, Valid: true},
}); err != nil { }); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update delivery", "details": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update delivery", "details": err.Error()})
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
}

View 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
}
}
}
}

View 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
}

View 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()
}
}

View 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)
}
}
}

View File

@@ -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

View File

@@ -10,12 +10,59 @@ server {
gzip_min_length 1024; gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json; gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Cache static assets # Hashed build assets — safe to cache forever
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location /assets/ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
# Other static files (icons, fonts at root etc.) — short cache
location ~* \.(png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
# Never cache entry points — must always revalidate so clients
# can detect new frontend versions and service worker updates
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
}
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
}
location = /registerSW.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
}
location = /manifest.webmanifest {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
}
location = /manifest.json {
add_header Cache-Control "no-cache, no-store, must-revalidate";
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;

View File

@@ -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",

View File

@@ -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",

View File

@@ -4,8 +4,10 @@ import { DeliveryForm } from './components/delivery/DeliveryForm';
import { LoginForm } from './components/auth/LoginForm'; import { LoginForm } from './components/auth/LoginForm';
import { ToastContainer } from './components/ui/Toast'; import { ToastContainer } from './components/ui/Toast';
import { Button } from './components/ui/Button'; import { Button } from './components/ui/Button';
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'));
@@ -29,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();
@@ -89,6 +93,7 @@ function App() {
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<> <>
<UpdatePrompt />
<LoginForm /> <LoginForm />
<ToastContainer /> <ToastContainer />
</> </>
@@ -97,6 +102,7 @@ function App() {
return ( return (
<div className="min-h-screen bg-[#fbf8fb]"> <div className="min-h-screen bg-[#fbf8fb]">
<UpdatePrompt />
<header className="sticky top-0 z-40 bg-[#1B263B] text-white shadow-md"> <header className="sticky top-0 z-40 bg-[#1B263B] text-white shadow-md">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14"> <div className="flex items-center justify-between h-14">

View File

@@ -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
); );
} }

View File

@@ -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,

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}

View File

@@ -0,0 +1,55 @@
import { useEffect } from 'react';
import { useRegisterSW } from 'virtual:pwa-register/react';
// Check for SW updates every hour and on tab focus/visibility change
const UPDATE_INTERVAL_MS = 60 * 60 * 1000;
const buildLabel = `${__APP_VERSION__}-${__APP_COMMIT__}`;
export function UpdatePrompt() {
const {
needRefresh: [needRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegisteredSW(_swUrl, registration) {
console.info('[App] version', {
version: __APP_VERSION__,
commit: __APP_COMMIT__,
builtAt: __APP_BUILT_AT__,
serviceWorker: registration ? 'registered' : 'unavailable',
});
if (!registration) return;
const checkForUpdate = async () => {
if (registration.installing || !navigator) return;
if ('connection' in navigator && !navigator.onLine) return;
try {
console.debug('[PWA] checking for update', { version: buildLabel });
await registration.update();
} catch {
console.debug('[PWA] update check failed, will retry later', { version: buildLabel });
}
};
setInterval(checkForUpdate, UPDATE_INTERVAL_MS);
const onVisible = () => {
if (document.visibilityState === 'visible') checkForUpdate();
};
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('focus', checkForUpdate);
},
});
useEffect(() => {
if (needRefresh) {
console.info('[PWA] new version detected, applying automatically', {
currentVersion: buildLabel,
});
updateServiceWorker(true);
}
}, [needRefresh, updateServiceWorker]);
return null;
}

View File

@@ -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 },
];

View File

@@ -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,
});
};

View File

@@ -3,18 +3,6 @@ import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.then((registration) => {
console.log('SW registered:', registration)
})
.catch((error) => {
console.log('SW registration failed:', error)
})
})
}
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />

View File

@@ -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>

View File

@@ -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;
} }
}, },

View File

@@ -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),
}));
},
})); }));

View File

@@ -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
View 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;

View File

@@ -5,7 +5,7 @@
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"], "lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"types": ["vite/client"], "types": ["vite/client", "vite-plugin-pwa/client", "vite-plugin-pwa/react"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */

View File

@@ -3,16 +3,31 @@ 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(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'prompt',
manifest: false, // manifest.json from public manifest: false, // manifest.json from public
workbox: { workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,json}'], globPatterns: ['**/*.{js,css,html,ico,png,svg,json}'],
cleanupOutdatedCaches: true,
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: /^https:\/\/.*\/api\//, urlPattern: /^https:\/\/.*\/api\//,
@@ -31,6 +46,7 @@ export default defineConfig({
'/api': { '/api': {
target: 'http://localhost:8080', target: 'http://localhost:8080',
changeOrigin: true, changeOrigin: true,
ws: true,
}, },
}, },
}, },