Compare commits

...

28 Commits

Author SHA1 Message Date
Egor Pozharov
2c59f027ea remove address auto-parsing and always display address detail fields in DeliveryForm
Some checks are pending
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
2026-04-17 16:43:16 +06:00
Egor Pozharov
b54cdb878d remove address auto-parsing and always display address detail fields in DeliveryForm
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-17 16:42:37 +06:00
Egor Pozharov
57fd82c6dd update DeliveryCard to pair pickup locations with product names in a single section
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-16 23:36:55 +06:00
Egor Pozharov
6647379abc fix watchtower config
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-16 23:29:52 +06:00
Egor Pozharov
11122c7919 update docker-compose.yml and makefile
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-16 23:19:54 +06:00
Egor Pozharov
357a395cbb update Watchtower to monitor specific containers and enable label-based filtering 2026-04-16 23:04:14 +06:00
Egor Pozharov
ce6ea377ce add customer name, service info, second pickup location, and structured address fields to deliveries 2026-04-16 20:16:21 +06:00
Egor Pozharov
7f775abf6a remove empty line and add empty cells for calendar month start alignment 2026-04-16 15:48:51 +06:00
Egor Pozharov
6864235e3d add healthcheck to backend service with 10s interval and 5 retries 2026-04-16 15:16:17 +06:00
Egor Pozharov
76668f8a48 add SEED_ADMIN_PASSWORD environment variable to backend service 2026-04-16 14:56:46 +06:00
Egor Pozharov
c77518b34a add seed binary to Docker build and expose frontend port 80 2026-04-16 14:11:53 +06:00
Egor Pozharov
1bf5d1afd6 update Gitea registry credentials in production example 2026-04-16 13:13:19 +06:00
Egor Pozharov
86a684790c update envs and urls 2026-04-16 13:10:58 +06:00
Egor Pozharov
ff27493670 fix JWT token expiry from 24 minutes to 24 hours 2026-04-16 13:00:48 +06:00
Egor Pozharov
70129baad5 change delivery count query to show next 7 days instead of current month 2026-04-16 13:00:32 +06:00
Egor Pozharov
c373d82135 add authentication with login form and token management 2026-04-16 12:47:42 +06:00
Egor Pozharov
be0b13acbf add auth module 2026-04-15 19:17:10 +06:00
Egor Pozharov
e50f81f7f3 update .gitignore 2026-04-15 19:16:22 +06:00
Egor Pozharov
8d6f4a4c52 update docker deploy config 2026-04-14 18:25:47 +06:00
Egor Pozharov
9abc1e3888 add /api proxy 2026-04-14 17:30:43 +06:00
Egor Pozharov
9c9f01b2f2 add pwa 2026-04-14 17:13:47 +06:00
Egor Pozharov
cb3f91c17f refactor [2] 2026-04-14 17:11:00 +06:00
Egor Pozharov
9b90a8aa7f frontend refactor 2026-04-14 17:10:13 +06:00
Egor Pozharov
0540218332 switch frontend to real API instead of mocks 2026-04-14 16:17:42 +06:00
Egor Pozharov
7f410e814b add CORS 2026-04-14 16:17:22 +06:00
Egor Pozharov
b36a6fb262 add PATCH update delivery status by id 2026-04-14 15:46:32 +06:00
Egor Pozharov
d3cd92b9f3 add dockerfile for backend && update docker-compose.yml 2026-04-14 15:39:21 +06:00
Egor Pozharov
10233808f4 add GET delivery count route 2026-04-14 15:38:45 +06:00
56 changed files with 12055 additions and 558 deletions

15
.env.production.example Normal file
View File

@@ -0,0 +1,15 @@
# Database
POSTGRES_USER=delivery_user
POSTGRES_PASSWORD=your_secure_password_here
POSTGRES_DB=delivery_tracker
# JWT
JWT_SECRET=your_random_jwt_secret_min_32_chars
# Seed admin password
SEED_ADMIN_PASSWORD=your_secure_password_here
# Gitea Registry credentials for Watchtower
GITEA_REGISTRY=gitea.chedius.ru/chedius
GITEA_USER=chedius
GITEA_TOKEN=your_gitea_token_or_password

View File

@@ -0,0 +1,48 @@
name: Build and Push Docker Images
on:
push:
branches: [main, master]
jobs:
build-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: gitea.chedius.ru
username: ${{ gitea.actor }}
password: ${{ secrets.GITEA_TOKEN }}
- name: Build and push backend
uses: docker/build-push-action@v5
with:
context: ./backend
push: true
tags: |
gitea.gitea.chedius.ru/${{ gitea.repository_owner }}/delivery-tracker/backend:latest
gitea.gitea.chedius.ru/${{ gitea.repository_owner }}/delivery-tracker/backend:${{ gitea.sha }}
build-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: gitea.chedius.ru
username: ${{ gitea.actor }}
password: ${{ secrets.GITEA_TOKEN }}
- name: Build and push frontend
uses: docker/build-push-action@v5
with:
context: ./frontend
push: true
tags: |
gitea.gitea.chedius.ru/${{ gitea.repository_owner }}/delivery-tracker/frontend:latest
gitea.gitea.chedius.ru/${{ gitea.repository_owner }}/delivery-tracker/frontend:${{ gitea.sha }}

3
.gitignore vendored
View File

@@ -9,6 +9,7 @@ lerna-debug.log*
node_modules node_modules
dist dist
dev-dist
dist-ssr dist-ssr
*.local *.local
@@ -16,6 +17,8 @@ dist-ssr
.env .env
.env.local .env.local
.env.*.local .env.*.local
backend/.env
backend/.env.local
# Testing # Testing
coverage coverage

168
DEPLOY.md Normal file
View File

@@ -0,0 +1,168 @@
# Автоматический деплой на LXC + Docker + Nginx Proxy Manager
## Схема работы
```
[Git Push] → [Gitea Actions] → [Build Images] → [Gitea Registry]
[LXC Server] ← [Watchtower] ← [Poll every 60s]
[Nginx Proxy Manager] → [HTTPS] → [frontend:80]
/api/* → [backend:8080] (внутри сети)
```
## Пошаговая настройка
### 1. Настройка Gitea
В конфиге Gitea (`app.ini`) включи registry:
```ini
[packages]
ENABLED = true
```
Перезапусти Gitea.
### 2. Обнови workflow файл
Открой `.gitea/workflows/deploy.yml` и замени:
- `gitea.your-domain.com` → на твой домен Gitea
- Убедись что путь `${{ gitea.repository_owner }}/delivery-tracker` корректен
### 3. Создай токен в Gitea
- Gitea → Settings → Applications → Generate Token
- Сохрани токен (понадобится для Watchtower)
### 4. Настройка LXC сервера (если еще не настроен Docker)
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp docker
```
### 5. Клонируй репозиторий на сервер
```bash
cd /opt
sudo git clone https://gitea.your-domain.com/yourusername/delivery-tracker.git
sudo chown -R $USER:$USER delivery-tracker
```
### 6. Настрой переменные окружения
```bash
cd delivery-tracker
cp .env.production.example .env
nano .env
```
Заполни:
- Пароли для PostgreSQL
- JWT секрет: `openssl rand -hex 32`
- Gitea credentials для Watchtower
- `GITEA_REGISTRY` — твой registry (например: `gitea.example.com/yourusername`)
### 7. Логин в Gitea Registry на сервере
```bash
docker login gitea.your-domain.com
# Введи username и токен/password
```
### 8. Первый запуск
```bash
docker-compose -f docker-compose.prod.yml pull
docker-compose -f docker-compose.prod.yml up -d
```
### 9. Настройка Nginx Proxy Manager
Открой веб-интерфейс NPM (обычно `http://server-ip:81` или через твой домен).
#### Добавь Proxy Host для приложения:
- **Domain Names**: `delivery.yourdomain.com` (замени на свой поддомен)
- **Scheme**: `http`
- **Forward Hostname/IP**: `frontend` (имя сервиса в docker-compose)
- **Forward Port**: `80`
- **Cache Assets**: Включи
- **Block Common Exploits**: Включи
**SSL Tab:**
- SSL Certificate: Request a new SSL Certificate
- Force SSL: Включи
- HTTP/2 Support: Включи
**Как это работает:**
- NPM проксирует все запросы на frontend контейнер
- Frontend nginx сам проксирует `/api/*` запросы на backend через docker network
- Backend вообще не доступен извне — только через frontend
**Важно**: Контейнеры используют `expose` порты (не `ports`). NPM достучится до `frontend` через docker network если NPM в той же сети, или по IP сервера.
#### Подключение NPM к сети контейнеров:
Если NPM запущен в другом compose, подключи его к сети delivery-tracker:
```bash
docker network connect delivery-tracker_delivery-network npm-app-1
```
Или используй IP адрес сервера (`172.17.0.1` или `host.docker.internal`) в поле Forward Hostname.
### 10. Проверь автоматическое обновление
Watchtower будет каждые 60 секунд проверять новые образы:
```bash
docker-compose -f docker-compose.prod.yml logs -f watchtower
```
## Как работает автодеплой
1. `git push` в main/master
2. Gitea Actions собирает образы → push в Registry
3. Watchtower (60s poll) → проверяет registry → pull новых образов → перезапускает контейнеры
4. NPM продолжает проксировать трафик на обновленные контейнеры
## Ручной деплой
```bash
cd /opt/delivery-tracker
docker-compose -f docker-compose.prod.yml pull
docker-compose -f docker-compose.prod.yml up -d
```
## Обновление SSL через NPM
NPM автоматически обновляет SSL сертификаты Let's Encrypt. Ничего делать не нужно.
## Траблшутинг
**Образы не обновляются:**
```bash
docker-compose -f docker-compose.prod.yml logs watchtower
docker login gitea.your-domain.com
```
**NPM не видит контейнеры:**
- Проверь что NPM и delivery-tracker в одной docker-сети
- Или используй IP сервера вместо имен сервисов
- Проверь `docker network ls` и `docker network inspect delivery-tracker_delivery-network`
**Контейнеры не запускаются:**
```bash
docker-compose -f docker-compose.prod.yml logs backend
docker-compose -f docker-compose.prod.yml logs frontend
docker-compose -f docker-compose.prod.yml logs postgres
```
**Frontend не подключается к backend:**
- Проверь что frontend nginx проксирует `/api/` на `backend:8080`
- Проверь логи frontend: `docker-compose -f docker-compose.prod.yml logs frontend`
- Убедись что backend работает: `docker-compose -f docker-compose.prod.yml logs backend`

40
Makefile Normal file
View File

@@ -0,0 +1,40 @@
# Delivery Tracker - Local Build & Deploy
REGISTRY = gitea.chedius.ru/chedius
PLATFORM = linux/amd64
# Build and push both services
.PHONY: all build push deploy
all: build push
build:
docker build --platform $(PLATFORM) -t $(REGISTRY)/delivery-tracker/backend:latest ./backend
docker build --platform $(PLATFORM) -t $(REGISTRY)/delivery-tracker/frontend:latest ./frontend
push:
docker push $(REGISTRY)/delivery-tracker/backend:latest
docker push $(REGISTRY)/delivery-tracker/frontend:latest
# Quick deploy - build, push and trigger watchtower check
deploy: build push
@echo "Build and push complete. Watchtower will auto-update within 60 seconds."
@echo "Or run 'make watchtower-now' to force immediate update"
# Force watchtower to check now (run from server)
watchtower-now:
docker exec delivery-tracker-watchtower-1 /watchtower --run-once delivery-tracker-backend-1 delivery-tracker-frontend-1
# Update specific containers on server (if watchtower fails)
update-server:
docker pull $(REGISTRY)/delivery-tracker/backend:latest
docker pull $(REGISTRY)/delivery-tracker/frontend:latest
docker-compose up -d --force-recreate backend frontend
# Full workflow: commit, build, push
release:
@if [ -z "$(MSG)" ]; then echo "Usage: make release MSG='commit message'"; exit 1; fi
git add -A
git commit -m "$(MSG)" || true
git push
$(MAKE) build push
@echo "Released! Watchtower will deploy within 60 seconds."

View File

@@ -0,0 +1,36 @@
# Stage 1: Builder
FROM golang:1.25-alpine AS builder
WORKDIR /app
# Install git and build dependencies
RUN apk add --no-cache git
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -o api ./cmd/api && \
CGO_ENABLED=0 GOOS=linux go build -o seed ./cmd/seed
# Stage 2: Production
FROM alpine:latest AS production
RUN apk --no-cache add ca-certificates
WORKDIR /app
# Copy binary from builder
COPY --from=builder /app/api .
COPY --from=builder /app/seed .
# Copy migrations
COPY internal/db/migrations ./migrations
EXPOSE 8080
CMD ["./api"]

View File

@@ -5,14 +5,30 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"time"
"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/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"
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )
func initAuth(queries *db.Queries) (*auth.Service, *auth.Handler) {
secret := []byte(os.Getenv("JWT_SECRET"))
expiry := 24 * time.Hour
if len(secret) == 0 {
log.Fatal("JWT_SECRET not set")
}
service := auth.New(queries, secret, expiry)
handler := auth.NewHandler(service)
return service, handler
}
func main() { func main() {
ctx := context.Background() ctx := context.Background()
godotenv.Load() godotenv.Load()
@@ -25,19 +41,38 @@ func main() {
defer pool.Close() defer pool.Close()
queries := db.New(pool) queries := db.New(pool)
_, authHandler := initAuth(queries)
h := delivery.NewHandler(queries) h := delivery.NewHandler(queries)
r := gin.Default() r := gin.Default()
// CORS middleware - allow all origins in development
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"*"},
AllowCredentials: false,
MaxAge: 12 * time.Hour,
}))
r.GET("/health", func(c *gin.Context) { r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})
}) })
r.GET("/api/deliveries", h.GetDeliveries) r.POST("/api/auth/register", authHandler.Register)
r.GET("/api/deliveries/:id", h.GetDeliveryByID) r.POST("/api/auth/login", authHandler.Login)
r.POST("/api/deliveries", h.CreateDelivery)
r.PATCH("/api/deliveries/:id", h.UpdateDelivery) authorized := r.Group("/api")
r.DELETE("/api/deliveries/:id", h.DeleteDelivery) authorized.Use(auth.AuthMiddleware([]byte(os.Getenv("JWT_SECRET"))))
{
authorized.GET("/deliveries", h.GetDeliveries)
authorized.GET("/deliveries/:id", h.GetDeliveryByID)
authorized.GET("/deliveries/count", h.GetDeliveryCount)
authorized.POST("/deliveries", h.CreateDelivery)
authorized.PATCH("/deliveries/:id", h.UpdateDelivery)
authorized.PATCH("/deliveries/:id/status", h.UpdateDeliveryStatus)
authorized.DELETE("/deliveries/:id", h.DeleteDelivery)
}
r.Run(":8080") r.Run(":8080")
} }

51
backend/cmd/seed/main.go Normal file
View File

@@ -0,0 +1,51 @@
package main
import (
"context"
"log"
"os"
"github.com/chedius/delivery-tracker/internal/auth"
db "github.com/chedius/delivery-tracker/internal/db/sqlc"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
)
func main() {
ctx := context.Background()
godotenv.Load()
dsn := os.Getenv("DATABASE_URL")
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("db connect: %v", err)
}
defer pool.Close()
queries := db.New(pool)
_, err = queries.GetUserByUsername(ctx, "admin")
if err == nil {
log.Println("admin user already exists, skipping seed")
return
}
secret := []byte(os.Getenv("JWT_SECRET"))
if len(secret) == 0 {
log.Fatalf("JWT_SECRET not set")
}
authService := auth.New(queries, secret, 0)
password := os.Getenv("SEED_ADMIN_PASSWORD")
if password == "" {
password = "admin123" // ⚠️ только для dev!
}
user, token, err := authService.Register(ctx, "admin", password)
if err != nil {
log.Fatalf("failed to create admin: %v", err)
}
log.Printf("created admin user: id=%s, username=%s", user.ID, user.Username)
log.Printf("token: %s", token)
}

View File

@@ -7,7 +7,12 @@ require (
github.com/jackc/pgx/v5 v5.9.1 github.com/jackc/pgx/v5 v5.9.1
) )
require github.com/google/uuid v1.6.0 require (
github.com/gin-contrib/cors v1.7.7
github.com/google/uuid v1.6.0
)
require github.com/golang-jwt/jwt/v5 v5.3.1
require ( require (
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
@@ -37,11 +42,11 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0
golang.org/x/net v0.51.0 // indirect golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.10 // indirect
) )

View File

@@ -11,6 +11,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
@@ -27,6 +29,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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=
@@ -83,19 +87,19 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -0,0 +1,12 @@
package auth
import "errors"
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUserExists = errors.New("user already exists")
ErrUserNotFound = errors.New("user not found")
ErrPasswordMismatch = errors.New("passwords do not match")
ErrCredentialsEmpty = errors.New("username and password cannot be empty")
ErrPasswordTooShort = errors.New("password must be at least 6 characters long")
)

View File

@@ -0,0 +1,77 @@
package auth
import (
"net/http"
"github.com/gin-gonic/gin"
)
type Handler struct {
authService *Service
}
func NewHandler(authService *Service) *Handler {
return &Handler{
authService: authService,
}
}
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Password string `json:"password" binding:"required,min=6"`
}
func (h *Handler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request", "details": err.Error()})
return
}
token, err := h.authService.Login(c.Request.Context(), req.Username, req.Password)
if err != nil {
switch err {
case ErrUserNotFound, ErrInvalidCredentials:
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"})
}
return
}
c.JSON(http.StatusOK, gin.H{"token": token})
}
func (h *Handler) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request", "details": err.Error()})
return
}
user, token, err := h.authService.Register(c.Request.Context(), req.Username, req.Password)
if err != nil {
switch err {
case ErrUserExists:
c.JSON(http.StatusConflict, gin.H{"error": "user already exists"})
case ErrPasswordTooShort:
c.JSON(http.StatusBadRequest, gin.H{"error": "password too short"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration failed"})
}
return
}
c.JSON(http.StatusCreated, gin.H{
"user": gin.H{
"id": user.ID,
"username": user.Username,
},
"token": token,
})
}

View File

@@ -0,0 +1,50 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type Claims struct {
UserID uuid.UUID `json:"user_id"`
jwt.RegisteredClaims
}
func GenerateToken(userID uuid.UUID, secret []byte, expiry time.Duration) (string, error) {
if userID == uuid.Nil {
return "", errors.New("user ID cannot be nil")
}
if secret == nil {
return "", errors.New("JWT secret not set")
}
claims := Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}
func ParseToken(tokenString string, secret []byte) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return secret, nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token claims")
}

View File

@@ -0,0 +1,36 @@
package auth
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func AuthMiddleware(secret []byte) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
c.Abort()
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"})
c.Abort()
return
}
claims, err := ParseToken(tokenString, secret)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
c.Abort()
return
}
c.Set("userID", claims.UserID)
c.Next()
}
}

View File

@@ -0,0 +1,98 @@
package auth
import (
"context"
"errors"
"time"
sqlc "github.com/chedius/delivery-tracker/internal/db/sqlc"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
type Service struct {
queries *sqlc.Queries
secret []byte
expiry time.Duration
}
type User struct {
ID string
Username string
// Password string
}
func New(queries *sqlc.Queries, secret []byte, expiry time.Duration) *Service {
return &Service{queries, secret, expiry}
}
func (s *Service) Register(ctx context.Context, username, password string) (User, string, error) {
if username == "" || password == "" {
return User{}, "", ErrCredentialsEmpty
}
if _, err := s.queries.GetUserByUsername(ctx, username); err == nil {
return User{}, "", ErrUserExists
}
if len(password) < 6 {
return User{}, "", ErrPasswordTooShort
}
hashedPassword, err := s.HashPassword(password)
if err != nil {
return User{}, "", err
}
user, err := s.queries.CreateUser(ctx, sqlc.CreateUserParams{
Username: username,
PasswordHash: hashedPassword,
})
if err != nil {
return User{}, "", err
}
token, err := GenerateToken(uuid.UUID(user.ID.Bytes), s.secret, s.expiry)
if err != nil {
return User{}, "", err
}
return User{
ID: user.ID.String(),
Username: user.Username,
}, token, nil
}
func (s *Service) Login(ctx context.Context, username, password string) (string, error) { // returns JWT
user, err := s.queries.GetUserByUsername(ctx, username)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrUserNotFound
}
return "", err
}
if !s.VerifyPassword(user.PasswordHash, password) {
return "", ErrInvalidCredentials
}
token, err := GenerateToken(uuid.UUID(user.ID.Bytes), s.secret, s.expiry)
if err != nil {
return "", err
}
return token, nil
}
func (s *Service) HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func (s *Service) VerifyPassword(hash, password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

View File

@@ -0,0 +1,10 @@
-- Revert new fields for delivery improvements
ALTER TABLE deliveries DROP COLUMN IF EXISTS customer_name;
ALTER TABLE deliveries DROP COLUMN IF EXISTS service_info;
ALTER TABLE deliveries DROP COLUMN IF EXISTS pickup_location_2;
ALTER TABLE deliveries DROP COLUMN IF EXISTS product_name_2;
ALTER TABLE deliveries DROP COLUMN IF EXISTS street;
ALTER TABLE deliveries DROP COLUMN IF EXISTS house;
ALTER TABLE deliveries DROP COLUMN IF EXISTS apartment;
ALTER TABLE deliveries DROP COLUMN IF EXISTS entrance;
ALTER TABLE deliveries DROP COLUMN IF EXISTS floor;

View File

@@ -0,0 +1,18 @@
-- Add new fields for delivery improvements
-- Client information
ALTER TABLE deliveries ADD COLUMN customer_name text NOT NULL DEFAULT '';
-- Services (assembly, lifting, etc.)
ALTER TABLE deliveries ADD COLUMN service_info text;
-- Second pickup location
ALTER TABLE deliveries ADD COLUMN pickup_location_2 varchar(20);
ALTER TABLE deliveries ADD COLUMN product_name_2 text;
-- Structured address components
ALTER TABLE deliveries ADD COLUMN street text NOT NULL DEFAULT '';
ALTER TABLE deliveries ADD COLUMN house text NOT NULL DEFAULT '';
ALTER TABLE deliveries ADD COLUMN apartment text;
ALTER TABLE deliveries ADD COLUMN entrance text;
ALTER TABLE deliveries ADD COLUMN floor text;

View File

@@ -1,9 +1,21 @@
-- name: CreateUser :one
INSERT INTO users (username, password_hash)
VALUES ($1, $2)
RETURNING *;
-- name: GetUserByUsername :one
SELECT * FROM users WHERE username = $1;
-- 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 (date, pickup_location, product_name, address, phone, additional_phone, has_elevator, comment) INSERT INTO deliveries (
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) date, pickup_location, pickup_location_2, product_name, product_name_2,
customer_name, address, street, house, apartment, entrance, floor,
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)
RETURNING *; RETURNING *;
-- name: GetDeliveryByID :one -- name: GetDeliveryByID :one
@@ -13,4 +25,31 @@ SELECT * FROM deliveries WHERE id = $1;
DELETE FROM deliveries WHERE id = $1; DELETE FROM deliveries WHERE id = $1;
-- name: UpdateDelivery :exec -- name: UpdateDelivery :exec
UPDATE deliveries SET date = $1, pickup_location = $2, product_name = $3, address = $4, phone = $5, additional_phone = $6, has_elevator = $7, comment = $8, updated_at = NOW() WHERE id = $9; UPDATE deliveries SET
date = $1,
pickup_location = $2,
pickup_location_2 = $3,
product_name = $4,
product_name_2 = $5,
customer_name = $6,
address = $7,
street = $8,
house = $9,
apartment = $10,
entrance = $11,
floor = $12,
phone = $13,
additional_phone = $14,
has_elevator = $15,
service_info = $16,
comment = $17,
updated_at = NOW()
WHERE id = $18;
-- name: GetDeliveryCount :many
SELECT COUNT(*) as count, date FROM deliveries
WHERE date >= CURRENT_DATE AND date < CURRENT_DATE + INTERVAL '7 days'
GROUP BY date;
-- name: UpdateDeliveryStatus :exec
UPDATE deliveries SET status = $1, updated_at = NOW() WHERE id = $2;

View File

@@ -21,6 +21,15 @@ type Delivery struct {
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"`
ServiceInfo pgtype.Text `db:"service_info" json:"service_info"`
PickupLocation2 pgtype.Text `db:"pickup_location_2" json:"pickup_location_2"`
ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"`
Street string `db:"street" json:"street"`
House string `db:"house" json:"house"`
Apartment pgtype.Text `db:"apartment" json:"apartment"`
Entrance pgtype.Text `db:"entrance" json:"entrance"`
Floor pgtype.Text `db:"floor" json:"floor"`
} }
type User struct { type User struct {

View File

@@ -12,10 +12,14 @@ import (
type Querier interface { type Querier interface {
CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (Delivery, error) CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (Delivery, error)
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
DeleteDelivery(ctx context.Context, id pgtype.UUID) error DeleteDelivery(ctx context.Context, id pgtype.UUID) error
GetDeliveriesByDate(ctx context.Context, date pgtype.Date) ([]Delivery, error) GetDeliveriesByDate(ctx context.Context, date pgtype.Date) ([]Delivery, error)
GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery, error) GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery, error)
GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow, error)
GetUserByUsername(ctx context.Context, username string) (User, error)
UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams) error UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams) error
UpdateDeliveryStatus(ctx context.Context, arg UpdateDeliveryStatusParams) error
} }
var _ Querier = (*Queries)(nil) var _ Querier = (*Queries)(nil)

View File

@@ -12,19 +12,32 @@ import (
) )
const createDelivery = `-- name: CreateDelivery :one const createDelivery = `-- name: CreateDelivery :one
INSERT INTO deliveries (date, pickup_location, product_name, address, phone, additional_phone, has_elevator, comment) INSERT INTO deliveries (
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) date, pickup_location, pickup_location_2, product_name, product_name_2,
RETURNING id, date, pickup_location, product_name, address, phone, additional_phone, has_elevator, comment, status, created_at, updated_at customer_name, address, street, house, apartment, entrance, floor,
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)
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
` `
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"`
ProductName string `db:"product_name" json:"product_name"` ProductName string `db:"product_name" json:"product_name"`
ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"`
CustomerName string `db:"customer_name" json:"customer_name"`
Address string `db:"address" json:"address"` Address string `db:"address" json:"address"`
Street string `db:"street" json:"street"`
House string `db:"house" json:"house"`
Apartment pgtype.Text `db:"apartment" json:"apartment"`
Entrance pgtype.Text `db:"entrance" json:"entrance"`
Floor pgtype.Text `db:"floor" json:"floor"`
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"`
ServiceInfo pgtype.Text `db:"service_info" json:"service_info"`
Comment pgtype.Text `db:"comment" json:"comment"` Comment pgtype.Text `db:"comment" json:"comment"`
} }
@@ -32,11 +45,20 @@ func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams)
row := q.db.QueryRow(ctx, createDelivery, row := q.db.QueryRow(ctx, createDelivery,
arg.Date, arg.Date,
arg.PickupLocation, arg.PickupLocation,
arg.PickupLocation2,
arg.ProductName, arg.ProductName,
arg.ProductName2,
arg.CustomerName,
arg.Address, arg.Address,
arg.Street,
arg.House,
arg.Apartment,
arg.Entrance,
arg.Floor,
arg.Phone, arg.Phone,
arg.AdditionalPhone, arg.AdditionalPhone,
arg.HasElevator, arg.HasElevator,
arg.ServiceInfo,
arg.Comment, arg.Comment,
) )
var i Delivery var i Delivery
@@ -53,6 +75,38 @@ func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams)
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.CustomerName,
&i.ServiceInfo,
&i.PickupLocation2,
&i.ProductName2,
&i.Street,
&i.House,
&i.Apartment,
&i.Entrance,
&i.Floor,
)
return i, err
}
const createUser = `-- name: CreateUser :one
INSERT INTO users (username, password_hash)
VALUES ($1, $2)
RETURNING id, username, password_hash, created_at
`
type CreateUserParams struct {
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRow(ctx, createUser, arg.Username, arg.PasswordHash)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
) )
return i, err return i, err
} }
@@ -67,7 +121,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 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 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) {
@@ -92,6 +146,15 @@ func (q *Queries) GetDeliveriesByDate(ctx context.Context, date pgtype.Date) ([]
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.CustomerName,
&i.ServiceInfo,
&i.PickupLocation2,
&i.ProductName2,
&i.Street,
&i.House,
&i.Apartment,
&i.Entrance,
&i.Floor,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -104,7 +167,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 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 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) {
@@ -123,22 +186,106 @@ func (q *Queries) GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.CustomerName,
&i.ServiceInfo,
&i.PickupLocation2,
&i.ProductName2,
&i.Street,
&i.House,
&i.Apartment,
&i.Entrance,
&i.Floor,
)
return i, err
}
const getDeliveryCount = `-- name: GetDeliveryCount :many
SELECT COUNT(*) as count, date FROM deliveries
WHERE date >= CURRENT_DATE AND date < CURRENT_DATE + INTERVAL '7 days'
GROUP BY date
`
type GetDeliveryCountRow struct {
Count int64 `db:"count" json:"count"`
Date pgtype.Date `db:"date" json:"date"`
}
func (q *Queries) GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow, error) {
rows, err := q.db.Query(ctx, getDeliveryCount)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetDeliveryCountRow{}
for rows.Next() {
var i GetDeliveryCountRow
if err := rows.Scan(&i.Count, &i.Date); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, password_hash, created_at FROM users WHERE username = $1
`
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
row := q.db.QueryRow(ctx, getUserByUsername, username)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
) )
return i, err return i, err
} }
const updateDelivery = `-- name: UpdateDelivery :exec const updateDelivery = `-- name: UpdateDelivery :exec
UPDATE deliveries SET date = $1, pickup_location = $2, product_name = $3, address = $4, phone = $5, additional_phone = $6, has_elevator = $7, comment = $8, updated_at = NOW() WHERE id = $9 UPDATE deliveries SET
date = $1,
pickup_location = $2,
pickup_location_2 = $3,
product_name = $4,
product_name_2 = $5,
customer_name = $6,
address = $7,
street = $8,
house = $9,
apartment = $10,
entrance = $11,
floor = $12,
phone = $13,
additional_phone = $14,
has_elevator = $15,
service_info = $16,
comment = $17,
updated_at = NOW()
WHERE id = $18
` `
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"`
ProductName string `db:"product_name" json:"product_name"` ProductName string `db:"product_name" json:"product_name"`
ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"`
CustomerName string `db:"customer_name" json:"customer_name"`
Address string `db:"address" json:"address"` Address string `db:"address" json:"address"`
Street string `db:"street" json:"street"`
House string `db:"house" json:"house"`
Apartment pgtype.Text `db:"apartment" json:"apartment"`
Entrance pgtype.Text `db:"entrance" json:"entrance"`
Floor pgtype.Text `db:"floor" json:"floor"`
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"`
ServiceInfo pgtype.Text `db:"service_info" json:"service_info"`
Comment pgtype.Text `db:"comment" json:"comment"` Comment pgtype.Text `db:"comment" json:"comment"`
ID pgtype.UUID `db:"id" json:"id"` ID pgtype.UUID `db:"id" json:"id"`
} }
@@ -147,13 +294,36 @@ func (q *Queries) UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams)
_, err := q.db.Exec(ctx, updateDelivery, _, err := q.db.Exec(ctx, updateDelivery,
arg.Date, arg.Date,
arg.PickupLocation, arg.PickupLocation,
arg.PickupLocation2,
arg.ProductName, arg.ProductName,
arg.ProductName2,
arg.CustomerName,
arg.Address, arg.Address,
arg.Street,
arg.House,
arg.Apartment,
arg.Entrance,
arg.Floor,
arg.Phone, arg.Phone,
arg.AdditionalPhone, arg.AdditionalPhone,
arg.HasElevator, arg.HasElevator,
arg.ServiceInfo,
arg.Comment, arg.Comment,
arg.ID, arg.ID,
) )
return err return err
} }
const updateDeliveryStatus = `-- name: UpdateDeliveryStatus :exec
UPDATE deliveries SET status = $1, updated_at = NOW() WHERE id = $2
`
type UpdateDeliveryStatusParams struct {
Status string `db:"status" json:"status"`
ID pgtype.UUID `db:"id" json:"id"`
}
func (q *Queries) UpdateDeliveryStatus(ctx context.Context, arg UpdateDeliveryStatusParams) error {
_, err := q.db.Exec(ctx, updateDeliveryStatus, arg.Status, arg.ID)
return err
}

View File

@@ -16,14 +16,23 @@ type Handler struct {
// 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"`
ProductName string `json:"product_name" binding:"required"` PickupLocation2 *string `json:"pickup_location_2" binding:"omitempty,oneof=warehouse symbat nursaya galaktika"`
Address string `json:"address" binding:"required"` ProductName string `json:"product_name" binding:"required"`
Phone string `json:"phone" binding:"required"` ProductName2 *string `json:"product_name_2"`
AdditionalPhone string `json:"additional_phone"` CustomerName string `json:"customer_name" binding:"required"`
HasElevator bool `json:"has_elevator"` Address string `json:"address" binding:"required"`
Comment string `json:"comment"` Street string `json:"street" binding:"required"`
House string `json:"house" binding:"required"`
Apartment *string `json:"apartment"`
Entrance *string `json:"entrance"`
Floor *string `json:"floor"`
Phone string `json:"phone" binding:"required"`
AdditionalPhone *string `json:"additional_phone"`
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) *Handler {
@@ -69,6 +78,17 @@ func (h *Handler) GetDeliveries(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"deliveries": deliveries}) c.JSON(http.StatusOK, gin.H{"deliveries": deliveries})
} }
// GET /api/deliveries/count
func (h *Handler) GetDeliveryCount(c *gin.Context) {
counts, err := h.queries.GetDeliveryCount(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get delivery count", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"counts": counts})
}
// POST /api/deliveries // POST /api/deliveries
func (h *Handler) CreateDelivery(c *gin.Context) { func (h *Handler) CreateDelivery(c *gin.Context) {
var req DeliveryRequest = DeliveryRequest{} var req DeliveryRequest = DeliveryRequest{}
@@ -88,11 +108,20 @@ 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},
ProductName: req.ProductName, ProductName: req.ProductName,
ProductName2: pgtype.Text{String: derefString(req.ProductName2), Valid: req.ProductName2 != nil},
CustomerName: req.CustomerName,
Address: req.Address, Address: req.Address,
Street: req.Street,
House: req.House,
Apartment: pgtype.Text{String: derefString(req.Apartment), Valid: req.Apartment != nil},
Entrance: pgtype.Text{String: derefString(req.Entrance), Valid: req.Entrance != nil},
Floor: pgtype.Text{String: derefString(req.Floor), Valid: req.Floor != nil},
Phone: req.Phone, Phone: req.Phone,
AdditionalPhone: pgtype.Text{String: req.AdditionalPhone, Valid: req.AdditionalPhone != ""}, AdditionalPhone: pgtype.Text{String: derefString(req.AdditionalPhone), Valid: req.AdditionalPhone != nil},
HasElevator: req.HasElevator, HasElevator: req.HasElevator,
ServiceInfo: pgtype.Text{String: derefString(req.ServiceInfo), Valid: req.ServiceInfo != nil},
Comment: pgtype.Text{String: req.Comment, Valid: true}, 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)
@@ -135,11 +164,20 @@ func (h *Handler) UpdateDelivery(c *gin.Context) {
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},
ProductName: req.ProductName, ProductName: req.ProductName,
ProductName2: pgtype.Text{String: derefString(req.ProductName2), Valid: req.ProductName2 != nil},
CustomerName: req.CustomerName,
Address: req.Address, Address: req.Address,
Street: req.Street,
House: req.House,
Apartment: pgtype.Text{String: derefString(req.Apartment), Valid: req.Apartment != nil},
Entrance: pgtype.Text{String: derefString(req.Entrance), Valid: req.Entrance != nil},
Floor: pgtype.Text{String: derefString(req.Floor), Valid: req.Floor != nil},
Phone: req.Phone, Phone: req.Phone,
AdditionalPhone: pgtype.Text{String: req.AdditionalPhone, Valid: req.AdditionalPhone != ""}, AdditionalPhone: pgtype.Text{String: derefString(req.AdditionalPhone), Valid: req.AdditionalPhone != nil},
HasElevator: req.HasElevator, HasElevator: req.HasElevator,
ServiceInfo: pgtype.Text{String: derefString(req.ServiceInfo), Valid: req.ServiceInfo != nil},
Comment: pgtype.Text{String: req.Comment, Valid: true}, 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()})
@@ -149,6 +187,47 @@ func (h *Handler) UpdateDelivery(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Delivery updated"}) c.JSON(http.StatusOK, gin.H{"message": "Delivery updated"})
} }
// PATCH /api/deliveries/:id/status
func (h *Handler) UpdateDeliveryStatus(c *gin.Context) {
var req struct {
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID is required"})
return
}
parsedID, err := uuid.Parse(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid UUID format", "details": err.Error()})
return
}
status := req.Status
if status == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Status is required"})
return
}
if err := h.queries.UpdateDeliveryStatus(c.Request.Context(), sqlc.UpdateDeliveryStatusParams{
ID: pgtype.UUID{Bytes: parsedID, Valid: true},
Status: status,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update delivery status", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Delivery status updated"})
}
// DELETE /api/deliveries/:id // DELETE /api/deliveries/:id
func (h *Handler) DeleteDelivery(c *gin.Context) { func (h *Handler) DeleteDelivery(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
@@ -179,3 +258,11 @@ func parseDate(dateStr string) (time.Time, error) {
} }
return t, nil return t, nil
} }
// derefString safely dereferences a string pointer
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}

66
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,66 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- delivery-network
backend:
image: ${GITEA_REGISTRY}/delivery-tracker/backend:latest
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
JWT_SECRET: ${JWT_SECRET}
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD}
# Нет expose - backend доступен только внутри сети delivery-network
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
networks:
- delivery-network
frontend:
image: ${GITEA_REGISTRY}/delivery-tracker/frontend:latest
ports:
- "80:80"
depends_on:
- backend
restart: unless-stopped
networks:
- delivery-network
watchtower:
image: containrrr/watchtower
environment:
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_POLL_INTERVAL=60
- WATCHTOWER_INCLUDE_STOPPED=true
- WATCHTOWER_REVIVE_STOPPED=false
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: delivery-tracker-backend-1 delivery-tracker-frontend-1 --interval 60
networks:
- delivery-network
volumes:
postgres_data:
networks:
delivery-network:
driver: bridge

View File

@@ -1,4 +1,36 @@
services: services:
# PostgreSQL database
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: egor
POSTGRES_PASSWORD: barsik
POSTGRES_DB: delivery_tracker
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U egor -d delivery_tracker"]
interval: 5s
timeout: 5s
retries: 5
# Backend API
backend:
build:
context: ./backend
dockerfile: Dockerfile
target: production
environment:
DATABASE_URL: postgres://egor:barsik@postgres:5432/delivery_tracker?sslmode=disable
ports:
- "8081:8080"
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
# Development service with hot reload # Development service with hot reload
frontend-dev: frontend-dev:
image: node:20-alpine image: node:20-alpine
@@ -22,7 +54,10 @@ services:
target: production target: production
ports: ports:
- "8080:80" - "8080:80"
depends_on:
- backend
restart: unless-stopped restart: unless-stopped
volumes: volumes:
node_modules: node_modules:
postgres_data:

4
frontend/.env.example Normal file
View File

@@ -0,0 +1,4 @@
# API Configuration
# Leave empty to use proxy (recommended for local dev and production)
# Or set full URL like http://localhost:8081 for direct API access
VITE_API_URL=http://localhost:8080

View File

@@ -16,6 +16,19 @@ server {
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
# Proxy API requests to backend
location /api/ {
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_cache_bypass $http_upgrade;
}
# Handle client-side routing # Handle client-side routing
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;

6902
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,7 @@
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/workbox-window": "^4.3.4",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
@@ -30,6 +31,8 @@
"globals": "^17.4.0", "globals": "^17.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.57.0", "typescript-eslint": "^8.57.0",
"vite": "^8.0.1" "vite": "^8.0.1",
"vite-plugin-pwa": "^1.2.0",
"workbox-window": "^7.4.0"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#1B263B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="3" width="15" height="13"/><polygon points="16 8 20 8 23 11 23 16 16 16"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg>

After

Width:  |  Height:  |  Size: 322 B

View File

@@ -1,17 +1,45 @@
import { useState } from 'react'; import { useState, useEffect, lazy, Suspense } from 'react';
import { Truck } from 'lucide-react'; import { Truck, Loader2, LogOut } from 'lucide-react';
import { Dashboard } from './pages/Dashboard';
import { DeliveryListPage } from './pages/DeliveryListPage';
import { DeliveryForm } from './components/delivery/DeliveryForm'; import { DeliveryForm } from './components/delivery/DeliveryForm';
import { LoginForm } from './components/auth/LoginForm';
import { ToastContainer } from './components/ui/Toast';
import { Button } from './components/ui/Button';
import { useDeliveryStore } from './stores/deliveryStore'; import { useDeliveryStore } from './stores/deliveryStore';
import { useAuthStore } from './stores/authStore';
// Lazy load pages for code splitting
const Dashboard = lazy(() => import('./pages/Dashboard'));
const DeliveryListPage = lazy(() => import('./pages/DeliveryListPage'));
// Fallback loading component
const PageLoader = () => (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-[#1B263B]" />
</div>
);
function App() { function App() {
const [view, setView] = useState<'dashboard' | 'delivery-list'>('dashboard'); const [view, setView] = useState<'dashboard' | 'delivery-list'>('dashboard');
const [selectedDate, setSelectedDate] = useState<string>(''); const [selectedDate, setSelectedDate] = useState<string>('');
const [isFormOpen, setIsFormOpen] = useState(false); const [isFormOpen, setIsFormOpen] = useState(false);
const [formDate, setFormDate] = useState<string>(''); const [formDate, setFormDate] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { isAuthenticated, isAuthChecking, restoreAuth, logout } = useAuthStore();
const addDelivery = useDeliveryStore(state => state.addDelivery); const addDelivery = useDeliveryStore(state => state.addDelivery);
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
// Restore auth on mount
useEffect(() => {
restoreAuth();
}, [restoreAuth]);
// Refresh counts when form closes (only when authenticated)
useEffect(() => {
if (isAuthenticated && !isFormOpen) {
fetchDeliveryCounts();
}
}, [isAuthenticated, isFormOpen, fetchDeliveryCounts]);
const handleDateSelect = (date: string) => { const handleDateSelect = (date: string) => {
setSelectedDate(date); setSelectedDate(date);
@@ -29,16 +57,44 @@ function App() {
setIsFormOpen(true); setIsFormOpen(true);
}; };
const handleFormSubmit = (data: Parameters<typeof addDelivery>[0]) => { const handleFormSubmit = async (data: Parameters<typeof addDelivery>[0]) => {
addDelivery(data); setIsSubmitting(true);
setIsFormOpen(false); try {
await addDelivery(data);
if (data.date !== new Date().toLocaleDateString('ru-RU').split('.').join('-')) { setIsFormOpen(false);
setSelectedDate(data.date);
setView('delivery-list'); // If created for different date, navigate to that date
const today = new Date().toLocaleDateString('ru-RU').split('.').join('-');
if (data.date !== today) {
setSelectedDate(data.date);
setView('delivery-list');
}
} catch {
// Error is handled by store
} finally {
setIsSubmitting(false);
} }
}; };
// Show loading while checking auth
if (isAuthChecking) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#fbf8fb]">
<Loader2 className="w-8 h-8 animate-spin text-[#1B263B]" />
</div>
);
}
// Show login form if not authenticated
if (!isAuthenticated) {
return (
<>
<LoginForm />
<ToastContainer />
</>
);
}
return ( return (
<div className="min-h-screen bg-[#fbf8fb]"> <div className="min-h-screen bg-[#fbf8fb]">
<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">
@@ -50,25 +106,38 @@ function App() {
</div> </div>
<h1 className="text-lg font-semibold hidden sm:block">Delivery Tracker</h1> <h1 className="text-lg font-semibold hidden sm:block">Delivery Tracker</h1>
</div> </div>
<div className="text-sm text-white/70"> <div className="flex items-center gap-4">
{view === 'dashboard' ? 'Панель управления' : `Доставки на ${selectedDate}`} <div className="text-sm text-white/70">
{view === 'dashboard' ? 'Панель управления' : `Доставки на ${selectedDate}`}
</div>
<Button
variant="ghost"
size="sm"
onClick={logout}
className="text-white hover:bg-white/10"
>
<LogOut size={18} className="mr-1" />
Выйти
</Button>
</div> </div>
</div> </div>
</div> </div>
</header> </header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{view === 'dashboard' ? ( <Suspense fallback={<PageLoader />}>
<Dashboard {view === 'dashboard' ? (
onDateSelect={handleDateSelect} <Dashboard
onAddDelivery={handleAddDelivery} onDateSelect={handleDateSelect}
/> onAddDelivery={handleAddDelivery}
) : ( />
<DeliveryListPage ) : (
selectedDate={selectedDate} <DeliveryListPage
onBack={handleBackToDashboard} selectedDate={selectedDate}
/> onBack={handleBackToDashboard}
)} />
)}
</Suspense>
</main> </main>
<DeliveryForm <DeliveryForm
@@ -76,7 +145,10 @@ function App() {
onClose={() => setIsFormOpen(false)} onClose={() => setIsFormOpen(false)}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
defaultDate={formDate} defaultDate={formDate}
isSubmitting={isSubmitting}
/> />
<ToastContainer />
</div> </div>
); );
} }

7
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,7 @@
import { api } from './client';
import type { LoginRequest, LoginResponse } from '../types';
export const authApi = {
login: (credentials: LoginRequest): Promise<LoginResponse> =>
api.post<LoginResponse>('/api/auth/login', credentials),
};

131
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,131 @@
import { useToastStore } from '../stores/toastStore';
const API_BASE_URL = import.meta.env.VITE_API_URL || '';
// Request deduplication cache
const pendingRequests = new Map<string, Promise<unknown>>();
// Abort controllers for cancelling requests
const abortControllers = new Map<string, AbortController>();
// Get token from localStorage
function getAuthToken(): string | null {
return localStorage.getItem('auth_token');
}
// Handle 401 unauthorized
function handleUnauthorized() {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
useToastStore.getState().addToast('Сессия истекла, войдите снова', 'error');
// Reload page to trigger auth check
window.location.reload();
}
export class ApiError extends Error {
status: number;
details?: unknown;
constructor(message: string, status: number, details?: unknown) {
super(message);
this.name = 'ApiError';
this.status = status;
this.details = details;
}
}
function getRequestKey(endpoint: string, method: string, body?: unknown): string {
return `${method}:${endpoint}:${body ? JSON.stringify(body) : ''}`;
}
async function fetchApi<T>(
endpoint: string,
options?: RequestInit & { deduplicate?: boolean }
): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
const method = options?.method || 'GET';
const requestKey = getRequestKey(endpoint, method, options?.body);
// Cancel previous request with same key (for non-GET requests or explicit override)
const shouldCancelPrevious = method !== 'GET' || options?.deduplicate === false;
if (shouldCancelPrevious && abortControllers.has(requestKey)) {
abortControllers.get(requestKey)?.abort();
}
// Deduplicate GET requests
if (method === 'GET' && options?.deduplicate !== false) {
if (pendingRequests.has(requestKey)) {
return pendingRequests.get(requestKey) as Promise<T>;
}
}
// Create new abort controller
const controller = new AbortController();
abortControllers.set(requestKey, controller);
const requestPromise = (async (): Promise<T> => {
try {
const token = getAuthToken();
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` }),
...options?.headers,
},
});
if (!response.ok) {
// Handle 401 unauthorized
if (response.status === 401) {
handleUnauthorized();
throw new ApiError('Unauthorized', 401);
}
const errorData = await response.json().catch(() => null);
throw new ApiError(
errorData?.error || `HTTP ${response.status}`,
response.status,
errorData?.details
);
}
return response.json();
} finally {
pendingRequests.delete(requestKey);
abortControllers.delete(requestKey);
}
})();
if (method === 'GET' && options?.deduplicate !== false) {
pendingRequests.set(requestKey, requestPromise);
}
return requestPromise;
}
export const api = {
get: <T>(endpoint: string, options?: { deduplicate?: boolean }) =>
fetchApi<T>(endpoint, { method: 'GET', ...options }),
post: <T>(endpoint: string, data: unknown) =>
fetchApi<T>(endpoint, {
method: 'POST',
body: JSON.stringify(data),
}),
patch: <T>(endpoint: string, data?: unknown) =>
fetchApi<T>(endpoint, {
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
}),
delete: <T>(endpoint: string) =>
fetchApi<T>(endpoint, { method: 'DELETE' }),
};
// Utility to cancel all pending requests (useful on unmount)
export function cancelAllRequests(): void {
abortControllers.forEach(controller => controller.abort());
abortControllers.clear();
pendingRequests.clear();
}

View File

@@ -0,0 +1,173 @@
import { api } from './client';
import { backendDateToFrontend } from '../utils/date';
import type { Delivery, PickupLocation, DeliveryStatus } from '../types';
// Types matching backend responses
interface BackendDelivery {
id: string;
date: string; // YYYY-MM-DD from pgtype.Date
pickup_location: PickupLocation;
pickup_location_2: PickupLocation | null;
product_name: string;
product_name_2: string | null;
customer_name: string;
address: string;
street: string;
house: string;
apartment: string | null;
entrance: string | null;
floor: string | null;
phone: string;
additional_phone: string | null;
has_elevator: boolean;
service_info: string | null;
comment: string;
status: DeliveryStatus;
created_at: string; // ISO timestamp
updated_at: string; // ISO timestamp
}
interface DeliveryCount {
date: string; // YYYY-MM-DD
count: number;
}
// API Response types
interface GetDeliveriesResponse {
deliveries: BackendDelivery[];
}
interface GetDeliveryResponse {
delivery: BackendDelivery;
}
interface GetDeliveryCountResponse {
counts: DeliveryCount[];
}
interface CreateDeliveryResponse {
message: string;
id: string;
}
interface UpdateDeliveryResponse {
message: string;
}
// Map backend delivery to frontend delivery
function mapBackendToFrontend(backend: BackendDelivery): Delivery {
return {
id: backend.id,
date: backendDateToFrontend(backend.date),
pickupLocation: backend.pickup_location,
pickupLocation2: backend.pickup_location_2 || undefined,
productName: backend.product_name,
productName2: backend.product_name_2 || undefined,
customerName: backend.customer_name,
address: backend.address,
street: backend.street,
house: backend.house,
apartment: backend.apartment || undefined,
entrance: backend.entrance || undefined,
floor: backend.floor || undefined,
phone: backend.phone,
additionalPhone: backend.additional_phone || undefined,
hasElevator: backend.has_elevator,
serviceInfo: backend.service_info || undefined,
comment: backend.comment,
status: backend.status,
createdAt: new Date(backend.created_at).getTime(),
updatedAt: new Date(backend.updated_at).getTime(),
};
}
// Delivery API methods
export const deliveriesApi = {
// Get deliveries by date (DD-MM-YYYY)
getByDate: async (date: string): Promise<Delivery[]> => {
const response = await api.get<GetDeliveriesResponse>(
`/api/deliveries?date=${encodeURIComponent(date)}`
);
return response.deliveries.map(mapBackendToFrontend);
},
// Get single delivery by ID
getById: async (id: string): Promise<Delivery> => {
const response = await api.get<GetDeliveryResponse>(`/api/deliveries/${id}`);
return mapBackendToFrontend(response.delivery);
},
// Get delivery counts by date
getCounts: async (): Promise<Record<string, number>> => {
const response = await api.get<GetDeliveryCountResponse>('/api/deliveries/count');
const counts: Record<string, number> = {};
response.counts.forEach(({ date, count }) => {
counts[backendDateToFrontend(date)] = count;
});
return counts;
},
// Create delivery
create: async (
data: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>
): Promise<string> => {
const payload = {
date: data.date,
pickup_location: data.pickupLocation,
pickup_location_2: data.pickupLocation2 || null,
product_name: data.productName,
product_name_2: data.productName2 || null,
customer_name: data.customerName,
address: data.address,
street: data.street,
house: data.house,
apartment: data.apartment || null,
entrance: data.entrance || null,
floor: data.floor || null,
phone: data.phone,
additional_phone: data.additionalPhone || null,
has_elevator: data.hasElevator,
service_info: data.serviceInfo || null,
comment: data.comment,
};
const response = await api.post<CreateDeliveryResponse>('/api/deliveries', payload);
return response.id;
},
// Update delivery
update: async (
id: string,
data: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>
): Promise<void> => {
const payload = {
date: data.date,
pickup_location: data.pickupLocation,
pickup_location_2: data.pickupLocation2 || null,
product_name: data.productName,
product_name_2: data.productName2 || null,
customer_name: data.customerName,
address: data.address,
street: data.street,
house: data.house,
apartment: data.apartment || null,
entrance: data.entrance || null,
floor: data.floor || null,
phone: data.phone,
additional_phone: data.additionalPhone || null,
has_elevator: data.hasElevator,
service_info: data.serviceInfo || null,
comment: data.comment,
};
await api.patch<UpdateDeliveryResponse>(`/api/deliveries/${id}`, payload);
},
// Update delivery status
updateStatus: async (id: string, status: DeliveryStatus): Promise<void> => {
await api.patch(`/api/deliveries/${id}/status`, { status });
},
// Delete delivery
delete: async (id: string): Promise<void> => {
await api.delete(`/api/deliveries/${id}`);
},
};

View File

@@ -0,0 +1,4 @@
export { api, ApiError, cancelAllRequests } from './client';
export { deliveriesApi } from './deliveries';
export { authApi } from './auth';
export { frontendDateToBackend } from '../utils/date';

View File

@@ -0,0 +1,91 @@
import { useState, type FormEvent } from 'react';
import { Lock, User, Loader2 } from 'lucide-react';
import { Button } from '../ui/Button';
import { Input } from '../ui/Input';
import { useAuthStore } from '../../stores/authStore';
export const LoginForm = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const { login, isLoading } = useAuthStore();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!username.trim() || !password.trim()) return;
try {
await login({ username: username.trim(), password });
} catch {
// Error is handled by store (toast)
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#fbf8fb] p-4">
<div className="w-full max-w-md bg-white rounded-xl shadow-lg p-8">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-[#1B263B] rounded-xl flex items-center justify-center mx-auto mb-4">
<Lock className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-[#1b1b1d]">
Delivery Tracker
</h1>
<p className="text-[#75777d] mt-2">
Войдите в систему
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[#75777d]">
<User size={20} />
</div>
<Input
type="text"
placeholder="Имя пользователя"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
minLength={3}
disabled={isLoading}
className="pl-10"
/>
</div>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[#75777d]">
<Lock size={20} />
</div>
<Input
type="password"
placeholder="Пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
disabled={isLoading}
className="pl-10"
/>
</div>
<Button
type="submit"
variant="primary"
size="lg"
disabled={isLoading}
className="w-full"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Вход...
</>
) : (
'Войти'
)}
</Button>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { LoginForm } from './LoginForm';

View File

@@ -1,9 +1,12 @@
import { MapPin, Phone, Package, Store, Calendar, MessageSquare, CheckCircle2, Circle, CheckSquare } from 'lucide-react'; import { memo } from 'react';
import { MapPin, Phone, Store, Calendar, MessageSquare, CheckCircle2, Circle, CheckSquare, User, Wrench } from 'lucide-react';
import type { Delivery } from '../../types'; import type { Delivery } from '../../types';
import { pickupLocationLabels } from '../../types'; import { pickupLocationLabels } from '../../types';
import { StatusBadge } from './StatusBadge'; import { StatusBadge } from './StatusBadge';
import { Card } from '../ui/Card'; import { Card } from '../ui/Card';
const CITY = 'kokshetau';
interface DeliveryCardProps { interface DeliveryCardProps {
delivery: Delivery; delivery: Delivery;
onStatusChange: (id: string) => void; onStatusChange: (id: string) => void;
@@ -11,10 +14,10 @@ interface DeliveryCardProps {
onDelete: (id: string) => void; onDelete: (id: string) => void;
} }
export const DeliveryCard = ({ delivery, onStatusChange, onEdit, onDelete }: DeliveryCardProps) => { export const DeliveryCard = memo(({ delivery, onStatusChange, onEdit, onDelete }: DeliveryCardProps) => {
const handleAddressClick = () => { const handleAddressClick = () => {
const encodedAddress = encodeURIComponent(delivery.address); const encodedAddress = encodeURIComponent(delivery.address);
window.open(`https://maps.google.com/?q=${encodedAddress}`, '_blank'); window.open(`https://2gis.kz/${CITY}/search/${encodedAddress}`, '_blank');
}; };
const handlePhoneClick = () => { const handlePhoneClick = () => {
@@ -55,14 +58,23 @@ export const DeliveryCard = ({ delivery, onStatusChange, onEdit, onDelete }: Del
<span className="text-[#1b1b1d] font-medium">{delivery.date}</span> <span className="text-[#1b1b1d] font-medium">{delivery.date}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm"> {/* Pickup locations paired with products */}
<Store size={16} className="text-[#75777d]" /> <div className="flex items-start gap-2 text-sm">
<span className="text-[#1b1b1d]">{pickupLocationLabels[delivery.pickupLocation]}</span> <Store size={16} className="text-[#75777d] mt-0.5 shrink-0" />
</div> <div className="flex flex-col gap-1 min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
<div className="flex items-center gap-2 text-sm"> <span className="text-[#1b1b1d] font-medium">{pickupLocationLabels[delivery.pickupLocation]}</span>
<Package size={16} className="text-[#75777d]" /> <span className="text-[#75777d]"></span>
<span className="text-[#1b1b1d]">{delivery.productName}</span> <span className="text-[#1b1b1d]">{delivery.productName}</span>
</div>
{delivery.pickupLocation2 && (
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-[#1b1b1d] font-medium">{pickupLocationLabels[delivery.pickupLocation2]}</span>
<span className="text-[#75777d]"></span>
<span className="text-[#1b1b1d]">{delivery.productName2 || '—'}</span>
</div>
)}
</div>
</div> </div>
<button <button
@@ -70,11 +82,25 @@ export const DeliveryCard = ({ delivery, onStatusChange, onEdit, onDelete }: Del
className="flex items-start gap-2 text-sm w-full text-left hover:bg-[#f5f3f5] -mx-1 px-1 py-0.5 rounded transition-colors" className="flex items-start gap-2 text-sm w-full text-left hover:bg-[#f5f3f5] -mx-1 px-1 py-0.5 rounded transition-colors"
> >
<MapPin size={16} className="text-[#F28C28] mt-0.5 shrink-0" /> <MapPin size={16} className="text-[#F28C28] mt-0.5 shrink-0" />
<span className="text-[#1B263B] underline decoration-[#F28C28]/30 underline-offset-2"> <div className="flex flex-col">
{delivery.address} <span className="text-[#1B263B] underline decoration-[#F28C28]/30 underline-offset-2">
</span> ул. {delivery.street}, д. {delivery.house}{delivery.apartment ? `, кв. ${delivery.apartment}` : ''}
</span>
{(delivery.entrance || delivery.floor) && (
<span className="text-[#75777d] text-xs">
{delivery.entrance && `Подъезд ${delivery.entrance}`}
{delivery.entrance && delivery.floor && ', '}
{delivery.floor && `этаж ${delivery.floor}`}
</span>
)}
</div>
</button> </button>
<div className="flex items-center gap-2 text-sm">
<User size={16} className="text-[#75777d]" />
<span className="text-[#1b1b1d]">{delivery.customerName}</span>
</div>
<button <button
onClick={handlePhoneClick} onClick={handlePhoneClick}
className="flex items-center gap-2 text-sm w-full text-left hover:bg-[#f5f3f5] -mx-1 px-1 py-0.5 rounded transition-colors" className="flex items-center gap-2 text-sm w-full text-left hover:bg-[#f5f3f5] -mx-1 px-1 py-0.5 rounded transition-colors"
@@ -104,6 +130,13 @@ export const DeliveryCard = ({ delivery, onStatusChange, onEdit, onDelete }: Del
</span> </span>
</div> </div>
{delivery.serviceInfo && (
<div className="flex items-start gap-2 text-sm">
<Wrench size={16} className="text-[#F28C28] mt-0.5 shrink-0" />
<span className="text-[#45474d]">{delivery.serviceInfo}</span>
</div>
)}
{delivery.comment && ( {delivery.comment && (
<div className="flex items-start gap-2 text-sm"> <div className="flex items-start gap-2 text-sm">
<MessageSquare size={16} className="text-[#75777d] mt-0.5 shrink-0" /> <MessageSquare size={16} className="text-[#75777d] mt-0.5 shrink-0" />
@@ -132,4 +165,6 @@ export const DeliveryCard = ({ delivery, onStatusChange, onEdit, onDelete }: Del
</div> </div>
</Card> </Card>
); );
}; });
DeliveryCard.displayName = 'DeliveryCard';

View File

@@ -1,82 +1,136 @@
import { useState, useEffect } 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 { formatDateForInput, parseDateFromInput, getTodayFrontend } from '../../utils/date';
import type { Delivery, PickupLocation, DeliveryStatus } from '../../types'; import type { Delivery, PickupLocation, DeliveryStatus } from '../../types';
import { pickupLocationLabels } from '../../types';
interface DeliveryFormProps { interface DeliveryFormProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onSubmit: (delivery: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => void; onSubmit: (delivery: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => void | Promise<void>;
initialData?: Delivery | null; initialData?: Delivery | null;
defaultDate?: string; defaultDate?: string;
isSubmitting?: boolean;
} }
const pickupOptions: { value: PickupLocation; label: string }[] = [ // Phone validation regex for Kazakhstan numbers
{ value: 'warehouse', label: pickupLocationLabels.warehouse }, const PHONE_REGEX = /^\+7\s?\(?\d{3}\)?\s?\d{3}[\s-]?\d{2}[\s-]?\d{2}$/;
{ value: 'symbat', label: pickupLocationLabels.symbat },
{ value: 'nursaya', label: pickupLocationLabels.nursaya },
{ value: 'galaktika', label: pickupLocationLabels.galaktika },
];
export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDate }: DeliveryFormProps) => { // City is not shown in UI but is included in the saved address (used for 2GIS search).
const CITY_LABEL = 'Кокшетау';
const buildAddressString = (
street: string,
house: string,
apartment: string,
entrance: string,
): string => {
const parts: string[] = [CITY_LABEL];
if (street) parts.push(`ул. ${street}`);
if (house) parts.push(`д. ${house}`);
if (apartment) parts.push(`кв. ${apartment}`);
if (entrance) parts.push(`подъезд ${entrance}`);
return parts.join(', ');
};
export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDate, isSubmitting }: DeliveryFormProps) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
date: defaultDate || new Date().toLocaleDateString('ru-RU').split('.').join('-'), date: defaultDate || getTodayFrontend(),
pickupLocation: 'warehouse' as PickupLocation, pickupLocation: 'warehouse' as PickupLocation,
pickupLocation2: null as PickupLocation | null,
productName: '', productName: '',
productName2: '',
customerName: '',
address: '', address: '',
street: '',
house: '',
apartment: '',
entrance: '',
floor: '',
phone: '', phone: '',
additionalPhone: '', additionalPhone: '',
hasElevator: false, hasElevator: false,
serviceInfo: '',
comment: '', comment: '',
status: 'new' as DeliveryStatus, status: 'new' as DeliveryStatus,
}); });
const [showSecondPickup, setShowSecondPickup] = useState(false);
useEffect(() => { useEffect(() => {
if (initialData) { if (initialData) {
setFormData({ setFormData({
date: initialData.date, date: initialData.date,
pickupLocation: initialData.pickupLocation, pickupLocation: initialData.pickupLocation,
pickupLocation2: initialData.pickupLocation2 || null,
productName: initialData.productName, productName: initialData.productName,
productName2: initialData.productName2 || '',
customerName: initialData.customerName,
address: initialData.address, address: initialData.address,
street: initialData.street,
house: initialData.house,
apartment: initialData.apartment || '',
entrance: initialData.entrance || '',
floor: initialData.floor || '',
phone: initialData.phone, phone: initialData.phone,
additionalPhone: initialData.additionalPhone || '', additionalPhone: initialData.additionalPhone || '',
hasElevator: initialData.hasElevator, hasElevator: initialData.hasElevator,
serviceInfo: initialData.serviceInfo || '',
comment: initialData.comment, comment: initialData.comment,
status: initialData.status, status: initialData.status,
}); });
setShowSecondPickup(!!initialData.pickupLocation2);
} else if (defaultDate) { } else if (defaultDate) {
setFormData(prev => ({ ...prev, date: defaultDate })); setFormData(prev => ({ ...prev, date: defaultDate }));
} }
}, [initialData, defaultDate, isOpen]); }, [initialData, defaultDate, isOpen]);
const handleSubmit = (e: React.FormEvent) => { const validatePhone = useCallback((phone: string): boolean => {
if (!phone) return false;
return PHONE_REGEX.test(phone);
}, []);
const isPhoneValid = !formData.phone || validatePhone(formData.phone);
const isAdditionalPhoneValid = !formData.additionalPhone || validatePhone(formData.additionalPhone);
const isFormValid = formData.productName && formData.phone && isPhoneValid && formData.customerName && formData.street && formData.house;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
onSubmit(formData); if (!isFormValid) return;
if (!initialData) { try {
setFormData({ const payload = {
date: defaultDate || new Date().toLocaleDateString('ru-RU').split('.').join('-'), ...formData,
pickupLocation: 'warehouse', address: buildAddressString(formData.street, formData.house, formData.apartment, formData.entrance),
productName: '', };
address: '', await onSubmit(payload);
phone: '', if (!initialData) {
additionalPhone: '', setFormData({
hasElevator: false, date: defaultDate || getTodayFrontend(),
comment: '', pickupLocation: 'warehouse',
status: 'new', pickupLocation2: null,
}); productName: '',
productName2: '',
customerName: '',
address: '',
street: '',
house: '',
apartment: '',
entrance: '',
floor: '',
phone: '',
additionalPhone: '',
hasElevator: false,
serviceInfo: '',
comment: '',
status: 'new',
});
setShowSecondPickup(false);
}
onClose();
} catch {
// Error is handled by parent, keep form open
} }
onClose();
}; };
const formatDateForInput = (dateStr: string) => {
const [day, month, year] = dateStr.split('-');
return `${year}-${month}-${day}`;
};
const formatDateFromInput = (dateStr: string) => {
const [year, month, day] = dateStr.split('-');
return `${day}-${month}-${year}`;
};
return ( return (
<Modal <Modal
@@ -85,11 +139,11 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
title={initialData ? 'Редактировать доставку' : 'Новая доставка'} title={initialData ? 'Редактировать доставку' : 'Новая доставка'}
footer={ footer={
<> <>
<Button variant="ghost" onClick={onClose}> <Button variant="ghost" onClick={onClose} disabled={isSubmitting}>
Отмена Отмена
</Button> </Button>
<Button type="submit" form="delivery-form"> <Button type="submit" form="delivery-form" disabled={isSubmitting || !isFormValid}>
{initialData ? 'Сохранить' : 'Создать'} {isSubmitting ? 'Сохранение...' : initialData ? 'Сохранить' : 'Создать'}
</Button> </Button>
</> </>
} }
@@ -102,7 +156,7 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
<input <input
type="date" type="date"
value={formatDateForInput(formData.date)} value={formatDateForInput(formData.date)}
onChange={(e) => setFormData({ ...formData, date: formatDateFromInput(e.target.value) })} onChange={(e) => setFormData({ ...formData, date: parseDateFromInput(e.target.value) })}
className="w-full px-3 py-2 bg-[#f5f3f5] border border-[#c5c6cd] rounded-md text-[#1b1b1d] focus:outline-none focus:ring-2 focus:ring-[#1B263B] focus:border-transparent transition-colors" className="w-full px-3 py-2 bg-[#f5f3f5] border border-[#c5c6cd] rounded-md text-[#1b1b1d] focus:outline-none focus:ring-2 focus:ring-[#1B263B] focus:border-transparent transition-colors"
required required
/> />
@@ -123,16 +177,70 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
required required
/> />
{/* Address fields */}
<div className="bg-[#f5f3f5] rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-[#1b1b1d]">Адрес доставки</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-[#75777d] mb-1">Улица *</label>
<input
type="text"
value={formData.street}
onChange={(e) => setFormData({ ...formData, street: e.target.value })}
className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm"
required
/>
</div>
<div>
<label className="block text-xs text-[#75777d] mb-1">Дом *</label>
<input
type="text"
value={formData.house}
onChange={(e) => setFormData({ ...formData, house: e.target.value })}
className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm"
required
/>
</div>
<div>
<label className="block text-xs text-[#75777d] mb-1">Квартира</label>
<input
type="text"
value={formData.apartment}
onChange={(e) => setFormData({ ...formData, apartment: e.target.value })}
className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm"
/>
</div>
<div>
<label className="block text-xs text-[#75777d] mb-1">Подъезд</label>
<input
type="text"
value={formData.entrance}
onChange={(e) => setFormData({ ...formData, entrance: e.target.value })}
className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm"
/>
</div>
<div>
<label className="block text-xs text-[#75777d] mb-1">Этаж</label>
<input
type="text"
value={formData.floor}
onChange={(e) => setFormData({ ...formData, floor: e.target.value })}
className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm"
/>
</div>
</div>
</div>
<Input <Input
label="Адрес разгрузки" label="ФИО клиента *"
value={formData.address} value={formData.customerName}
onChange={(e) => setFormData({ ...formData, address: e.target.value })} onChange={(e) => setFormData({ ...formData, customerName: e.target.value })}
placeholder="ул. Примерная, д. 1" placeholder="Иванов Иван Иванович"
required required
/> />
<Input <Input
label="Телефон покупателя" label="Телефон покупателя *"
type="tel" type="tel"
value={formData.phone} value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })} onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
@@ -143,7 +251,14 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
}} }}
placeholder="+7 (776)-567-89-01" placeholder="+7 (776)-567-89-01"
required required
aria-invalid={!isPhoneValid}
aria-describedby={!isPhoneValid ? 'phone-error' : undefined}
/> />
{!isPhoneValid && formData.phone && (
<p id="phone-error" className="text-sm text-red-500 mt-1">
Введите корректный номер: +7 (XXX) XXX-XX-XX
</p>
)}
<Input <Input
label="Дополнительный номер телефона" label="Дополнительный номер телефона"
@@ -156,7 +271,51 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
} }
}} }}
placeholder="+7 (776)-567-89-01" placeholder="+7 (776)-567-89-01"
aria-invalid={!isAdditionalPhoneValid}
aria-describedby={!isAdditionalPhoneValid ? 'additional-phone-error' : undefined}
/> />
{!isAdditionalPhoneValid && formData.additionalPhone && (
<p id="additional-phone-error" className="text-sm text-red-500 mt-1">
Введите корректный номер: +7 (XXX) XXX-XX-XX
</p>
)}
{/* Second pickup location */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="hasSecondPickup"
checked={showSecondPickup}
onChange={(e) => {
setShowSecondPickup(e.target.checked);
if (!e.target.checked) {
setFormData({ ...formData, pickupLocation2: null, productName2: '' });
}
}}
className="w-4 h-4 text-[#1B263B] border-[#c5c6cd] rounded focus:ring-[#1B263B]"
/>
<label htmlFor="hasSecondPickup" className="text-sm text-[#1b1b1d]">
Добавить вторую точку загрузки
</label>
</div>
{showSecondPickup && (
<div className="bg-[#f5f3f5] rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-[#1b1b1d]">Вторая точка загрузки</p>
<Select
label="Место загрузки 2"
value={formData.pickupLocation2 || ''}
onChange={(e) => setFormData({ ...formData, pickupLocation2: e.target.value as PickupLocation })}
options={pickupOptions}
/>
<Input
label="Название товара 2"
value={formData.productName2}
onChange={(e) => setFormData({ ...formData, productName2: e.target.value })}
placeholder="Название товара со второй точки"
/>
</div>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
@@ -171,6 +330,13 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
</label> </label>
</div> </div>
<Input
label="Услуги (сборка, подъём)"
value={formData.serviceInfo}
onChange={(e) => setFormData({ ...formData, serviceInfo: e.target.value })}
placeholder="Сборка 5000 тг, подъём на этаж 3000 тг"
/>
<Input <Input
label="Комментарий" label="Комментарий"
value={formData.comment} value={formData.comment}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { Plus, LayoutGrid, Table as TableIcon } from 'lucide-react'; import { Plus, LayoutGrid, Table as TableIcon } from 'lucide-react';
import { DeliveryCard } from './DeliveryCard'; import { DeliveryCard } from './DeliveryCard';
import { DeliveryRow } from './DeliveryRow'; import { DeliveryRow } from './DeliveryRow';
@@ -17,8 +17,8 @@ interface DeliveryListProps {
export const DeliveryList = ({ deliveries, onStatusChange, onEdit, onDelete, onAdd, date }: DeliveryListProps) => { export const DeliveryList = ({ deliveries, onStatusChange, onEdit, onDelete, onAdd, date }: DeliveryListProps) => {
const [viewMode, setViewMode] = useState<'kanban' | 'table'>('kanban'); const [viewMode, setViewMode] = useState<'kanban' | 'table'>('kanban');
const newDeliveries = deliveries.filter(d => d.status === 'new'); const newDeliveries = useMemo(() => deliveries.filter(d => d.status === 'new'), [deliveries]);
const deliveredDeliveries = deliveries.filter(d => d.status === 'delivered'); const deliveredDeliveries = useMemo(() => deliveries.filter(d => d.status === 'delivered'), [deliveries]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -114,6 +114,7 @@ export const DeliveryList = ({ deliveries, onStatusChange, onEdit, onDelete, onA
<th className="px-4 py-3">Дата</th> <th className="px-4 py-3">Дата</th>
<th className="px-4 py-3">Загрузка</th> <th className="px-4 py-3">Загрузка</th>
<th className="px-4 py-3">Товар</th> <th className="px-4 py-3">Товар</th>
<th className="px-4 py-3">Клиент</th>
<th className="px-4 py-3">Адрес</th> <th className="px-4 py-3">Адрес</th>
<th className="px-4 py-3">Телефон</th> <th className="px-4 py-3">Телефон</th>
<th className="px-4 py-3">Комментарий</th> <th className="px-4 py-3">Комментарий</th>

View File

@@ -1,8 +1,11 @@
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 { pickupLocationLabels } from '../../types';
import { StatusBadge } from './StatusBadge'; import { StatusBadge } from './StatusBadge';
const CITY = 'kokshetau';
interface DeliveryRowProps { interface DeliveryRowProps {
delivery: Delivery; delivery: Delivery;
onStatusChange: (id: string) => void; onStatusChange: (id: string) => void;
@@ -10,11 +13,11 @@ interface DeliveryRowProps {
onDelete: (id: string) => void; onDelete: (id: string) => void;
} }
export const DeliveryRow = ({ delivery, onStatusChange, onEdit, onDelete }: DeliveryRowProps) => { export const DeliveryRow = memo(({ delivery, onStatusChange, onEdit, onDelete }: DeliveryRowProps) => {
const handleAddressClick = (e: React.MouseEvent) => { const handleAddressClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
const encodedAddress = encodeURIComponent(delivery.address); const encodedAddress = encodeURIComponent(delivery.address);
window.open(`https://maps.google.com/?q=${encodedAddress}`, '_blank'); window.open(`https://2gis.kz/${CITY}/search/${encodedAddress}`, '_blank');
}; };
const handlePhoneClick = (e: React.MouseEvent) => { const handlePhoneClick = (e: React.MouseEvent) => {
@@ -32,15 +35,25 @@ export const DeliveryRow = ({ delivery, onStatusChange, onEdit, onDelete }: Deli
/> />
</td> </td>
<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]">{pickupLocationLabels[delivery.pickupLocation]}</td> <td className="px-4 py-3 text-sm text-[#1b1b1d]">
<td className="px-4 py-3 text-sm text-[#1b1b1d]">{delivery.productName}</td> {delivery.pickupLocation2
? `${pickupLocationLabels[delivery.pickupLocation]} + ${pickupLocationLabels[delivery.pickupLocation2]}`
: pickupLocationLabels[delivery.pickupLocation]}
</td>
<td className="px-4 py-3 text-sm text-[#1b1b1d]">
{delivery.productName}
{delivery.productName2 && <span className="block text-xs text-[#75777d]">+ {delivery.productName2}</span>}
</td>
<td className="px-4 py-3 text-sm text-[#1b1b1d]">{delivery.customerName}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<button <button
onClick={handleAddressClick} onClick={handleAddressClick}
className="flex items-center gap-1.5 text-sm text-[#1B263B] hover:text-[#F28C28] transition-colors text-left" className="flex items-center gap-1.5 text-sm text-[#1B263B] hover:text-[#F28C28] transition-colors text-left"
> >
<MapPin size={14} /> <MapPin size={14} />
<span className="max-w-[200px] truncate">{delivery.address}</span> <span className="max-w-[200px] truncate">
ул. {delivery.street}, д. {delivery.house}{delivery.apartment ? `, кв. ${delivery.apartment}` : ''}
</span>
</button> </button>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -75,4 +88,6 @@ export const DeliveryRow = ({ delivery, onStatusChange, onEdit, onDelete }: Deli
</td> </td>
</tr> </tr>
); );
}; });
DeliveryRow.displayName = 'DeliveryRow';

View File

@@ -0,0 +1,45 @@
import { useToastStore } from '../../stores/toastStore';
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
const icons = {
success: CheckCircle,
error: AlertCircle,
info: Info,
};
const styles = {
success: 'bg-green-50 border-green-200 text-green-800',
error: 'bg-red-50 border-red-200 text-red-800',
info: 'bg-blue-50 border-blue-200 text-blue-800',
};
export const ToastContainer = () => {
const { toasts, removeToast } = useToastStore();
if (toasts.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => {
const Icon = icons[toast.type];
return (
<div
key={toast.id}
className={`flex items-center gap-3 px-4 py-3 rounded-lg border shadow-lg min-w-[300px] animate-in slide-in-from-right ${styles[toast.type]}`}
role="alert"
>
<Icon size={20} />
<p className="flex-1 text-sm">{toast.message}</p>
<button
onClick={() => removeToast(toast.id)}
className="p-1 hover:bg-black/5 rounded transition-colors"
aria-label="Закрыть"
>
<X size={16} />
</button>
</div>
);
})}
</div>
);
};

View File

@@ -0,0 +1,14 @@
import type { PickupLocation } from '../types';
import { pickupLocationLabels } from '../types';
export const pickupOptions: { value: PickupLocation; label: string }[] = [
{ value: 'warehouse', label: pickupLocationLabels.warehouse },
{ value: 'symbat', label: pickupLocationLabels.symbat },
{ value: 'nursaya', label: pickupLocationLabels.nursaya },
{ value: 'galaktika', label: pickupLocationLabels.galaktika },
];
export const pickupFilterOptions: { value: PickupLocation | 'all'; label: string }[] = [
{ value: 'all', label: 'Все места загрузки' },
...pickupOptions,
];

View File

@@ -2,15 +2,16 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { mockDeliveries } from './utils/mockData'
import { useDeliveryStore } from './stores/deliveryStore'
// Seed mock data if no data exists if ('serviceWorker' in navigator) {
const stored = localStorage.getItem('delivery-tracker-data') window.addEventListener('load', () => {
if (!stored) { navigator.serviceWorker.register('/sw.js', { scope: '/' })
const store = useDeliveryStore.getState() .then((registration) => {
mockDeliveries.forEach(delivery => { console.log('SW registered:', registration)
store.addDelivery(delivery) })
.catch((error) => {
console.log('SW registration failed:', error)
})
}) })
} }

View File

@@ -1,8 +1,10 @@
import { useState } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { Plus, Printer, ChevronRight, CalendarDays } from 'lucide-react'; import { Plus, Printer, ChevronRight, CalendarDays } from 'lucide-react';
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isToday } from 'date-fns'; import { format, startOfMonth, endOfMonth, eachDayOfInterval, isToday, getDay } from 'date-fns';
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 { pickupLocationLabels } 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';
@@ -11,23 +13,40 @@ interface DashboardProps {
onAddDelivery: () => void; onAddDelivery: () => void;
} }
export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => { const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
const deliveries = useDeliveryStore(state => state.deliveries); const deliveryCounts = useDeliveryStore(state => state.deliveryCounts);
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
const [currentMonth, setCurrentMonth] = useState(new Date()); const [currentMonth, setCurrentMonth] = useState(new Date());
const monthStart = startOfMonth(currentMonth); // Fetch counts on mount
const monthEnd = endOfMonth(currentMonth); useEffect(() => {
const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); fetchDeliveryCounts();
}, [fetchDeliveryCounts]);
const days = useMemo(() => {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
return eachDayOfInterval({ start: monthStart, end: monthEnd });
}, [currentMonth]);
const getCountForDate = (date: Date) => { const getCountForDate = (date: Date) => {
const dateStr = format(date, 'dd-MM-yyyy'); const dateStr = format(date, 'dd-MM-yyyy');
return deliveries.filter(d => d.date === dateStr).length; return deliveryCounts[dateStr] || 0;
}; };
const handlePrintDay = (date: Date) => { const handlePrintDay = (date: Date) => {
const dateStr = format(date, 'dd-MM-yyyy'); const dateStr = format(date, 'dd-MM-yyyy');
const dayDeliveries = deliveries.filter(d => d.date === dateStr); const fetchDeliveriesByDate = useDeliveryStore.getState().fetchDeliveriesByDate;
// Fetch and print
fetchDeliveriesByDate(dateStr).then(() => {
const deliveries = useDeliveryStore.getState().deliveries;
printDeliveries(date, deliveries);
});
};
const printDeliveries = (date: Date, dayDeliveries: Delivery[]) => {
const printWindow = window.open('', '_blank'); const printWindow = window.open('', '_blank');
if (!printWindow) return; if (!printWindow) return;
@@ -39,31 +58,35 @@ export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
<style> <style>
body { font-family: system-ui, sans-serif; margin: 20px; } body { font-family: system-ui, sans-serif; margin: 20px; }
h1 { font-size: 18px; margin-bottom: 16px; } h1 { font-size: 18px; margin-bottom: 16px; }
table { width: 100%; border-collapse: collapse; } table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #ddd; } th, td { text-align: left; padding: 6px; border-bottom: 1px solid #ddd; }
th { font-weight: 600; background: #f5f5f5; } th { font-weight: 600; background: #f5f5f5; }
.status-new { background: #ffdcc3; padding: 2px 8px; border-radius: 12px; font-size: 12px; } .address-details { font-size: 11px; color: #666; }
.status-delivered { background: #dcfce7; padding: 2px 8px; border-radius: 12px; font-size: 12px; }
</style> </style>
</head> </head>
<body> <body>
<h1>Доставки на ${format(date, 'dd MMMM yyyy', { locale: ru })}</h1> <h1>Доставки на ${format(date, 'dd MMMM yyyy', { locale: ru })}</h1>
<table> <table>
<tr> <tr>
<th>Статус</th>
<th>Загрузка</th> <th>Загрузка</th>
<th>Товар</th> <th>Товар</th>
<th>Клиент</th>
<th>Адрес</th> <th>Адрес</th>
<th>Телефон</th> <th>Телефон</th>
<th>Услуги</th>
<th>Комментарий</th> <th>Комментарий</th>
</tr> </tr>
${dayDeliveries.map(d => ` ${dayDeliveries.map((d: Delivery) => `
<tr> <tr>
<td><span class="status-${d.status}">${d.status === 'new' ? 'Новое' : 'Доставлено'}</span></td> <td>${d.pickupLocation2 ? pickupLocationLabels[d.pickupLocation] + ' + ' + pickupLocationLabels[d.pickupLocation2] : pickupLocationLabels[d.pickupLocation]}</td>
<td>${d.pickupLocation === 'warehouse' ? 'Склад' : d.pickupLocation === 'symbat' ? 'Сымбат' : d.pickupLocation === 'nursaya' ? 'Нурсая' : 'Галактика'}</td> <td>${d.productName}${d.productName2 ? '<br><small>+ ' + d.productName2 + '</small>' : ''}</td>
<td>${d.productName}</td> <td>${d.customerName}</td>
<td>${d.address}</td> <td>
ул. ${d.street}, д. ${d.house}${d.apartment ? ', кв. ' + d.apartment : ''}
${d.entrance || d.floor ? '<br><span class="address-details">' + (d.entrance ? 'Подъезд ' + d.entrance : '') + (d.entrance && d.floor ? ', ' : '') + (d.floor ? 'этаж ' + d.floor : '') + '</span>' : ''}
</td>
<td>${d.phone}</td> <td>${d.phone}</td>
<td>${d.serviceInfo || '-'}</td>
<td>${d.comment || '-'}</td> <td>${d.comment || '-'}</td>
</tr> </tr>
`).join('')} `).join('')}
@@ -71,10 +94,10 @@ export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
</body> </body>
</html> </html>
`; `;
printWindow.document.write(html); printWindow.document.write(html);
printWindow.document.close(); printWindow.document.close();
printWindow.print(); printWindow?.print();
}; };
const navigateMonth = (direction: 'prev' | 'next') => { const navigateMonth = (direction: 'prev' | 'next') => {
@@ -102,7 +125,7 @@ export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-[#1b1b1d] flex items-center gap-2"> <h2 className="text-lg font-semibold text-[#1b1b1d] flex items-center gap-2">
<CalendarDays size={20} className="text-[#1B263B]" /> <CalendarDays size={20} className="text-[#1B263B]" />
{format(currentMonth, 'MMMM yyyy', { locale: ru })} {format(currentMonth, 'LLLL yyyy', { locale: ru })}
</h2> </h2>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => navigateMonth('prev')}> <Button variant="ghost" size="sm" onClick={() => navigateMonth('prev')}>
@@ -130,6 +153,9 @@ export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
</div> </div>
<div className="grid grid-cols-7 gap-1"> <div className="grid grid-cols-7 gap-1">
{Array.from({ length: (getDay(startOfMonth(currentMonth)) + 6) % 7 }).map((_, i) => (
<div key={`empty-${i}`} className="p-3 min-h-[80px]" />
))}
{days.map((day) => { {days.map((day) => {
const count = getCountForDate(day); const count = getCountForDate(day);
const isTodayDate = isToday(day); const isTodayDate = isToday(day);
@@ -218,3 +244,5 @@ export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
</div> </div>
); );
}; };
export default Dashboard;

View File

@@ -1,44 +1,51 @@
import { useState } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { ArrowLeft, Filter } from 'lucide-react'; import { ArrowLeft, Filter, Loader2, AlertCircle } from 'lucide-react';
import { useDeliveryStore } from '../stores/deliveryStore'; import { useDeliveryStore } from '../stores/deliveryStore';
import { DeliveryList as DeliveryListComponent } from '../components/delivery/DeliveryList'; import { DeliveryList as DeliveryListComponent } from '../components/delivery/DeliveryList';
import { DeliveryForm } from '../components/delivery/DeliveryForm'; import { DeliveryForm } from '../components/delivery/DeliveryForm';
import { Button } from '../components/ui/Button'; import { Button } from '../components/ui/Button';
import { Select } from '../components/ui/Select'; import { Select } from '../components/ui/Select';
import { pickupFilterOptions } from '../constants/pickup';
import type { Delivery, PickupLocation } from '../types'; import type { Delivery, PickupLocation } from '../types';
import { pickupLocationLabels } from '../types';
interface DeliveryListPageProps { interface DeliveryListPageProps {
selectedDate: string; selectedDate: string;
onBack: () => void; onBack: () => void;
} }
export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps) => { const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps) => {
const deliveries = useDeliveryStore(state => state.deliveries); const {
const toggleStatus = useDeliveryStore(state => state.toggleStatus); deliveries,
const deleteDelivery = useDeliveryStore(state => state.deleteDelivery); isLoading,
const updateDelivery = useDeliveryStore(state => state.updateDelivery); error,
const addDelivery = useDeliveryStore(state => state.addDelivery); fetchDeliveriesByDate,
toggleStatus,
deleteDelivery,
updateDelivery,
addDelivery,
clearError,
} = useDeliveryStore();
// Fetch deliveries when date changes
useEffect(() => {
fetchDeliveriesByDate(selectedDate);
}, [selectedDate, fetchDeliveriesByDate]);
const [isFormOpen, setIsFormOpen] = useState(false); const [isFormOpen, setIsFormOpen] = useState(false);
const [editingDelivery, setEditingDelivery] = useState<Delivery | null>(null); const [editingDelivery, setEditingDelivery] = useState<Delivery | null>(null);
const [pickupFilter, setPickupFilter] = useState<PickupLocation | 'all'>('all'); const [pickupFilter, setPickupFilter] = useState<PickupLocation | 'all'>('all');
const dayDeliveries = deliveries.filter(d => d.date === selectedDate); // Use all deliveries from store (already filtered by API)
const filteredDeliveries = pickupFilter === 'all' const filteredDeliveries = useMemo(() => {
? dayDeliveries if (pickupFilter === 'all') return deliveries;
: dayDeliveries.filter(d => d.pickupLocation === pickupFilter); return deliveries.filter(d => d.pickupLocation === pickupFilter);
}, [deliveries, pickupFilter]);
const pickupOptions: { value: PickupLocation | 'all'; label: string }[] = [ const handleStatusChange = async (id: string) => {
{ value: 'all', label: 'Все места загрузки' }, const delivery = deliveries.find(d => d.id === id);
{ value: 'warehouse', label: pickupLocationLabels.warehouse }, if (delivery) {
{ value: 'symbat', label: pickupLocationLabels.symbat }, await toggleStatus(id, delivery.status);
{ value: 'nursaya', label: pickupLocationLabels.nursaya }, }
{ value: 'galaktika', label: pickupLocationLabels.galaktika },
];
const handleStatusChange = (id: string) => {
toggleStatus(id);
}; };
const handleEdit = (delivery: Delivery) => { const handleEdit = (delivery: Delivery) => {
@@ -46,19 +53,27 @@ export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps
setIsFormOpen(true); setIsFormOpen(true);
}; };
const handleDelete = (id: string) => { const handleDelete = async (id: string) => {
if (confirm('Удалить эту доставку?')) { if (confirm('Удалить эту доставку?')) {
deleteDelivery(id); try {
await deleteDelivery(id);
} catch {
// Error is handled by store
}
} }
}; };
const handleSubmit = (data: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => { const handleSubmit = async (data: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => {
if (editingDelivery) { try {
updateDelivery(editingDelivery.id, data); if (editingDelivery) {
} else { await updateDelivery(editingDelivery.id, data);
addDelivery(data); } else {
await addDelivery(data);
}
setEditingDelivery(null);
} catch {
// Error is handled by store
} }
setEditingDelivery(null);
}; };
const handleAdd = () => { const handleAdd = () => {
@@ -89,19 +104,35 @@ export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps
label="" label=""
value={pickupFilter} value={pickupFilter}
onChange={(e) => setPickupFilter(e.target.value as PickupLocation | 'all')} onChange={(e) => setPickupFilter(e.target.value as PickupLocation | 'all')}
options={pickupOptions} options={pickupFilterOptions}
/> />
</div> </div>
</div> </div>
<DeliveryListComponent {isLoading ? (
deliveries={filteredDeliveries} <div className="flex items-center justify-center py-12">
onStatusChange={handleStatusChange} <Loader2 className="w-8 h-8 animate-spin text-[#1B263B]" />
onEdit={handleEdit} </div>
onDelete={handleDelete} ) : error ? (
onAdd={handleAdd} <div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
date={selectedDate} <AlertCircle className="w-5 h-5 text-red-500" />
/> <div className="flex-1">
<p className="text-red-700">{error}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => { clearError(); fetchDeliveriesByDate(selectedDate); }}>
Повторить
</Button>
</div>
) : (
<DeliveryListComponent
deliveries={filteredDeliveries}
onStatusChange={handleStatusChange}
onEdit={handleEdit}
onDelete={handleDelete}
onAdd={handleAdd}
date={selectedDate}
/>
)}
<DeliveryForm <DeliveryForm
isOpen={isFormOpen} isOpen={isFormOpen}
@@ -113,3 +144,5 @@ export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps
</div> </div>
); );
}; };
export default DeliveryListPage;

View File

@@ -0,0 +1,79 @@
import { create } from 'zustand';
import { authApi } from '../api/auth';
import { useToastStore } from './toastStore';
import type { User, LoginRequest } from '../types';
interface AuthState {
token: string | null;
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
isAuthChecking: boolean;
login: (credentials: LoginRequest) => Promise<void>;
logout: () => void;
restoreAuth: () => void;
}
const TOKEN_KEY = 'auth_token';
const USER_KEY = 'auth_user';
export const useAuthStore = create<AuthState>((set) => ({
token: null,
user: null,
isAuthenticated: false,
isLoading: false,
isAuthChecking: true,
login: async (credentials: LoginRequest) => {
set({ isLoading: true });
try {
const response = await authApi.login(credentials);
const token = response.token;
// Extract user info from token payload (JWT)
const payload = JSON.parse(atob(token.split('.')[1]));
const user: User = {
id: payload.sub || '',
username: credentials.username,
};
// Save to localStorage
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(user));
set({ token, user, isAuthenticated: true, isLoading: false });
useToastStore.getState().addToast('Вход выполнен успешно', 'success');
} catch (error) {
set({ isLoading: false });
const message = error instanceof Error ? error.message : 'Ошибка входа';
useToastStore.getState().addToast(message, 'error');
throw error;
}
},
logout: () => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
set({ token: null, user: null, isAuthenticated: false });
useToastStore.getState().addToast('Вы вышли из системы', 'info');
},
restoreAuth: () => {
const token = localStorage.getItem(TOKEN_KEY);
const userJson = localStorage.getItem(USER_KEY);
if (token && userJson) {
try {
const user = JSON.parse(userJson) as User;
set({ token, user, isAuthenticated: true, isAuthChecking: false });
} catch {
// Invalid stored data, clear it
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
set({ isAuthChecking: false });
}
} else {
set({ isAuthChecking: false });
}
},
}));

View File

@@ -1,83 +1,177 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { deliveriesApi } from '../api';
import type { Delivery } from '../types'; import { useToastStore } from './toastStore';
import type { Delivery, DeliveryStatus } from '../types';
interface DeliveryState { interface DeliveryState {
// Data
deliveries: Delivery[]; deliveries: Delivery[];
addDelivery: (delivery: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => void; deliveryCounts: Record<string, number>;
updateDelivery: (id: string, updates: Partial<Delivery>) => void;
deleteDelivery: (id: string) => void; // Loading states
toggleStatus: (id: string) => void; isLoading: boolean;
isLoadingCounts: boolean;
error: string | null;
// Actions
fetchDeliveriesByDate: (date: string) => Promise<void>;
fetchDeliveryCounts: () => Promise<void>;
addDelivery: (delivery: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => Promise<void>;
updateDelivery: (id: string, updates: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => Promise<void>;
deleteDelivery: (id: string) => Promise<void>;
toggleStatus: (id: string, currentStatus: DeliveryStatus) => Promise<void>;
getDeliveriesByDate: (date: string) => Delivery[]; getDeliveriesByDate: (date: string) => Delivery[];
getDeliveriesByDateRange: (startDate: string, endDate: string) => Delivery[]; getDeliveriesByDateRange: (startDate: string, endDate: string) => Delivery[];
getDeliveryCountsByDate: () => Record<string, number>; getDeliveryCountsByDate: () => Record<string, number>;
clearError: () => void;
} }
const STORAGE_KEY = 'delivery-tracker-data'; export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
// Initial state
deliveries: [],
deliveryCounts: {},
isLoading: false,
isLoadingCounts: false,
error: null,
export const useDeliveryStore = create<DeliveryState>()( // Fetch deliveries for a specific date
persist( fetchDeliveriesByDate: async (date: string) => {
(set, get) => ({ set({ isLoading: true, error: null });
deliveries: [], try {
const deliveries = await deliveriesApi.getByDate(date);
addDelivery: (delivery) => { set({ deliveries, isLoading: false });
const now = Date.now(); } catch (err) {
const newDelivery: Delivery = { const message = err instanceof Error ? err.message : 'Failed to fetch deliveries';
...delivery, set({
id: crypto.randomUUID(), error: message,
createdAt: now, isLoading: false,
updatedAt: now, });
}; useToastStore.getState().addToast(message, 'error');
set((state) => ({
deliveries: [...state.deliveries, newDelivery],
}));
},
updateDelivery: (id, updates) => {
set((state) => ({
deliveries: state.deliveries.map((d) =>
d.id === id ? { ...d, ...updates, updatedAt: Date.now() } : d
),
}));
},
deleteDelivery: (id) => {
set((state) => ({
deliveries: state.deliveries.filter((d) => d.id !== id),
}));
},
toggleStatus: (id) => {
set((state) => ({
deliveries: state.deliveries.map((d) =>
d.id === id
? { ...d, status: d.status === 'new' ? 'delivered' : 'new', updatedAt: Date.now() }
: d
),
}));
},
getDeliveriesByDate: (date) => {
return get().deliveries.filter((d) => d.date === date);
},
getDeliveriesByDateRange: (startDate, endDate) => {
return get().deliveries.filter((d) => {
const date = d.date;
return date >= startDate && date <= endDate;
});
},
getDeliveryCountsByDate: () => {
const counts: Record<string, number> = {};
get().deliveries.forEach((d) => {
counts[d.date] = (counts[d.date] || 0) + 1;
});
return counts;
},
}),
{
name: STORAGE_KEY,
} }
) },
);
// Fetch delivery counts for calendar
fetchDeliveryCounts: async () => {
set({ isLoadingCounts: true, error: null });
try {
const counts = await deliveriesApi.getCounts();
set({ deliveryCounts: counts, isLoadingCounts: false });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch counts';
set({
error: message,
isLoadingCounts: false,
});
useToastStore.getState().addToast(message, 'error');
}
},
// Add new delivery
addDelivery: async (delivery) => {
set({ isLoading: true, error: null });
try {
await deliveriesApi.create(delivery);
// Refresh deliveries for that date
await get().fetchDeliveriesByDate(delivery.date);
// Refresh counts
await get().fetchDeliveryCounts();
set({ isLoading: false });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create delivery';
set({
error: message,
isLoading: false,
});
useToastStore.getState().addToast(message, 'error');
throw err;
}
},
// Update delivery
updateDelivery: async (id, updates) => {
set({ isLoading: true, error: null });
try {
await deliveriesApi.update(id, updates);
// Refresh deliveries for that date
await get().fetchDeliveriesByDate(updates.date);
// Refresh counts (in case date changed)
await get().fetchDeliveryCounts();
set({ isLoading: false });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update delivery';
set({
error: message,
isLoading: false,
});
useToastStore.getState().addToast(message, 'error');
throw err;
}
},
// Delete delivery
deleteDelivery: async (id) => {
set({ isLoading: true, error: null });
try {
await deliveriesApi.delete(id);
// Remove from local state
set((state) => ({
deliveries: state.deliveries.filter((d) => d.id !== id),
isLoading: false,
}));
// Refresh counts
await get().fetchDeliveryCounts();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete delivery';
set({
error: message,
isLoading: false,
});
useToastStore.getState().addToast(message, 'error');
throw err;
}
},
// Toggle delivery status
toggleStatus: async (id, currentStatus) => {
set({ isLoading: true, error: null });
try {
const newStatus = currentStatus === 'new' ? 'delivered' : 'new';
await deliveriesApi.updateStatus(id, newStatus);
// Update local state
set((state) => ({
deliveries: state.deliveries.map((d) =>
d.id === id
? { ...d, status: newStatus, updatedAt: Date.now() }
: d
),
isLoading: false,
}));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update status';
set({
error: message,
isLoading: false,
});
useToastStore.getState().addToast(message, 'error');
throw err;
}
},
// Getters (local filtering)
getDeliveriesByDate: (date) => {
return get().deliveries.filter((d) => d.date === date);
},
getDeliveriesByDateRange: (startDate, endDate) => {
return get().deliveries.filter((d) => {
const date = d.date;
return date >= startDate && date <= endDate;
});
},
getDeliveryCountsByDate: () => {
return get().deliveryCounts;
},
clearError: () => set({ error: null }),
}));

View File

@@ -0,0 +1,35 @@
import { create } from 'zustand';
export type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: string;
message: string;
type: ToastType;
}
interface ToastState {
toasts: Toast[];
addToast: (message: string, type: ToastType) => void;
removeToast: (id: string) => void;
}
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
addToast: (message, type) => {
const id = Math.random().toString(36).substring(2, 9);
set((state) => ({
toasts: [...state.toasts, { id, message, type }],
}));
// Auto remove after 5 seconds
setTimeout(() => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}));
}, 5000);
},
removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
})),
}));

View File

@@ -5,10 +5,19 @@ export interface Delivery {
id: string; id: string;
date: string; // DD-MM-YYYY date: string; // DD-MM-YYYY
pickupLocation: PickupLocation; pickupLocation: PickupLocation;
pickupLocation2?: PickupLocation | null;
productName: string; productName: string;
address: string; productName2?: string | null;
customerName: string;
phone: string; phone: string;
additionalPhone?: string; additionalPhone?: string;
address: string; // full address for compatibility
street: string;
house: string;
apartment?: string;
entrance?: string;
floor?: string;
serviceInfo?: string;
hasElevator: boolean; hasElevator: boolean;
comment: string; comment: string;
status: DeliveryStatus; status: DeliveryStatus;
@@ -28,3 +37,17 @@ export const statusLabels: Record<DeliveryStatus, string> = {
new: 'Новое', new: 'Новое',
delivered: 'Доставлено', delivered: 'Доставлено',
}; };
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
token: string;
}
export interface User {
id: string;
username: string;
}

View File

@@ -0,0 +1,122 @@
export interface ParsedAddress {
street: string;
house: string;
apartment: string;
entrance: string;
floor: string;
remaining: string; // unrecognized parts
}
// Common Russian/Kazakh address patterns
const STREET_PREFIXES = ['ул\\.', 'ул', 'пр\\.', 'пр', 'пр-т', 'бульвар', 'пер\\.', 'пер', 'ш\\.', 'шоссе', 'тракт'];
const HOUSE_PATTERNS = ['д\\.', 'дом', 'д(?=\\s*\\d)', 'строение', 'стр\\.'];
const APARTMENT_PATTERNS = ['кв\\.', 'квартира', 'кв(?=\\s*\\d)', 'офис', 'оф\\.'];
const ENTRANCE_PATTERNS = ['подъезд', 'под\\.', 'под(?=\\s*\\d)', 'п(?=\\s*\\d)'];
const FLOOR_PATTERNS = ['этаж', 'эт\\.', 'эт(?=\\s*\\d)', 'э(?=\\s*\\d)'];
function createPattern(prefixes: string[]): RegExp {
const prefixPart = prefixes.join('|');
// Match prefix followed by optional spaces/separators and then the value
// Use Unicode property \p{L} for letters to support Cyrillic
return new RegExp(`(?:${prefixPart})[\\s\\.]*([0-9]+[\\p{L}\\-]*|[\\p{L}][\\p{L}\\d\\-]*)`, 'iu');
}
function extractValue(text: string, patterns: string[]): { value: string; remaining: string } {
const regex = createPattern(patterns);
const match = text.match(regex);
if (match) {
// Remove the matched part from text
const remaining = text.replace(match[0], '').trim().replace(/^[,.\s]+/, '');
return { value: match[1].trim(), remaining };
}
return { value: '', remaining: text };
}
export function parseAddress(address: string): ParsedAddress {
let remaining = address.trim();
// Extract components in order
const streetResult = extractValue(remaining, STREET_PREFIXES);
const street = streetResult.value;
remaining = streetResult.remaining;
// Try to extract house: first standalone number at the start, then with prefix
let house = '';
let houseResult;
// Try standalone number first (e.g., "ул. Абая 5" - house is "5" without "д." prefix)
const standaloneHouseMatch = remaining.match(/^\s*(\d+[\p{L}]?)(?:\s*[,;]|\s+(?=кв|под|э|п\s|э\s|д\.|д\s|дом))/iu);
if (standaloneHouseMatch) {
house = standaloneHouseMatch[1];
remaining = remaining.slice(standaloneHouseMatch[0].length).trim().replace(/^[,;\s]+/, '');
} else {
// Fallback: try with prefix patterns
houseResult = extractValue(remaining, HOUSE_PATTERNS);
house = houseResult.value;
remaining = houseResult.remaining;
}
const apartmentResult = extractValue(remaining, APARTMENT_PATTERNS);
const apartment = apartmentResult.value;
remaining = apartmentResult.remaining;
const entranceResult = extractValue(remaining, ENTRANCE_PATTERNS);
const entrance = entranceResult.value;
remaining = entranceResult.remaining;
const floorResult = extractValue(remaining, FLOOR_PATTERNS);
const floor = floorResult.value;
remaining = floorResult.remaining;
// Clean up remaining - remove common separators
remaining = remaining
.replace(/^[,.\s]+/, '')
.replace(/[,.\s]+$/, '')
.trim();
return {
street,
house,
apartment,
entrance,
floor,
remaining
};
}
// Format address for display
export function formatAddressShort(addr: ParsedAddress): string {
const parts: string[] = [];
if (addr.street) parts.push(addr.street);
if (addr.house) parts.push(`д. ${addr.house}`);
if (addr.apartment) parts.push(`кв. ${addr.apartment}`);
return parts.join(', ') || addr.remaining;
}
export function formatAddressDetails(addr: ParsedAddress): string {
const parts: string[] = [];
if (addr.entrance) parts.push(`Подъезд ${addr.entrance}`);
if (addr.floor) parts.push(`этаж ${addr.floor}`);
return parts.join(', ');
}
// Build full address from components
export function buildFullAddress(
street: string,
house: string,
apartment?: string,
entrance?: string,
floor?: string
): string {
const parts: string[] = [];
if (street) parts.push(street);
if (house) parts.push(`д. ${house}`);
if (apartment) parts.push(`кв. ${apartment}`);
if (entrance || floor) {
const details: string[] = [];
if (entrance) details.push(`подъезд ${entrance}`);
if (floor) details.push(`этаж ${floor}`);
parts.push(details.join(', '));
}
return parts.join(', ');
}

View File

@@ -0,0 +1,52 @@
import { format, parse, type Locale } from 'date-fns';
/**
* Convert backend date format (YYYY-MM-DD) to frontend format (DD-MM-YYYY)
*/
export function backendDateToFrontend(dateStr: string): string {
const [year, month, day] = dateStr.split('-');
return `${day}-${month}-${year}`;
}
/**
* Convert frontend date format (DD-MM-YYYY) to backend format (YYYY-MM-DD)
*/
export function frontendDateToBackend(dateStr: string): string {
const [day, month, year] = dateStr.split('-');
return `${year}-${month}-${day}`;
}
/**
* Format frontend date for HTML input type="date" (YYYY-MM-DD)
*/
export function formatDateForInput(dateStr: string): string {
const [day, month, year] = dateStr.split('-');
return `${year}-${month}-${day}`;
}
/**
* Parse date from HTML input type="date" to frontend format (DD-MM-YYYY)
*/
export function parseDateFromInput(dateStr: string): string {
const [year, month, day] = dateStr.split('-');
return `${day}-${month}-${year}`;
}
/**
* Get today's date in frontend format
*/
export function getTodayFrontend(): string {
return format(new Date(), 'dd-MM-yyyy');
}
/**
* Format frontend date for display with date-fns
*/
export function formatFrontendDate(
dateStr: string,
formatStr: string,
options?: { locale?: Locale }
): string {
const date = parse(dateStr, 'dd-MM-yyyy', new Date());
return format(date, formatStr, options);
}

View File

@@ -1,59 +0,0 @@
import type { Delivery } from '../types';
export const mockDeliveries: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>[] = [
{
date: new Date().toLocaleDateString('ru-RU').split('.').join('-'),
pickupLocation: 'symbat',
productName: 'Диван прямой Милан',
address: 'ул. Ленина, д. 10, кв. 25',
phone: '+7 (771)-123-45-67',
additionalPhone: '',
hasElevator: true,
comment: 'Доставить после 18:00',
status: 'new',
},
{
date: new Date().toLocaleDateString('ru-RU').split('.').join('-'),
pickupLocation: 'warehouse',
productName: 'Шкаф двухдверный',
address: 'ул. Гагарина, д. 5, офис 304',
phone: '+7 (777)-234-56-78',
additionalPhone: '+7 (702)-111-22-33',
hasElevator: false,
comment: 'Предварительно позвонить',
status: 'new',
},
{
date: new Date(Date.now() + 86400000).toLocaleDateString('ru-RU').split('.').join('-'),
pickupLocation: 'nursaya',
productName: 'Стол обеденный + 4 стула',
address: 'пр. Мира, д. 15',
phone: '+7 (705)-345-67-89',
additionalPhone: '',
hasElevator: true,
comment: '',
status: 'new',
},
{
date: new Date(Date.now() - 86400000).toLocaleDateString('ru-RU').split('.').join('-'),
pickupLocation: 'galaktika',
productName: 'Матрас ортопедический 160x200',
address: 'ул. Пушкина, д. 20',
phone: '+7 (701)-456-78-90',
additionalPhone: '',
hasElevator: false,
comment: 'Доставлено успешно',
status: 'delivered',
},
{
date: new Date(Date.now() + 172800000).toLocaleDateString('ru-RU').split('.').join('-'),
pickupLocation: 'warehouse',
productName: 'Кресло реклайнер',
address: 'ул. Чехова, д. 8, кв. 12',
phone: '+7 (776)-567-89-01',
additionalPhone: '',
hasElevator: true,
comment: 'Подъезд с торца',
status: 'new',
},
];

View File

@@ -1,15 +1,37 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
manifest: false, // manifest.json from public
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,json}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/.*\/api\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
},
},
],
},
}),
],
server: { server: {
allowedHosts: ['delivery.loca.lt', '.loca.lt'], allowedHosts: ['delivery.loca.lt', '.loca.lt'],
// hmr: { proxy: {
// host: 'delivery.loca.lt', // Use the hostname provided by localtunnel '/api': {
// port: 443, // Use HTTPS port target: 'http://localhost:8080',
// }, changeOrigin: true,
},
},
}, },
}) })

File diff suppressed because it is too large Load Diff