commit 4058a1636d1b247b4fa03ee83551f1db0f0d4945 Author: Dana Medical Developer Date: Wed Oct 14 13:33:13 2026 +0000 chore: initial commit of dana-medical production state diff --git a/.deploy/dana-medical.caddy b/.deploy/dana-medical.caddy new file mode 100644 index 0000000..ff454b2 --- /dev/null +++ b/.deploy/dana-medical.caddy @@ -0,0 +1,74 @@ +danamedical.ru { + encode zstd gzip + + header { + -Server + } + + reverse_proxy 172.17.0.1:8082 { + header_up X-Real-IP {remote_host} + } + + request_body { + max_size 20MB + } + + log { + output file /var/log/caddy/dana-medical.log { + roll_size 10MB + roll_keep 10 + } + } +} + +www.danamedical.ru { + redir https://danamedical.ru{uri} permanent +} + +new.danamedical.ru { + encode zstd gzip + + header { + X-Robots-Tag "noindex, nofollow" + -Server + } + + reverse_proxy 172.17.0.1:8082 { + header_up X-Real-IP {remote_host} + } + + request_body { + max_size 20MB + } + + log { + output file /var/log/caddy/dana-medical-preview.log { + roll_size 10MB + roll_keep 5 + } + } +} + +dana.195-95-133-47.sslip.io { + encode zstd gzip + + header { + X-Robots-Tag "noindex, nofollow" + -Server + } + + reverse_proxy 172.17.0.1:8082 { + header_up X-Real-IP {remote_host} + } + + request_body { + max_size 20MB + } + + log { + output file /var/log/caddy/dana-medical-preview.log { + roll_size 10MB + roll_keep 5 + } + } +} diff --git a/.deploy/remote-build-start-dana.sh b/.deploy/remote-build-start-dana.sh new file mode 100644 index 0000000..8ebe258 --- /dev/null +++ b/.deploy/remote-build-start-dana.sh @@ -0,0 +1,33 @@ +#!/bin/sh +set -eu + +cd /home/mikail/dana-medical +docker compose -p dana-medical -f docker-compose.prod.yml config --quiet +docker compose -p dana-medical -f docker-compose.prod.yml build api +docker compose -p dana-medical -f docker-compose.prod.yml create api + +media_volume="$(docker volume ls -q \ + --filter label=com.docker.compose.project=dana-medical \ + --filter label=com.docker.compose.volume=media)" +test -n "$media_volume" +docker run --rm \ + -v "$media_volume:/dest" \ + -v /home/mikail/dana-medical/media:/src:ro \ + postgres:16-alpine sh -c 'cp -a /src/. /dest/ && chown -R 10001:10001 /dest' + +docker compose -p dana-medical -f docker-compose.prod.yml up -d api +api_id="$(docker compose -p dana-medical -f docker-compose.prod.yml ps -q api)" +test -n "$api_id" +i=0 +status=starting +while [ "$i" -lt 45 ]; do + status="$(docker inspect --format '{{.State.Health.Status}}' "$api_id")" + [ "$status" = healthy ] && break + [ "$status" = unhealthy ] && docker logs --tail 80 "$api_id" && exit 1 + sleep 2 + i=$((i + 1)) +done +[ "$status" = healthy ] +printf 'api_health=%s\n' "$status" +printf 'media_volume=%s\n' "$media_volume" +printf 'media_files=%s\n' "$(docker run --rm -v "$media_volume:/data:ro" postgres:16-alpine sh -c 'find /data -type f | wc -l')" diff --git a/.deploy/remote-init-dana.sh b/.deploy/remote-init-dana.sh new file mode 100644 index 0000000..6fadc96 --- /dev/null +++ b/.deploy/remote-init-dana.sh @@ -0,0 +1,38 @@ +#!/bin/sh +set -eu + +root=/home/mikail/dana-medical +deploy="$root/.deploy" +test -f "$deploy/dana-source.tar.gz" +test -f "$deploy/dana-media.tar.gz" +test -f "$deploy/dana-predeploy.dump" +test -f "$deploy/.env" + +tar -xzf "$deploy/dana-source.tar.gz" -C "$root" +tar -xzf "$deploy/dana-media.tar.gz" -C "$root" + +umask 077 +db_password="$(openssl rand -hex 32)" +{ + printf 'POSTGRES_PASSWORD=%s\n' "$db_password" + printf 'SITE_URL=https://danamedical.ru\n' + printf 'DANA_PORT=8082\n' + printf 'ALLOWED_ORIGINS=https://danamedical.ru,https://www.danamedical.ru\n' + grep '^MEGAFON_API_URL=' "$deploy/.env" || true + grep '^MEGAFON_API_KEY=' "$deploy/.env" || true + printf 'SMTP_HOST=\n' + printf 'SMTP_PORT=465\n' + printf 'SMTP_USER=\n' + printf 'SMTP_PASSWORD=\n' + printf 'NOTIFY_FROM=\n' + printf 'NOTIFY_TO=\n' +} > "$root/.env" +chmod 600 "$root/.env" "$deploy/dana-predeploy.dump" +rm -f "$deploy/.env" + +test -f "$root/docker-compose.prod.yml" +test -f "$root/Dockerfile" +test -d "$root/media" +printf 'source_files=%s\n' "$(find "$root" -path "$deploy" -prune -o -type f -print | wc -l)" +printf 'media_files=%s\n' "$(find "$root/media" -type f | wc -l)" +printf 'env_mode=%s\n' "$(stat -c %a "$root/.env")" diff --git a/.deploy/remote-install-dana-caddy.sh b/.deploy/remote-install-dana-caddy.sh new file mode 100644 index 0000000..0969e35 --- /dev/null +++ b/.deploy/remote-install-dana-caddy.sh @@ -0,0 +1,40 @@ +#!/bin/sh +set -eu + +source_file=/home/mikail/dana-medical/.deploy/dana-medical.caddy +custom_dir=/home/kadir/timechecker/caddy-custom +target_file="$custom_dir/dana-medical.caddy" +container=timechecker-caddy-1 + +test -s "$source_file" +if docker run --rm -v "$custom_dir:/target:ro" alpine:3.22 test -e /target/dana-medical.caddy; then + echo 'target_already_exists' + exit 1 +fi + +docker run --rm \ + -v /home/mikail/dana-medical/.deploy:/source:ro \ + -v "$custom_dir:/target" \ + alpine:3.22 cp /source/dana-medical.caddy /target/dana-medical.caddy + +rollback() { + docker run --rm -v "$custom_dir:/target" alpine:3.22 rm -f /target/dana-medical.caddy >/dev/null 2>&1 || true +} +trap rollback INT TERM HUP + +if ! docker exec "$container" caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile; then + rollback + echo 'caddy_validation_failed' + exit 1 +fi + +if ! docker exec "$container" caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile; then + rollback + docker exec "$container" caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1 || true + echo 'caddy_reload_failed' + exit 1 +fi + +trap - INT TERM HUP +docker exec "$container" caddy adapt --config /etc/caddy/Caddyfile --adapter caddyfile 2>/dev/null | grep -q 'new.danamedical.ru' +echo 'caddy_route=installed' diff --git a/.deploy/remote-restore-dana.sh b/.deploy/remote-restore-dana.sh new file mode 100644 index 0000000..5d21a28 --- /dev/null +++ b/.deploy/remote-restore-dana.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu + +cd /home/mikail/dana-medical +dump=.deploy/dana-predeploy.dump +test -s "$dump" + +existing="$(docker compose -p dana-medical -f docker-compose.prod.yml exec -T db psql -U dana -d dana -Atqc "select count(*) from information_schema.tables where table_schema='public'")" +[ "$existing" = 0 ] + +docker compose -p dana-medical -f docker-compose.prod.yml exec -T db \ + pg_restore -U dana -d dana --exit-on-error --no-owner --no-privileges < "$dump" + +docker compose -p dana-medical -f docker-compose.prod.yml exec -T db psql -U dana -d dana -At \ + -c "select 'admins='||count(*) from admin_users; select 'services='||count(*) from services; select 'leads='||count(*) from leads; select 'pbx_calls='||count(*) from pbx_calls;" diff --git a/.deploy/remote-secure-dana.sh b/.deploy/remote-secure-dana.sh new file mode 100644 index 0000000..1bc0b2e --- /dev/null +++ b/.deploy/remote-secure-dana.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +root=/home/mikail/dana-medical +chmod 700 "$root/.deploy" +find "$root/.deploy" -maxdepth 1 -type f -exec chmod 600 {} \; +if [ -f "$root/remote-build-start-dana.sh" ]; then + mv "$root/remote-build-start-dana.sh" "$root/.deploy/" + chmod 600 "$root/.deploy/remote-build-start-dana.sh" +fi +printf 'deploy_mode=%s\n' "$(stat -c %a "$root/.deploy")" +printf 'env_mode=%s\n' "$(stat -c %a "$root/.env")" diff --git a/.deploy/remote-smoke-dana.sh b/.deploy/remote-smoke-dana.sh new file mode 100644 index 0000000..a8e50af --- /dev/null +++ b/.deploy/remote-smoke-dana.sh @@ -0,0 +1,49 @@ +#!/bin/sh +set -eu + +cd /home/mikail/dana-medical +origin=http://172.17.0.1:8082 +marker="DANA-DEPLOY-CHECK-$(date +%Y%m%d%H%M%S)" +body="/tmp/dana-lead-check-$$.json" +cleanup() { + docker compose -p dana-medical -f docker-compose.prod.yml exec -T db \ + psql -U dana -d dana -v ON_ERROR_STOP=1 -c "delete from leads where name='$marker'" >/dev/null 2>&1 || true + rm -f "$body" +} +trap cleanup EXIT INT TERM + +for path in / /services/ /company/staff/ /price/ /checkup/ /contacts/ /robots.txt /sitemap.xml /admin/ /api/v1/health /api/v1/forms; do + code="$(curl -sS -o /dev/null -w '%{http_code}' "$origin$path")" + [ "$code" = 200 ] + printf '%s=%s\n' "$path" "$code" +done + +curl -fsS "$origin/" | grep -q '/dev/null 2>&1 || true +} +trap rollback INT TERM HUP + +if ! docker exec "$container" caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile; then + rollback + echo 'caddy_validation_failed' + exit 1 +fi + +if ! docker exec "$container" caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile; then + rollback + docker exec "$container" caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1 || true + echo 'caddy_reload_failed' + exit 1 +fi + +trap - INT TERM HUP +docker run --rm -v "$custom_dir:/target" alpine:3.22 rm -f /target/dana-medical.caddy.previous +docker exec "$container" caddy adapt --config /etc/caddy/Caddyfile --adapter caddyfile 2>/dev/null | grep -q 'danamedical.ru' +echo 'caddy_dana_routes=installed' diff --git a/.deploy/remote-verify-dana-preview.sh b/.deploy/remote-verify-dana-preview.sh new file mode 100644 index 0000000..4d931c2 --- /dev/null +++ b/.deploy/remote-verify-dana-preview.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -eu + +origin=https://dana.195-95-133-47.sslip.io +headers="/tmp/dana-preview-headers-$$" +body="/tmp/dana-preview-body-$$" +cleanup() { rm -f "$headers" "$body"; } +trap cleanup EXIT INT TERM + +i=0 +status=000 +while [ "$i" -lt 30 ]; do + status="$(curl -sS --max-time 10 -D "$headers" -o "$body" -w '%{http_code}' "$origin/" 2>/dev/null || true)" + [ "$status" = 200 ] && break + sleep 2 + i=$((i + 1)) +done +[ "$status" = 200 ] +grep -qi '^x-robots-tag: noindex, nofollow' "$headers" +grep -q '/dev/null | openssl x509 -noout -subject -issuer -dates diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3a0da2c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.env +.env.* +!.env.example +.work +.gocache +.gotmp +media +web/node_modules +web/dist +web/.ssr +*.exe +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2a83b68 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Подключение к базе. Для локальной установки PostgreSQL порт 5432, +# для варианта с docker compose — 5433. +DATABASE_URL=postgres://dana:CHANGE_ME@localhost:5432/dana?sslmode=disable + +APP_ADDR=:8080 +MEDIA_DIR=./media +ALLOWED_ORIGINS=http://localhost:5173 +DEBUG=true + +# Готовый HTML сайта после npm run build в web. +WEB_DIR=./web/dist +# Один и тот же origin при сборке web и запуске Go. Для боевого сайта укажите HTTPS-домен. +SITE_URL=http://127.0.0.1:8080 + +# Актуальный HTML из БД на запросе; кеш обновляется при изменении данных. +# На сервере нужны Node.js, web/.ssr и установленные зависимости web. +SSR_RENDERER=./web/scripts/render-page.mjs +NODE_BINARY=node +# SSR_RENDERER=off — только снимки сборки, требующие ручной пересборки после правок. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c658f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +/server +/server.exe +/server.exe~ +/media/ +/tmp/ +.env +*.log +.gocache/ +.gotmp/ +.work/ +.idea/ +__pycache__/ +migrations/import/seed_*.sql +migrations/import/fix*.sql +migrations/import/*.csv +migrations/import/*_report.txt +migrations/import/*_plan.txt +web/node_modules/ +web/dist/ +web/.ssr/ +tmp_seed/ +backups/ +.deploy/*.tar.gz +.deploy/*.dump diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f1093e7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM node:24-alpine AS web-build +WORKDIR /src +COPY web/package.json web/package-lock.json ./web/ +RUN cd web && npm ci +COPY seo ./seo +COPY web ./web +RUN cd web && npm run build:client && npx vite build --ssr src/entry-server.jsx --outDir .ssr && npm prune --omit=dev + +FROM golang:1.26-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server + +FROM node:24-alpine +RUN apk add --no-cache ca-certificates tzdata && adduser -D -u 10001 app +COPY --from=build /out/server /usr/local/bin/server +WORKDIR /app +COPY --from=web-build /src/web/dist ./web/dist +COPY --from=web-build /src/web/.ssr ./web/.ssr +COPY --from=web-build /src/web/node_modules ./web/node_modules +COPY web/package.json ./web/package.json +COPY web/scripts/render-page.mjs web/scripts/seo-document.mjs ./web/scripts/ +ENV WEB_DIR=/app/web/dist SSR_RENDERER=/app/web/scripts/render-page.mjs +USER app +EXPOSE 8080 +ENTRYPOINT ["server"] diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..16ca929 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,140 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "dana/internal/api" + "dana/internal/bitrix" + "dana/internal/config" + "dana/internal/megafon" + "dana/internal/notify" + "dana/internal/onec" + "dana/internal/prodoctorov" + "dana/internal/sberhealth" + "dana/internal/storage" +) + +func main() { + if err := run(); err != nil { + slog.Error("fatal", "err", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + return err + } + + level := slog.LevelInfo + if cfg.Debug { + level = slog.LevelDebug + } + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + pd, err := prodoctorov.New(cfg.ProdoctorovCookie, cfg.ProdoctorovLPU) + if err != nil { + return err + } + sh, err := sberhealth.New(cfg.SberHealthCookie, cfg.SberHealthClinic) + if err != nil { + return err + } + + store, err := storage.New(ctx, cfg.DatabaseURL) + if err != nil { + return err + } + defer store.Close() + + if err := store.Migrate(ctx); err != nil { + return err + } + + // Первый администратор заводится один раз, если таблица пуста. + if err := store.EnsureAdmin(ctx, cfg.AdminEmail, cfg.AdminPassword); err != nil { + return err + } + + if err := api.SetTrustedProxies(cfg.TrustedProxies); err != nil { + return err + } + handler := api.New(store, cfg.MediaDir) + if err := handler.FailInterruptedReports(ctx); err != nil { + slog.Warn("mark interrupted reports", "err", err) + } + handler.WithPBX(megafon.New(cfg.MegafonURL, cfg.MegafonKey)) + if cfg.OchevidnoMegafonURL != "" && cfg.OchevidnoMegafonKey != "" { + handler.WithClinicPBX("ochevidno", megafon.New(cfg.OchevidnoMegafonURL, cfg.OchevidnoMegafonKey)) + } + go handler.RunPBXSync(ctx) + handler.WithOneC(onec.New(cfg.OnecURL, cfg.OnecKey, cfg.OnecAuthHeader)) + handler.WithOneCWebhook(cfg.OnecWebhookKey) + handler.WithOneCTuning(time.Duration(cfg.OnecSyncSecs)*time.Second, cfg.OnecLongPoll) + go handler.RunOneCSync(ctx) + handler.WithBitrix(bitrix.New(cfg.BitrixWebhookURL)) + go handler.RunBookingConfirm(ctx) + handler.WithSMSC(cfg.SMSCLogin, cfg.SMSCPassword) + handler.WithSMSCCallbackToken(cfg.SMSCCallbackToken) + handler.WithProdoctorov(pd) + handler.WithSberHealth(sh) + if err := handler.WithWebsite(cfg.WebDir, cfg.SiteURL); err != nil { + return err + } + if err := handler.WithSSR(cfg.SSRRenderer, cfg.NodeBinary); err != nil { + return err + } + if cfg.SMTPHost != "" { + handler.WithNotifier(¬ify.Email{ + Host: cfg.SMTPHost, + Port: cfg.SMTPPort, + User: cfg.SMTPUser, + Password: cfg.SMTPPassword, + From: cfg.NotifyFrom, + To: cfg.NotifyTo, + SiteURL: cfg.SiteURL, + }) + slog.Info("lead notifications by email", "smtp", cfg.SMTPHost, "to", cfg.NotifyTo) + } else { + slog.Warn("SMTP_HOST is not set: lead notifications are disabled") + } + + srv := &http.Server{ + Addr: cfg.Addr, + Handler: handler.Routes(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + slog.Info("listening", "addr", cfg.Addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + slog.Info("shutting down") + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) + defer cancel() + return srv.Shutdown(shutdownCtx) +} diff --git a/deploy.env.example b/deploy.env.example new file mode 100644 index 0000000..5574914 --- /dev/null +++ b/deploy.env.example @@ -0,0 +1,24 @@ +# Скопировать в .env только на VPS и заменить значения. +POSTGRES_PASSWORD=CHANGE_ME_LONG_RANDOM_VALUE +ADMIN_EMAIL=admin@example.ru +ADMIN_PASSWORD=CHANGE_ME_LONG_RANDOM_VALUE + +# Канонический адрес нового сайта. До переключения DNS приложение можно +# проверять через SSH-туннель, не публикуя его в Caddy. +SITE_URL=https://danamedical.ru +DANA_PORT=8082 +ALLOWED_ORIGINS=https://danamedical.ru,https://www.danamedical.ru + +# Уведомления о новых заявках. Если SMTP_HOST пуст, заявки сохраняются в +# админке, но письма не отправляются. +SMTP_HOST= +SMTP_PORT=465 +SMTP_USER= +SMTP_PASSWORD= +NOTIFY_FROM= +NOTIFY_TO= + +# Виртуальная АТС МегаФон. Без ключа раздел работает с уже загруженными +# данными, но автоматическая синхронизация отключена. +MEGAFON_API_URL=vats970345.megapbx.ru +MEGAFON_API_KEY= diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..7e46242 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,53 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: dana + POSTGRES_USER: dana + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dana -d dana"] + interval: 5s + timeout: 3s + retries: 20 + + api: + build: + context: . + restart: unless-stopped + env_file: + - .env + environment: + DATABASE_URL: postgres://dana:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/dana?sslmode=disable + APP_ADDR: ":8080" + MEDIA_DIR: /data/media + WEB_DIR: /app/web/dist + SSR_RENDERER: /app/web/scripts/render-page.mjs + NODE_BINARY: node + SITE_URL: ${SITE_URL:?SITE_URL is required} + DEBUG: "false" + # Caddy уже работает в контейнере Ability Center. Публикуем DANA только + # на docker0 хоста: из интернета порт недоступен, Caddy обращается к нему + # как к 172.17.0.1:8082. + ports: + - "172.17.0.1:${DANA_PORT:-8082}:8080" + volumes: + - media:/data/media + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/api/v1/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + security_opt: + - no-new-privileges:true + +volumes: + db-data: + media: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..31e93c7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: dana + POSTGRES_USER: dana + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dana} + ports: + - "5433:5432" + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dana -d dana"] + interval: 5s + timeout: 3s + retries: 10 + + api: + build: . + environment: + DATABASE_URL: postgres://dana:${POSTGRES_PASSWORD:-dana}@db:5432/dana?sslmode=disable + APP_ADDR: ":8080" + MEDIA_DIR: /data/media + DEBUG: "true" + SITE_URL: ${SITE_URL:-} + # Уведомления о заявках на почту; пустой SMTP_HOST — отключены. + # Для Яндекс Почты: smtp.yandex.ru, порт 465, пароль приложения, а не пароль ящика. + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-465} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + NOTIFY_FROM: ${NOTIFY_FROM:-} + NOTIFY_TO: ${NOTIFY_TO:-} + ports: + - "8080:8080" + volumes: + - media:/data/media + depends_on: + db: + condition: service_healthy + +volumes: + db-data: + media: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..68511db --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module dana + +go 1.26.0 + +require ( + github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 + github.com/jackc/pgx/v5 v5.9.2 + golang.org/x/crypto v0.56.0 + golang.org/x/text v0.41.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.22.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..62d0ff1 --- /dev/null +++ b/go.sum @@ -0,0 +1,30 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 h1:n+nk0bNe2+gVbRI8WRbLFVwwcBQ0rr5p+gzkKb6ol8c= +github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8IdQ1/R2uIRBsNfnPnwsYE9YYI5WyY1zw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/admin.go b/internal/api/admin.go new file mode 100644 index 0000000..269ff3d --- /dev/null +++ b/internal/api/admin.go @@ -0,0 +1,318 @@ +package api + +import ( + "encoding/json" + "errors" + "io" + "net" + "net/http" + "strconv" + "strings" + "time" + + "dana/internal/storage" +) + +const sessionCookie = "dana_admin" + +type ctxKey string + +const userKey ctxKey = "admin_user" + +// trustedProxies — откуда принимаем X-Forwarded-For / X-Real-IP. По умолчанию сам сервер и Docker-сети: +// порт сайта открыт только внутри сервера (172.17.0.1), снаружи к нему ходит лишь Caddy. +// Заголовки от любого другого адреса — подделка клиента для обхода лимитов по IP. +var trustedProxies = mustParseProxies("127.0.0.0/8,::1/128,172.16.0.0/12") + +// SetTrustedProxies задаёт доверенные прокси (TRUSTED_PROXIES): адреса или подсети через запятую, +// например адрес Caddy в Docker-сети. Пустая строка оставляет значение по умолчанию. +func SetTrustedProxies(spec string) error { + if strings.TrimSpace(spec) == "" { + return nil + } + nets, err := parseProxies(spec) + if err != nil { + return err + } + trustedProxies = nets + return nil +} + +func parseProxies(spec string) ([]*net.IPNet, error) { + var out []*net.IPNet + for _, part := range strings.Split(spec, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if !strings.Contains(part, "/") { + if ip := net.ParseIP(part); ip != nil && ip.To4() != nil { + part += "/32" + } else { + part += "/128" + } + } + _, n, err := net.ParseCIDR(part) + if err != nil { + return nil, errors.New("TRUSTED_PROXIES: не адрес и не подсеть: " + part) + } + out = append(out, n) + } + return out, nil +} + +func mustParseProxies(spec string) []*net.IPNet { + n, err := parseProxies(spec) + if err != nil { + panic(err) + } + return n +} + +func isTrustedProxy(ip net.IP) bool { + for _, n := range trustedProxies { + if n.Contains(ip) { + return true + } + } + return false +} + +// clientIP определяет адрес посетителя. Заголовки прокси учитываем, только если соединение +// пришло от доверенного прокси; иначе берём адрес соединения. +// Первым смотрим X-Forwarded-For: его Caddy формирует сам (входящий от посетителя отбрасывает), +// а X-Real-IP Caddy без отдельной настройки пропускает от посетителя как есть. +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + remote := net.ParseIP(host) + if remote == nil || !isTrustedProxy(remote) { + return host + } + // справа налево: правые элементы дописали наши прокси, первый чужой — клиент + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + for i := len(parts) - 1; i >= 0; i-- { + ip := net.ParseIP(strings.TrimSpace(parts[i])) + if ip == nil { + break + } + if !isTrustedProxy(ip) { + return ip.String() + } + } + } + if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); net.ParseIP(real) != nil { + return real + } + return host +} + +// drainBody дочитывает небольшое тело запроса перед отказом. Если ответить, не прочитав его, +// а клиент дошлёт тело уже после закрытия соединения, Windows рвёт соединение (RST), +// и клиент вместо ответа 401 получает ошибку сети. +func drainBody(r *http.Request) { + _, _ = io.Copy(io.Discard, io.LimitReader(r.Body, 1<<20)) +} + +// requireAuth пропускает только запросы с действующей сессией. +func (a *API) requireAuth(next func(http.ResponseWriter, *http.Request, *storage.AdminUser)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(sessionCookie) + if err != nil || c.Value == "" { + drainBody(r) + fail(w, http.StatusUnauthorized, "unauthorized", "Требуется вход") + return + } + u, err := a.store.UserByToken(r.Context(), c.Value) + if err != nil { + drainBody(r) + fail(w, http.StatusUnauthorized, "unauthorized", "Сессия истекла") + return + } + next(w, r, u) + } +} + +func (a *API) adminLogin(w http.ResponseWriter, r *http.Request) { + // защита от перебора пароля: не больше 10 попыток входа с одного IP за 15 минут + if a.tooMany(w, "login:"+clientIP(r), 10, 15*time.Minute) { + return + } + var in struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return + } + token, u, err := a.store.Login(r.Context(), + strings.TrimSpace(strings.ToLower(in.Email)), in.Password, + clientIP(r), r.UserAgent()) + if errors.Is(err, storage.ErrBadCredentials) { + fail(w, http.StatusUnauthorized, "bad_credentials", "Неверный логин или пароль") + return + } + if err != nil { + handleErr(w, err) + return + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 12 * 3600, + }) + ok(w, u, nil) +} + +func (a *API) adminLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookie); err == nil { + _ = a.store.Logout(r.Context(), c.Value) + } + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: -1}) + ok(w, map[string]string{"status": "ok"}, nil) +} + +func (a *API) adminMe(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + ok(w, u, nil) +} + +// ---------- Услуги ---------- + +func (a *API) adminServices(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + list, err := a.store.AdminServices(r.Context(), r.URL.Query().Get("q")) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, map[string]any{"total": len(list)}) +} + +func (a *API) adminUpdateService(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + var in storage.ServiceUpdate + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return + } + if strings.TrimSpace(in.Name) == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Название не может быть пустым") + return + } + if err := a.store.AdminUpdateService(r.Context(), id, in); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "service", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +// ---------- Прайс ---------- + +func (a *API) adminPriceSections(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + list, err := a.store.AdminPriceSections(r.Context()) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, nil) +} + +func (a *API) adminPriceItems(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + q := r.URL.Query() + sectionID, _ := strconv.ParseInt(q.Get("section_id"), 10, 64) + limit, _ := strconv.Atoi(q.Get("limit")) + if limit <= 0 || limit > 200 { + limit = 50 + } + offset, _ := strconv.Atoi(q.Get("offset")) + if offset < 0 { + offset = 0 + } + list, total, err := a.store.AdminPriceItems(r.Context(), sectionID, q.Get("q"), limit, offset) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, map[string]any{"total": total, "limit": limit, "offset": offset}) +} + +func validPriceInput(in storage.PriceItemInput) string { + if strings.TrimSpace(in.Name) == "" { + return "Название не может быть пустым" + } + if in.Price < 0 { + return "Цена не может быть отрицательной" + } + if in.SectionID == 0 { + return "Не выбран раздел" + } + return "" +} + +func (a *API) adminUpdatePriceItem(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + var in storage.PriceItemInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return + } + if msg := validPriceInput(in); msg != "" { + fail(w, http.StatusUnprocessableEntity, "validation", msg) + return + } + if err := a.store.AdminUpdatePriceItem(r.Context(), id, in); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "price_item", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +func (a *API) adminCreatePriceItem(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + var in storage.PriceItemInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return + } + if msg := validPriceInput(in); msg != "" { + fail(w, http.StatusUnprocessableEntity, "validation", msg) + return + } + id, err := a.store.AdminCreatePriceItem(r.Context(), in) + if err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "price_item", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +func (a *API) adminDeletePriceItem(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + if err := a.store.AdminDeletePriceItem(r.Context(), id); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "delete", "price_item", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} diff --git a/internal/api/admin_banners.go b/internal/api/admin_banners.go new file mode 100644 index 0000000..8df9b86 --- /dev/null +++ b/internal/api/admin_banners.go @@ -0,0 +1,161 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "unicode/utf8" + + "dana/internal/storage" +) + +// Совпадает со значением по умолчанию в миграции 0006. +const defaultBannerDisclaimer = "Имеются противопоказания, необходима консультация специалиста" + +const ( + maxBannerChips = 6 + maxBannerChipLen = 40 +) + +func (a *API) adminBanners(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + list, err := a.store.AdminBanners(r.Context(), r.URL.Query().Get("position")) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, map[string]any{"total": len(list)}) +} + +func decodeBanner(w http.ResponseWriter, r *http.Request) (storage.BannerInput, bool) { + var in storage.BannerInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return in, false + } + in.Position = strings.TrimSpace(in.Position) + in.Layout = strings.TrimSpace(in.Layout) + in.Theme = strings.TrimSpace(in.Theme) + in.Title = strings.TrimSpace(in.Title) + in.Subtitle = strings.TrimSpace(in.Subtitle) + in.Badge = strings.TrimSpace(in.Badge) + in.LinkURL = strings.TrimSpace(in.LinkURL) + in.Disclaimer = strings.TrimSpace(in.Disclaimer) + if in.Position == "" { + in.Position = "home_hero" + } + if in.Layout == "" { + in.Layout = "image" + } + if in.Theme == "" { + in.Theme = "navy" + } + // Пустое поле из формы перетёрло бы значение по умолчанию из схемы, и реклама + // медуслуг ушла бы на сайт без обязательного предупреждения (ФЗ «О рекламе», ст. 24). + if in.Disclaimer == "" { + in.Disclaimer = defaultBannerDisclaimer + } + + switch in.Layout { + case "image": + // Текст на таких слайдах нарисован на картинке, поэтому достаточно + // либо картинки, либо заголовка — но хоть что-то показывать нужно. + if in.ImageID == nil && in.Title == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Нужна картинка или заголовок") + return in, false + } + case "text": + if in.Title == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "У текстового слайда нужен заголовок") + return in, false + } + case "doctor": + if in.Title == "" || in.ImageID == nil { + fail(w, http.StatusUnprocessableEntity, "validation", "У слайда с врачом нужны заголовок и фото") + return in, false + } + default: + fail(w, http.StatusUnprocessableEntity, "validation", "Неизвестный вид слайда") + return in, false + } + if in.Theme != "navy" && in.Theme != "lilac" && in.Theme != "orange" { + fail(w, http.StatusUnprocessableEntity, "validation", "Неизвестный цвет слайда") + return in, false + } + + chips := make([]string, 0, len(in.Chips)) + for _, c := range in.Chips { + if c = strings.TrimSpace(c); c == "" { + continue + } + if utf8.RuneCountInString(c) > maxBannerChipLen { + fail(w, http.StatusUnprocessableEntity, "validation", "Плашка длиннее 40 символов: «"+c+"»") + return in, false + } + chips = append(chips, c) + } + if len(chips) > maxBannerChips { + fail(w, http.StatusUnprocessableEntity, "validation", "Не больше 6 плашек на слайде") + return in, false + } + in.Chips = chips + + // Ссылка только на страницу сайта или на http(s): javascript: и прочее отсекаем. + if in.LinkURL != "" && !strings.HasPrefix(in.LinkURL, "/") && + !strings.HasPrefix(in.LinkURL, "https://") && !strings.HasPrefix(in.LinkURL, "http://") { + fail(w, http.StatusUnprocessableEntity, "validation", + "Ссылка должна вести на страницу сайта (/...) или начинаться с https://") + return in, false + } + if in.StartsAt != nil && in.EndsAt != nil && !in.EndsAt.After(*in.StartsAt) { + fail(w, http.StatusUnprocessableEntity, "validation", "Окончание показа должно быть позже начала") + return in, false + } + return in, true +} + +func (a *API) adminCreateBanner(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + in, valid := decodeBanner(w, r) + if !valid { + return + } + id, err := a.store.AdminCreateBanner(r.Context(), in) + if err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "banner", id, clientIP(r)) + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}}) +} + +func (a *API) adminUpdateBanner(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + in, valid := decodeBanner(w, r) + if !valid { + return + } + if err := a.store.AdminUpdateBanner(r.Context(), id, in); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "banner", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +func (a *API) adminDeleteBanner(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + if err := a.store.AdminDeleteBanner(r.Context(), id); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "delete", "banner", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} diff --git a/internal/api/admin_banners_test.go b/internal/api/admin_banners_test.go new file mode 100644 index 0000000..412c097 --- /dev/null +++ b/internal/api/admin_banners_test.go @@ -0,0 +1,41 @@ +package api + +import ( + "net/http/httptest" + "strings" + "testing" +) + +func TestDecodeBannerRules(t *testing.T) { + cases := []struct { + name string + body string + wantOK bool + wantText string + }{ + {"text slide", `{"layout":"text","theme":"lilac","title":"Программы чек-апов"}`, true, ""}, + {"doctor slide", `{"layout":"doctor","title":"Уролог из Москвы","image_id":5,"chips":["26 лет стажа"," "]}`, true, ""}, + {"image slide by picture", `{"layout":"image","image_id":5}`, true, ""}, + {"text slide without title", `{"layout":"text","theme":"lilac"}`, false, "заголовок"}, + {"doctor slide without photo", `{"layout":"doctor","title":"Уролог"}`, false, "фото"}, + {"unknown layout", `{"layout":"video","title":"x"}`, false, "вид"}, + {"unknown theme", `{"layout":"text","theme":"red","title":"x"}`, false, "цвет"}, + {"too many chips", `{"layout":"text","title":"x","chips":["1","2","3","4","5","6","7"]}`, false, "6 плашек"}, + {"javascript link", `{"layout":"text","title":"x","link_url":"javascript:alert(1)"}`, false, "Ссылка"}, + } + for _, tc := range cases { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/api/v1/admin/banners", strings.NewReader(tc.body)) + in, valid := decodeBanner(w, r) + if valid != tc.wantOK || (tc.wantText != "" && !strings.Contains(w.Body.String(), tc.wantText)) { + t.Errorf("%s: valid=%v body=%s, want valid=%v containing %q", tc.name, valid, w.Body.String(), tc.wantOK, tc.wantText) + continue + } + if valid && in.Disclaimer == "" { + t.Errorf("%s: disclaimer must default to the ФЗ «О рекламе» warning", tc.name) + } + if tc.name == "doctor slide" && len(in.Chips) != 1 { + t.Errorf("empty chips must be dropped, got %q", in.Chips) + } + } +} diff --git a/internal/api/admin_checkups.go b/internal/api/admin_checkups.go new file mode 100644 index 0000000..3984b7f --- /dev/null +++ b/internal/api/admin_checkups.go @@ -0,0 +1,147 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "unicode/utf8" + + "dana/internal/storage" +) + +const ( + maxCheckupItems = 100 + maxCheckupItemLen = 300 +) + +type checkupRequest struct { + Slug string `json:"slug"` + Audience string `json:"audience"` + Name string `json:"name"` + Lead string `json:"lead"` + Price *float64 `json:"price"` + OldPrice *float64 `json:"old_price"` + ImageID *int64 `json:"image_id"` + Items []string `json:"items"` + Sort int `json:"sort"` + Published bool `json:"published"` + SEOTitle string `json:"seo_title"` + SEODesc string `json:"seo_description"` + SEOH1 string `json:"seo_h1"` +} + +func decodeCheckup(w http.ResponseWriter, r *http.Request) (storage.CheckupInput, bool) { + var req checkupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return storage.CheckupInput{}, false + } + in := storage.CheckupInput{ + Audience: strings.TrimSpace(req.Audience), + Name: strings.TrimSpace(req.Name), + Lead: strings.TrimSpace(req.Lead), + Price: req.Price, + OldPrice: req.OldPrice, + ImageID: req.ImageID, + Sort: req.Sort, + Published: req.Published, + SEOTitle: strings.TrimSpace(req.SEOTitle), + SEODesc: strings.TrimSpace(req.SEODesc), + SEOH1: strings.TrimSpace(req.SEOH1), + } + if in.Name == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Укажите название программы") + return in, false + } + slug, valid := resolveSlug(w, req.Slug, in.Name) + if !valid { + return in, false + } + in.Slug = slug + + if in.Audience != "adults" && in.Audience != "kids" { + fail(w, http.StatusUnprocessableEntity, "validation", "Выберите, для кого программа: взрослые или дети") + return in, false + } + if in.Price != nil && *in.Price < 0 || in.OldPrice != nil && *in.OldPrice < 0 { + fail(w, http.StatusUnprocessableEntity, "validation", "Цена не может быть отрицательной") + return in, false + } + // Цена без скидки на сайте зачёркивается — она обязана быть больше действующей. + if in.OldPrice != nil && (in.Price == nil || *in.OldPrice <= *in.Price) { + fail(w, http.StatusUnprocessableEntity, "validation", + "Цена без скидки должна быть больше цены программы") + return in, false + } + if in.Sort == 0 { + in.Sort = 100 + } + + for _, item := range req.Items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if utf8.RuneCountInString(item) > maxCheckupItemLen { + fail(w, http.StatusUnprocessableEntity, "validation", + "Пункт состава длиннее 300 символов: "+string([]rune(item)[:40])+"…") + return in, false + } + in.Items = append(in.Items, item) + } + if len(in.Items) > maxCheckupItems { + fail(w, http.StatusUnprocessableEntity, "validation", "В составе программы не больше 100 пунктов") + return in, false + } + return in, true +} + +func (a *API) adminCheckups(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + list, err := a.store.AdminCheckups(r.Context()) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, map[string]any{"total": len(list)}) +} + +func (a *API) adminCreateCheckup(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + in, valid := decodeCheckup(w, r) + if !valid { + return + } + id, err := a.store.AdminCreateCheckup(r.Context(), in) + if err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "checkup", id, clientIP(r)) + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id, "slug": in.Slug}}) +} + +func (a *API) adminUpdateCheckup(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + in, valid := decodeCheckup(w, r) + if !valid { + return + } + oldSlug, err := a.store.PublishedCheckupSlug(r.Context(), id) + if err != nil { + handleErr(w, err) + return + } + if err := a.store.AdminUpdateCheckup(r.Context(), id, in); err != nil { + handleErr(w, err) + return + } + if oldSlug != "" && in.Published { + a.keepOldPath(r.Context(), checkupWebPath(oldSlug), checkupWebPath(in.Slug)) + } + a.store.Audit(r.Context(), u.ID, "update", "checkup", id, clientIP(r)) + ok(w, map[string]any{"id": id, "slug": in.Slug}, nil) +} diff --git a/internal/api/admin_doctors.go b/internal/api/admin_doctors.go new file mode 100644 index 0000000..dc98af1 --- /dev/null +++ b/internal/api/admin_doctors.go @@ -0,0 +1,206 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "dana/internal/storage" +) + +var bookingProviders = map[string]bool{"none": true, "native": true, "medflex": true} +var bioKinds = map[string]bool{ + "education": true, "experience": true, "training": true, + "award": true, "conference": true, +} + +func (a *API) adminDoctors(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + list, err := a.store.AdminDoctors(r.Context(), r.URL.Query().Get("q")) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, map[string]any{"total": len(list)}) +} + +func (a *API) adminUpdateDoctor(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + var in storage.DoctorUpdate + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return + } + if strings.TrimSpace(in.LastName) == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Фамилия не может быть пустой") + return + } + if strings.TrimSpace(in.Slug) == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Адрес (slug) не может быть пустым") + return + } + if in.Booking == "" { + in.Booking = "none" + } + if !bookingProviders[in.Booking] { + fail(w, http.StatusUnprocessableEntity, "validation", "Недопустимый способ записи") + return + } + if in.Rating != nil && (*in.Rating < 1 || *in.Rating > 5) { + fail(w, http.StatusUnprocessableEntity, "validation", "Рейтинг должен быть от 1,0 до 5,0") + return + } + // Medflex без ссылки работать не будет — не даём сохранить заведомо мёртвую кнопку. + if in.Booking == "medflex" && strings.TrimSpace(in.MedflexUser) == "" { + fail(w, http.StatusUnprocessableEntity, "validation", + "Для записи через Medflex нужна ссылка") + return + } + oldSlug, err := a.store.PublishedDoctorSlug(r.Context(), id) + if err != nil { + handleErr(w, err) + return + } + oldPath := a.doctorPathBySlug(r.Context(), oldSlug) + if err := a.store.AdminUpdateDoctor(r.Context(), id, in); err != nil { + handleErr(w, err) + return + } + if oldPath != "" && in.Published { + a.keepOldPath(r.Context(), oldPath, a.doctorPathBySlug(r.Context(), in.Slug)) + } + a.store.Audit(r.Context(), u.ID, "update", "doctor", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +type doctorLinksRequest struct { + ServiceIDs []int64 `json:"service_ids"` + BranchIDs []int64 `json:"branch_ids"` +} + +// adminSetDoctorLinks сохраняет услуги, которые ведёт врач, и филиалы, где он принимает. +func (a *API) adminSetDoctorLinks(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + var in doctorLinksRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return + } + if in.ServiceIDs == nil { + in.ServiceIDs = []int64{} + } + if in.BranchIDs == nil { + in.BranchIDs = []int64{} + } + if len(in.ServiceIDs) > 500 || len(in.BranchIDs) > 50 { + fail(w, http.StatusUnprocessableEntity, "validation", "Слишком много связей") + return + } + if err := a.store.SetDoctorLinks(r.Context(), id, in.ServiceIDs, in.BranchIDs); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update_links", "doctor", id, clientIP(r)) + ok(w, map[string]any{"id": id, "services": len(in.ServiceIDs), "branches": len(in.BranchIDs)}, nil) +} + +// ---------- Биография врача ---------- + +func (a *API) adminBioEntries(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + doctorID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id врача") + return + } + list, err := a.store.AdminBioEntries(r.Context(), doctorID) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, map[string]any{"total": len(list)}) +} + +func (a *API) decodeBioEntry(w http.ResponseWriter, r *http.Request) (storage.BioEntryInput, bool) { + var in storage.BioEntryInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return in, false + } + if !bioKinds[in.Kind] { + fail(w, http.StatusUnprocessableEntity, "validation", "Недопустимый тип записи") + return in, false + } + if strings.TrimSpace(in.Title) == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Заполните текст записи") + return in, false + } + return in, true +} + +func (a *API) adminCreateBioEntry(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + in, okReq := a.decodeBioEntry(w, r) + if !okReq { + return + } + if in.DoctorID == 0 { + fail(w, http.StatusUnprocessableEntity, "validation", "Не указан врач") + return + } + id, err := a.store.AdminCreateBioEntry(r.Context(), in) + if err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "doctor_bio", id, clientIP(r)) + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}}) +} + +func (a *API) adminUpdateBioEntry(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + in, okReq := a.decodeBioEntry(w, r) + if !okReq { + return + } + if err := a.store.AdminUpdateBioEntry(r.Context(), id, in); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "doctor_bio", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +func (a *API) adminDeleteBioEntry(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + if err := a.store.AdminDeleteBioEntry(r.Context(), id); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "delete", "doctor_bio", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +// adminDoctorQuality — данные для «Дашборда врачей»: биография по видам, отзывы и заявки по врачам. +func (a *API) adminDoctorQuality(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + rep, err := a.store.DoctorQuality(r.Context()) + if err != nil { + handleErr(w, err) + return + } + ok(w, rep, nil) +} diff --git a/internal/api/admin_documents.go b/internal/api/admin_documents.go new file mode 100644 index 0000000..c15948d --- /dev/null +++ b/internal/api/admin_documents.go @@ -0,0 +1,230 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "dana/internal/storage" +) + +// documentKinds — допустимые виды документов (как в CHECK таблицы documents). +var documentKinds = map[string]bool{ + "license": true, "certificate": true, "award": true, "supervisor": true, "other": true, +} + +type documentRequest struct { + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + FileID *int64 `json:"file_id"` + PreviewID *int64 `json:"preview_id"` + IssuedAt *string `json:"issued_at"` + Sort int `json:"sort"` + Published bool `json:"published"` +} + +func decodeDocument(w http.ResponseWriter, r *http.Request) (storage.DocumentInput, bool) { + var req documentRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return storage.DocumentInput{}, false + } + in := storage.DocumentInput{ + Kind: req.Kind, + Title: strings.TrimSpace(req.Title), + Body: strings.TrimSpace(strings.ReplaceAll(req.Body, "\r", "")), + FileID: req.FileID, + PreviewID: req.PreviewID, + Sort: req.Sort, + Published: req.Published, + } + if in.Sort == 0 { + in.Sort = 100 + } + if !documentKinds[in.Kind] { + fail(w, http.StatusUnprocessableEntity, "validation", "Выберите вид документа") + return in, false + } + if in.Title == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Укажите название") + return in, false + } + var err error + if in.IssuedAt, err = parseDate(req.IssuedAt); err != nil { + fail(w, http.StatusUnprocessableEntity, "validation", "Дата выдачи в формате ГГГГ-ММ-ДД") + return in, false + } + // лицензию или награду без скана и файла на сайте показать нечем + if in.Published && in.Kind != "supervisor" && in.FileID == nil && in.PreviewID == nil { + fail(w, http.StatusUnprocessableEntity, "validation", "Чтобы показать документ на сайте, загрузите скан или PDF") + return in, false + } + return in, true +} + +// checkDocumentMedia не даёт поставить PDF на место картинки-скана. +func (a *API) checkDocumentMedia(w http.ResponseWriter, r *http.Request, in storage.DocumentInput) bool { + if in.PreviewID == nil { + return true + } + mime, err := a.store.MediaMime(r.Context(), *in.PreviewID) + if errors.Is(err, storage.ErrNotFound) || (err == nil && !strings.HasPrefix(mime, "image/")) { + fail(w, http.StatusUnprocessableEntity, "validation", "Скан для просмотра должен быть картинкой, а PDF загрузите в поле «Файл документа»") + return false + } + if err != nil { + handleErr(w, err) + return false + } + return true +} + +func (a *API) adminDocuments(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + list, err := a.store.AdminDocuments(r.Context()) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, map[string]any{"total": len(list)}) +} + +func (a *API) adminCreateDocument(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + in, valid := decodeDocument(w, r) + if !valid || !a.checkDocumentMedia(w, r, in) { + return + } + id, err := a.store.AdminCreateDocument(r.Context(), in) + if err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "document", id, clientIP(r)) + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}}) +} + +func (a *API) adminUpdateDocument(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + in, valid := decodeDocument(w, r) + if !valid || !a.checkDocumentMedia(w, r, in) { + return + } + if err := a.store.AdminUpdateDocument(r.Context(), id, in); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "document", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +func (a *API) adminDeleteDocument(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + if err := a.store.AdminDeleteDocument(r.Context(), id); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "delete", "document", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +// ---------- вопросы и ответы ---------- + +type faqRequest struct { + Question string `json:"question"` + Answer string `json:"answer"` + ServiceID *int64 `json:"service_id"` + Sort int `json:"sort"` + Published bool `json:"published"` +} + +func decodeFAQ(w http.ResponseWriter, r *http.Request) (storage.FAQInput, bool) { + var req faqRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return storage.FAQInput{}, false + } + in := storage.FAQInput{ + Question: strings.TrimSpace(req.Question), + Answer: strings.TrimSpace(strings.ReplaceAll(req.Answer, "\r", "")), + ServiceID: req.ServiceID, + Sort: req.Sort, + Published: req.Published, + } + if in.Sort == 0 { + in.Sort = 100 + } + if in.Question == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Укажите вопрос") + return in, false + } + if in.Published && in.Answer == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Чтобы показать вопрос на сайте, напишите ответ") + return in, false + } + return in, true +} + +func (a *API) adminFAQ(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + list, err := a.store.AdminFAQ(r.Context()) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, map[string]any{"total": len(list)}) +} + +func (a *API) adminCreateFAQ(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + in, valid := decodeFAQ(w, r) + if !valid { + return + } + id, err := a.store.AdminCreateFAQ(r.Context(), in) + if err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "faq", id, clientIP(r)) + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}}) +} + +func (a *API) adminUpdateFAQ(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + in, valid := decodeFAQ(w, r) + if !valid { + return + } + if err := a.store.AdminUpdateFAQ(r.Context(), id, in); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "faq", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +func (a *API) adminDeleteFAQ(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + if err := a.store.AdminDeleteFAQ(r.Context(), id); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "delete", "faq", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} diff --git a/internal/api/admin_documents_test.go b/internal/api/admin_documents_test.go new file mode 100644 index 0000000..d9708cc --- /dev/null +++ b/internal/api/admin_documents_test.go @@ -0,0 +1,65 @@ +package api + +import ( + "net/http/httptest" + "strings" + "testing" +) + +func TestDecodeDocumentRules(t *testing.T) { + cases := []struct { + name string + body string + wantCode int + wantText string + }{ + {"license with scan", `{"kind":"license","title":"Лицензия","preview_id":5,"issued_at":"2025-12-17","published":true}`, 200, ""}, + {"supervisor without files", `{"kind":"supervisor","title":"Минздрав","body":"Телефон: +7","published":true}`, 200, ""}, + {"hidden draft without files", `{"kind":"award","title":"Премия","published":false}`, 200, ""}, + {"unknown kind", `{"kind":"passport","title":"x"}`, 422, "вид документа"}, + {"no title", `{"kind":"license","title":" "}`, 422, "название"}, + {"published license without files", `{"kind":"license","title":"Лицензия","published":true}`, 422, "загрузите скан"}, + {"bad date", `{"kind":"license","title":"Лицензия","issued_at":"17.12.2025"}`, 422, "ГГГГ-ММ-ДД"}, + } + for _, tc := range cases { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body)) + _, valid := decodeDocument(w, r) + code := 200 + if !valid { + code = w.Code + } + if code != tc.wantCode || (tc.wantText != "" && !strings.Contains(w.Body.String(), tc.wantText)) { + t.Errorf("%s: got %d %s, want %d containing %q", tc.name, code, w.Body.String(), tc.wantCode, tc.wantText) + } + } +} + +func TestDecodeFAQRules(t *testing.T) { + cases := []struct { + name string + body string + wantCode int + wantText string + }{ + {"published answer", `{"question":"Где парковка?","answer":"Во дворе\r\nбесплатно","published":true}`, 200, ""}, + {"draft without answer", `{"question":"Черновик","published":false}`, 200, ""}, + {"no question", `{"question":"","answer":"x"}`, 422, "вопрос"}, + {"published without answer", `{"question":"Где парковка?","published":true}`, 422, "напишите ответ"}, + } + for _, tc := range cases { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body)) + in, valid := decodeFAQ(w, r) + code := 200 + if !valid { + code = w.Code + } + if code != tc.wantCode || (tc.wantText != "" && !strings.Contains(w.Body.String(), tc.wantText)) { + t.Errorf("%s: got %d %s, want %d containing %q", tc.name, code, w.Body.String(), tc.wantCode, tc.wantText) + } + if valid && strings.Contains(in.Answer, "\r") { + t.Errorf("%s: answer keeps CR", tc.name) + } + } +} diff --git a/internal/api/admin_inbox.go b/internal/api/admin_inbox.go new file mode 100644 index 0000000..df27c8f --- /dev/null +++ b/internal/api/admin_inbox.go @@ -0,0 +1,198 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "time" + + "dana/internal/storage" +) + +// допустимые значения статусов — совпадают с CHECK в схеме +var leadStatuses = map[string]bool{"new": true, "in_progress": true, "done": true, "spam": true} +var reviewStatuses = map[string]bool{"pending": true, "published": true, "rejected": true} + +func pageParams(r *http.Request) (limit, offset int) { + limit = queryInt(r, "limit", 50) + if limit <= 0 || limit > 200 { + limit = 50 + } + offset = queryInt(r, "offset", 0) + if offset < 0 { + offset = 0 + } + return limit, offset +} + +// ---------- Заявки ---------- + +func (a *API) adminLeads(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + limit, offset := pageParams(r) + q := r.URL.Query() + var from, to *time.Time + for _, filter := range []struct { + key string + out **time.Time + }{{"from", &from}, {"to", &to}} { + if value := q.Get(filter.key); value != "" { + day, err := time.ParseInLocation("2006-01-02", value, time.Local) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректная дата в фильтре") + return + } + if filter.key == "to" { + day = day.AddDate(0, 0, 1) + } + *filter.out = &day + } + } + if from != nil && to != nil && !from.Before(*to) { + fail(w, http.StatusBadRequest, "bad_request", "Дата начала позже даты окончания") + return + } + list, total, err := a.store.AdminLeads(r.Context(), q.Get("status"), q.Get("q"), from, to, limit, offset) + if err != nil { + handleErr(w, err) + return + } + counts, err := a.store.LeadCounts(r.Context()) + if err != nil { + handleErr(w, err) + return + } + today, err := a.store.LeadTodayCount(r.Context()) + if err != nil { + handleErr(w, err) + return + } + // Просмотр заявок — это доступ к персональным данным, фиксируем в журнале. + a.store.Audit(r.Context(), u.ID, "view_leads", "lead", 0, clientIP(r)) + ok(w, list, map[string]any{"total": total, "limit": limit, "offset": offset, "counts": counts, "today": today}) +} + +func (a *API) adminUpdateLead(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + var in storage.LeadUpdate + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return + } + if !leadStatuses[in.Status] { + fail(w, http.StatusUnprocessableEntity, "validation", "Недопустимый статус заявки") + return + } + if err := a.store.AdminUpdateLead(r.Context(), id, in); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "lead", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +// ---------- Отзывы ---------- + +func (a *API) adminReviews(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + limit, offset := pageParams(r) + q := r.URL.Query() + list, total, err := a.store.AdminReviews(r.Context(), q.Get("status"), q.Get("q"), limit, offset) + if err != nil { + handleErr(w, err) + return + } + counts, err := a.store.ReviewCounts(r.Context()) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, map[string]any{"total": total, "limit": limit, "offset": offset, "counts": counts}) +} + +func decodeReview(w http.ResponseWriter, r *http.Request) (storage.ReviewUpdate, bool) { + var in storage.ReviewUpdate + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return in, false + } + in.AuthorName = strings.TrimSpace(in.AuthorName) + in.Text = strings.TrimSpace(strings.ReplaceAll(in.Text, "\r", "")) + in.Reply = strings.TrimSpace(strings.ReplaceAll(in.Reply, "\r", "")) + if !reviewStatuses[in.Status] { + fail(w, http.StatusUnprocessableEntity, "validation", "Недопустимый статус отзыва") + return in, false + } + video, err := embedVideoURL(in.VideoURL) + if err != nil { + fail(w, http.StatusUnprocessableEntity, "validation", + "Видео — ссылка на YouTube или Rutube, например https://youtu.be/…") + return in, false + } + in.VideoURL = video + // У видео-отзывов текста нет — содержимое там в ролике. + if in.Text == "" && in.VideoURL == "" { + fail(w, http.StatusUnprocessableEntity, "validation", + "Отзыв должен содержать текст или ссылку на видео") + return in, false + } + if in.AuthorName == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Укажите имя автора отзыва") + return in, false + } + if in.Rating != nil && (*in.Rating < 1 || *in.Rating > 5) { + fail(w, http.StatusUnprocessableEntity, "validation", "Оценка должна быть от 1 до 5") + return in, false + } + return in, true +} + +// adminCreateReview — отзыв, записанный со слов пациента или видеоотзыв с YouTube. +func (a *API) adminCreateReview(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + in, valid := decodeReview(w, r) + if !valid { + return + } + id, err := a.store.AdminCreateReview(r.Context(), in) + if err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "review", id, clientIP(r)) + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}}) +} + +func (a *API) adminUpdateReview(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + in, valid := decodeReview(w, r) + if !valid { + return + } + if err := a.store.AdminUpdateReview(r.Context(), id, in); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "review", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +func (a *API) adminDeleteReview(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + if err := a.store.AdminDeleteReview(r.Context(), id); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "delete", "review", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} diff --git a/internal/api/admin_media.go b/internal/api/admin_media.go new file mode 100644 index 0000000..5b6b1dd --- /dev/null +++ b/internal/api/admin_media.go @@ -0,0 +1,120 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "log/slog" + "net/http" + "os" + "path" + "path/filepath" + "strings" + "time" + + "dana/internal/storage" +) + +const maxUploadSize = 10 << 20 // 10 МБ + +// Разрешены только растровые картинки. SVG не пускаем: внутри него может быть +// скрипт, и загруженный файл выполнился бы на домене сайта. +var uploadTypes = map[string]string{ + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", +} + +// adminUploadMedia принимает картинку или PDF документа (multipart, поле file) и регистрирует её +// в медиатеке. Тип определяется по содержимому, а не по расширению из имени файла. +func (a *API) adminUploadMedia(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + if a.mediaDir == "" { + fail(w, http.StatusInternalServerError, "no_media_dir", "Не настроена папка для файлов") + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize+(1<<20)) + if err := r.ParseMultipartForm(maxUploadSize); err != nil { + fail(w, http.StatusBadRequest, "bad_upload", "Файл не больше 10 МБ") + return + } + file, _, err := r.FormFile("file") + if err != nil { + fail(w, http.StatusBadRequest, "no_file", "Не передан файл") + return + } + defer file.Close() + + head := make([]byte, 512) + n, _ := io.ReadFull(file, head) + mime := http.DetectContentType(head[:n]) + ext, allowed := uploadTypes[mime] + // PDF принимаем только в поля документов (?accept=document), чтобы он не попал на место картинки. + documentUpload := r.URL.Query().Get("accept") == "document" + if mime == "application/pdf" && documentUpload { + ext, allowed = ".pdf", true + } + if !allowed { + msg := "Можно загрузить JPEG, PNG, WebP или GIF" + if documentUpload { + msg = "Можно загрузить PDF, JPEG, PNG, WebP или GIF" + } + fail(w, http.StatusUnprocessableEntity, "bad_type", msg) + return + } + + var width, height *int + if _, err := file.Seek(0, io.SeekStart); err != nil { + handleErr(w, err) + return + } + if cfg, _, err := image.DecodeConfig(file); err == nil { + width, height = &cfg.Width, &cfg.Height + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + handleErr(w, err) + return + } + + rnd := make([]byte, 16) + if _, err := rand.Read(rnd); err != nil { + handleErr(w, err) + return + } + rel := path.Join("uploads", time.Now().Format("2006/01"), hex.EncodeToString(rnd)+ext) + dst := filepath.Join(a.mediaDir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + handleErr(w, err) + return + } + out, err := os.Create(dst) + if err != nil { + handleErr(w, err) + return + } + size, copyErr := io.Copy(out, file) + closeErr := out.Close() + if copyErr != nil || closeErr != nil { + _ = os.Remove(dst) + slog.Error("save upload", "copy", copyErr, "close", closeErr) + fail(w, http.StatusInternalServerError, "save_failed", "Не удалось сохранить файл") + return + } + + webPath := "/media/" + rel + alt := strings.TrimSpace(r.FormValue("alt")) + id, err := a.store.CreateMedia(r.Context(), webPath, mime, size, width, height, alt) + if err != nil { + _ = os.Remove(dst) // файл без записи в медиатеке никому не нужен + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "media", id, clientIP(r)) + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{ + "id": id, "path": webPath, "width": width, "height": height, + }}) +} diff --git a/internal/api/admin_partners.go b/internal/api/admin_partners.go new file mode 100644 index 0000000..fbddffa --- /dev/null +++ b/internal/api/admin_partners.go @@ -0,0 +1,119 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + + "dana/internal/storage" +) + +type partnerRequest struct { + Name string `json:"name"` + URL string `json:"url"` + LogoID *int64 `json:"logo_id"` + Sort int `json:"sort"` + Published bool `json:"published"` +} + +// partnerURL проверяет ссылку на сайт партнёра: только http(s) с доменом. +// Адрес «glaz-almaz05.ru» без схемы дополняем до https://. +func partnerURL(raw string) (string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", true + } + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || !strings.Contains(u.Hostname(), ".") || u.User != nil { + return "", false + } + return u.String(), true +} + +func decodePartner(w http.ResponseWriter, r *http.Request) (storage.PartnerInput, bool) { + var req partnerRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return storage.PartnerInput{}, false + } + in := storage.PartnerInput{ + Name: strings.TrimSpace(req.Name), + LogoID: req.LogoID, + Sort: req.Sort, + Published: req.Published, + } + if in.Sort == 0 { + in.Sort = 100 + } + if in.Name == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Укажите название партнёра") + return in, false + } + link, valid := partnerURL(req.URL) + if !valid { + fail(w, http.StatusUnprocessableEntity, "validation", "Сайт — адрес вида https://example.ru") + return in, false + } + in.URL = link + return in, true +} + +func (a *API) adminPartners(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + list, err := a.store.AdminPartners(r.Context()) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, map[string]any{"total": len(list)}) +} + +func (a *API) adminCreatePartner(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + in, valid := decodePartner(w, r) + if !valid { + return + } + id, err := a.store.AdminCreatePartner(r.Context(), in) + if err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "partner", id, clientIP(r)) + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}}) +} + +func (a *API) adminUpdatePartner(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + in, valid := decodePartner(w, r) + if !valid { + return + } + if err := a.store.AdminUpdatePartner(r.Context(), id, in); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "partner", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +func (a *API) adminDeletePartner(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + if err := a.store.AdminDeletePartner(r.Context(), id); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "delete", "partner", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} diff --git a/internal/api/admin_partners_test.go b/internal/api/admin_partners_test.go new file mode 100644 index 0000000..613fd34 --- /dev/null +++ b/internal/api/admin_partners_test.go @@ -0,0 +1,55 @@ +package api + +import ( + "net/http/httptest" + "strings" + "testing" +) + +func TestPartnerURL(t *testing.T) { + for in, want := range map[string]string{ + "": "", + "glaz-almaz05.ru": "https://glaz-almaz05.ru", + " https://ochevidno.ru/ ": "https://ochevidno.ru/", + "http://abilitycenter.ru": "http://abilitycenter.ru", + } { + if got, ok := partnerURL(in); !ok || got != want { + t.Errorf("partnerURL(%q) = %q, %v; want %q", in, got, ok, want) + } + } + for _, in := range []string{"javascript:alert(1)", "https://localhost", "ftp://files.example.ru", "https://user:pass@example.ru"} { + if got, ok := partnerURL(in); ok { + t.Errorf("partnerURL(%q) accepted as %q", in, got) + } + } +} + +func TestDecodeReviewRules(t *testing.T) { + cases := []struct { + name, body, wantText string + wantCode int + }{ + {"text review", `{"author_name":"Мадина","text":"Спасибо","status":"published","rating":5}`, "", 200}, + {"video review link is normalised", `{"author_name":"Пациент","video_url":"https://youtu.be/V1w8DhkWARc","status":"published"}`, "", 200}, + {"foreign video host", `{"author_name":"Пациент","video_url":"javascript:alert(1)","status":"published"}`, "YouTube или Rutube", 422}, + {"empty review", `{"author_name":"Пациент","status":"pending"}`, "текст или ссылку", 422}, + {"no author", `{"author_name":" ","text":"Спасибо","status":"pending"}`, "имя автора", 422}, + {"bad status", `{"author_name":"А","text":"Б","status":"spam"}`, "статус", 422}, + {"bad rating", `{"author_name":"А","text":"Б","status":"pending","rating":7}`, "от 1 до 5", 422}, + } + for _, tc := range cases { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body)) + in, valid := decodeReview(w, r) + code := 200 + if !valid { + code = w.Code + } + if code != tc.wantCode || (tc.wantText != "" && !strings.Contains(w.Body.String(), tc.wantText)) { + t.Errorf("%s: got %d %s, want %d containing %q", tc.name, code, w.Body.String(), tc.wantCode, tc.wantText) + } + if tc.name == "video review link is normalised" && in.VideoURL != "https://www.youtube.com/embed/V1w8DhkWARc" { + t.Errorf("video url = %q", in.VideoURL) + } + } +} diff --git a/internal/api/admin_posts.go b/internal/api/admin_posts.go new file mode 100644 index 0000000..d051a88 --- /dev/null +++ b/internal/api/admin_posts.go @@ -0,0 +1,174 @@ +package api + +import ( + "encoding/json" + "net/http" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" + + "dana/internal/storage" +) + +// Новости и статьи редактируются в админке одинаково; различаются только видом записи. +var postKindLabels = map[string]string{"news": "новость", "article": "статью"} + +// authHandler — обработчик админки, которому requireAuth передаёт вошедшего пользователя. +type authHandler = func(http.ResponseWriter, *http.Request, *storage.AdminUser) + +const maxPostBody = 2 << 20 // текст статьи с разметкой; картинки грузятся отдельно + +var tagRE = regexp.MustCompile(`<[^>]*>`) + +type postRequest struct { + Slug string `json:"slug"` + Title string `json:"title"` + Lead string `json:"lead"` + HTML string `json:"html"` + ImageID *int64 `json:"image_id"` + PublishedAt string `json:"published_at"` + Published bool `json:"published"` + SEOTitle string `json:"seo_title"` + SEODesc string `json:"seo_description"` + SEOH1 string `json:"seo_h1"` +} + +// parsePublishedAt принимает дату «2025-05-28» (полдень по Москве) или полную метку времени, +// которую админка возвращает без изменений, если дату не трогали. +func parsePublishedAt(raw string) (time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Now(), nil + } + if t, err := time.Parse(time.RFC3339Nano, raw); err == nil { + return t, nil + } + moscow := time.FixedZone("MSK", 3*60*60) + return time.ParseInLocation("2006-01-02 15:04", raw+" 12:00", moscow) +} + +func decodePost(w http.ResponseWriter, r *http.Request, kind string) (storage.PostInput, bool) { + var req postRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxPostBody)).Decode(&req); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос или слишком большой текст") + return storage.PostInput{}, false + } + in := storage.PostInput{ + Kind: kind, + Title: strings.TrimSpace(req.Title), + Lead: strings.TrimSpace(req.Lead), + ImageID: req.ImageID, + Published: req.Published, + SEOTitle: strings.TrimSpace(req.SEOTitle), + SEODesc: strings.TrimSpace(req.SEODesc), + SEOH1: strings.TrimSpace(req.SEOH1), + } + if in.Title == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Укажите заголовок") + return in, false + } + if utf8.RuneCountInString(in.Title) > 300 { + fail(w, http.StatusUnprocessableEntity, "validation", "Заголовок длиннее 300 символов") + return in, false + } + slug, valid := resolveSlug(w, req.Slug, in.Title) + if !valid { + return in, false + } + in.Slug = slug + + html := strings.TrimSpace(req.HTML) + text := strings.TrimSpace(strings.ReplaceAll(tagRE.ReplaceAllString(html, ""), " ", "")) + if in.Published && text == "" && !strings.Contains(html, "Текст

","published_at":"2026-09-17","published":true}`, 200, ""}, + {"draft without text", `{"title":"Черновик","published":false}`, 200, ""}, + {"picture-only post", `{"title":"Афиша","html":"

","published":true}`, 200, ""}, + {"no title", `{"title":" ","published":false}`, 422, "заголовок"}, + {"published without text", `{"title":"Пусто","html":"

 

","published":true}`, 422, "добавьте текст"}, + {"bad date", `{"title":"Дата","published_at":"17.09.2026"}`, 422, "ГГГГ-ММ-ДД"}, + {"bad slug", `{"title":"Адрес","slug":"новость"}`, 422, "латинские"}, + // адреса со старого сайта: хвостовой дефис, подчёркивание — запись должна сохраняться + {"legacy slug", `{"title":"Старая","slug":"v-klinike-dana-","published":false}`, 200, ""}, + {"legacy underscore", `{"title":"Старая","slug":"lech_bol","published":false}`, 200, ""}, + {"leading dash", `{"title":"Адрес","slug":"-dana"}`, 422, "латинские"}, + } + for _, tc := range cases { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/api/v1/admin/news", strings.NewReader(tc.body)) + _, valid := decodePost(w, r, "news") + code := 200 + if !valid { + code = w.Code + } + if code != tc.wantCode || (tc.wantText != "" && !strings.Contains(w.Body.String(), tc.wantText)) { + t.Errorf("%s: got %d %s, want %d containing %q", tc.name, code, w.Body.String(), tc.wantCode, tc.wantText) + } + } +} + +func TestDecodePostStoresHTMLBlockAndDate(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/", strings.NewReader(`{"title":"Статья","html":"

Раздел

","published_at":"2025-05-28"}`)) + in, valid := decodePost(w, r, "article") + if !valid { + t.Fatal(w.Body.String()) + } + var blocks []map[string]string + if err := json.Unmarshal(in.Blocks, &blocks); err != nil || len(blocks) != 1 || blocks[0]["html"] != "

Раздел

" { + t.Errorf("blocks = %s", in.Blocks) + } + if in.Slug != "statya" { + t.Errorf("slug = %s", in.Slug) + } + if got := in.PublishedAt.UTC().Format(time.RFC3339); got != "2025-05-28T09:00:00Z" { + t.Errorf("published_at = %s, want midday Moscow", got) + } + + // неизменённая дата возвращается полной меткой времени и сохраняется как есть + w = httptest.NewRecorder() + r = httptest.NewRequest("POST", "/", strings.NewReader(`{"title":"Статья","published_at":"2024-01-13T14:03:41+03:00"}`)) + in, _ = decodePost(w, r, "article") + if got := in.PublishedAt.UTC().Format(time.RFC3339); got != "2024-01-13T11:03:41Z" { + t.Errorf("kept published_at = %s", got) + } +} + +func TestDecodeVacancy(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/", strings.NewReader(`{"title":"Администратор","requirements":"- грамотная речь\r\n\r\n• ответственность ","published":true}`)) + in, valid := decodeVacancy(w, r) + if !valid { + t.Fatal(w.Body.String()) + } + if in.Requirements != "грамотная речь\nответственность" || in.Slug != "administrator" || in.Sort != 100 { + t.Errorf("got %+v", in) + } + + w = httptest.NewRecorder() + r = httptest.NewRequest("POST", "/", strings.NewReader(`{"title":"Пустая","published":true}`)) + if _, valid := decodeVacancy(w, r); valid || !strings.Contains(w.Body.String(), "опишите") { + t.Errorf("empty published vacancy accepted: %d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/api/admin_promos.go b/internal/api/admin_promos.go new file mode 100644 index 0000000..d1e2c25 --- /dev/null +++ b/internal/api/admin_promos.go @@ -0,0 +1,205 @@ +package api + +import ( + "encoding/json" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "dana/internal/storage" +) + +// Адрес: латиница, цифры, дефис и подчёркивание. Хвостовой дефис и «_» встречаются в адресах +// со старого сайта («…v-klinike-dana-», «lech_bol») — их нужно сохранять, иначе запись не сохранить. +var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + +var translit = map[rune]string{ + 'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "e", 'ж': "zh", 'з': "z", + 'и': "i", 'й': "y", 'к': "k", 'л': "l", 'м': "m", 'н': "n", 'о': "o", 'п': "p", 'р': "r", + 'с': "s", 'т': "t", 'у': "u", 'ф': "f", 'х': "kh", 'ц': "ts", 'ч': "ch", 'ш': "sh", 'щ': "shch", + 'ъ': "", 'ы': "y", 'ь': "", 'э': "e", 'ю': "yu", 'я': "ya", +} + +// slugFromTitle делает адрес из названия: «Я в сад» → «ya-v-sad». +func slugFromTitle(title string) string { + var b strings.Builder + dash := false + for _, r := range strings.ToLower(title) { + switch { + case r >= 'a' && r <= 'z' || r >= '0' && r <= '9': + b.WriteRune(r) + dash = false + case translit[r] != "" || r == 'ъ' || r == 'ь': + b.WriteString(translit[r]) + dash = false + default: + if !dash && b.Len() > 0 { + b.WriteByte('-') + dash = true + } + } + } + s := strings.Trim(b.String(), "-") + if len(s) > 80 { + s = strings.Trim(s[:80], "-") + } + return s +} + +// resolveSlug берёт адрес из формы или делает его из названия и проверяет формат. +func resolveSlug(w http.ResponseWriter, raw, title string) (string, bool) { + slug := strings.ToLower(strings.TrimSpace(raw)) + if slug == "" { + slug = slugFromTitle(title) + } + if !slugRE.MatchString(slug) || len(slug) > 100 { + fail(w, http.StatusUnprocessableEntity, "validation", + "Адрес — латинские буквы, цифры и дефисы, например ya-v-sad") + return "", false + } + return slug, true +} + +func parseDate(raw *string) (*time.Time, error) { + if raw == nil || strings.TrimSpace(*raw) == "" { + return nil, nil + } + t, err := time.Parse("2006-01-02", strings.TrimSpace(*raw)) + if err != nil { + return nil, err + } + return &t, nil +} + +type promoRequest struct { + Slug string `json:"slug"` + Title string `json:"title"` + Lead string `json:"lead"` + ImageID *int64 `json:"image_id"` + DiscountPercent *int `json:"discount_percent"` + Terms string `json:"terms"` + CheckupID *int64 `json:"checkup_id"` + StartsAt *string `json:"starts_at"` + EndsAt *string `json:"ends_at"` + Published bool `json:"published"` + SEOTitle string `json:"seo_title"` + SEODesc string `json:"seo_description"` + SEOH1 string `json:"seo_h1"` +} + +func decodePromo(w http.ResponseWriter, r *http.Request) (storage.PromoInput, bool) { + var req promoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return storage.PromoInput{}, false + } + in := storage.PromoInput{ + Title: strings.TrimSpace(req.Title), + Lead: strings.TrimSpace(req.Lead), + ImageID: req.ImageID, + DiscountPercent: req.DiscountPercent, + Terms: strings.TrimSpace(req.Terms), + CheckupID: req.CheckupID, + Published: req.Published, + SEOTitle: strings.TrimSpace(req.SEOTitle), + SEODesc: strings.TrimSpace(req.SEODesc), + SEOH1: strings.TrimSpace(req.SEOH1), + } + if in.Title == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Укажите название акции") + return in, false + } + slug, valid := resolveSlug(w, req.Slug, in.Title) + if !valid { + return in, false + } + in.Slug = slug + + if in.DiscountPercent != nil && (*in.DiscountPercent < 1 || *in.DiscountPercent > 99) { + fail(w, http.StatusUnprocessableEntity, "validation", "Скидка — от 1 до 99 %") + return in, false + } + var err error + if in.StartsAt, err = parseDate(req.StartsAt); err != nil { + fail(w, http.StatusUnprocessableEntity, "validation", "Дата начала в формате ГГГГ-ММ-ДД") + return in, false + } + if in.EndsAt, err = parseDate(req.EndsAt); err != nil { + fail(w, http.StatusUnprocessableEntity, "validation", "Дата окончания в формате ГГГГ-ММ-ДД") + return in, false + } + if in.StartsAt != nil && in.EndsAt != nil && in.EndsAt.Before(*in.StartsAt) { + fail(w, http.StatusUnprocessableEntity, "validation", "Окончание акции не может быть раньше начала") + return in, false + } + + // Закон «О рекламе» (ст. 9): рекламируя скидку, нужно сообщить её условия и срок. + if in.Published && in.Terms == "" { + fail(w, http.StatusUnprocessableEntity, "validation", + "Чтобы показать акцию на сайте, заполните её условия") + return in, false + } + if in.Published && in.DiscountPercent != nil && in.EndsAt == nil { + fail(w, http.StatusUnprocessableEntity, "validation", + "У акции со скидкой укажите последний день действия") + return in, false + } + return in, true +} + +func (a *API) adminPromos(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + list, err := a.store.AdminPromos(r.Context()) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, map[string]any{"total": len(list)}) +} + +func (a *API) adminCreatePromo(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + in, valid := decodePromo(w, r) + if !valid { + return + } + id, err := a.store.AdminCreatePromo(r.Context(), in) + if err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "promo", id, clientIP(r)) + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id, "slug": in.Slug}}) +} + +func (a *API) adminUpdatePromo(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + in, valid := decodePromo(w, r) + if !valid { + return + } + if err := a.store.AdminUpdatePromo(r.Context(), id, in); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "promo", id, clientIP(r)) + ok(w, map[string]any{"id": id, "slug": in.Slug}, nil) +} + +func (a *API) adminDeletePromo(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + if err := a.store.AdminDeletePromo(r.Context(), id); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "delete", "promo", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} diff --git a/internal/api/admin_promos_test.go b/internal/api/admin_promos_test.go new file mode 100644 index 0000000..6089c51 --- /dev/null +++ b/internal/api/admin_promos_test.go @@ -0,0 +1,92 @@ +package api + +import ( + "net/http/httptest" + "strings" + "testing" +) + +func TestSlugFromTitle(t *testing.T) { + cases := map[string]string{ + "Я в сад": "ya-v-sad", + "Здоровый ЖКТ": "zdorovyy-zhkt", + "Мне год (1–2 года)": "mne-god-1-2-goda", + " Чек-ап «Я подросток»! ": "chek-ap-ya-podrostok", + "Подъезд, щука": "podezd-shchuka", + "EMSELLA 2026": "emsella-2026", + "!!!": "", + } + for title, want := range cases { + if got := slugFromTitle(title); got != want { + t.Errorf("slugFromTitle(%q) = %q, want %q", title, got, want) + } + } +} + +func decodePromoBody(body string) (int, string) { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/api/v1/admin/promos", strings.NewReader(body)) + if _, valid := decodePromo(w, r); valid { + return 200, "" + } + return w.Code, w.Body.String() +} + +func TestDecodePromoRules(t *testing.T) { + cases := []struct { + name string + body string + wantCode int + wantText string + }{ + {"valid published promo", `{"title":"Я в сад","discount_percent":20,"terms":"Скидка на программу чек-апа","ends_at":"2026-12-31","published":true}`, 200, ""}, + {"draft without terms is allowed", `{"title":"Черновик","published":false}`, 200, ""}, + {"no title", `{"title":" ","published":false}`, 422, "название"}, + {"published without terms", `{"title":"Я в сад","published":true}`, 422, "условия"}, + {"discount without end date", `{"title":"Я в сад","discount_percent":20,"terms":"x","published":true}`, 422, "последний день"}, + {"discount out of range", `{"title":"Я в сад","discount_percent":150,"published":false}`, 422, "от 1 до 99"}, + {"end before start", `{"title":"Я в сад","starts_at":"2026-10-01","ends_at":"2026-09-01","published":false}`, 422, "раньше начала"}, + {"bad date", `{"title":"Я в сад","ends_at":"31.12.2026","published":false}`, 422, "ГГГГ-ММ-ДД"}, + {"bad slug", `{"title":"Я в сад","slug":"Я в сад","published":false}`, 422, "латинские"}, + } + for _, tc := range cases { + code, body := decodePromoBody(tc.body) + if code != tc.wantCode || (tc.wantText != "" && !strings.Contains(body, tc.wantText)) { + t.Errorf("%s: got %d %s, want %d containing %q", tc.name, code, body, tc.wantCode, tc.wantText) + } + } +} + +func decodeCheckupBody(body string) (int, string, int) { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/api/v1/admin/checkups", strings.NewReader(body)) + in, valid := decodeCheckup(w, r) + if valid { + return 200, "", len(in.Items) + } + return w.Code, w.Body.String(), 0 +} + +func TestDecodeCheckupRules(t *testing.T) { + code, body, items := decodeCheckupBody(`{"name":"Здоровая девочка","audience":"kids","price":5000,"old_price":6000,"items":["УЗИ"," ","Ферритин"]}`) + if code != 200 || items != 2 { + t.Fatalf("valid checkup rejected or empty items kept: %d %s items=%d", code, body, items) + } + + cases := []struct { + name string + body string + wantText string + }{ + {"unknown audience", `{"name":"X","audience":"pets"}`, "для кого"}, + {"old price not above price", `{"name":"X","audience":"adults","price":5000,"old_price":5000}`, "больше цены"}, + {"old price without price", `{"name":"X","audience":"adults","old_price":5000}`, "больше цены"}, + {"negative price", `{"name":"X","audience":"adults","price":-1}`, "отрицательной"}, + } + for _, tc := range cases { + code, body, _ := decodeCheckupBody(tc.body) + if code != 422 || !strings.Contains(body, tc.wantText) { + t.Errorf("%s: got %d %s, want 422 containing %q", tc.name, code, body, tc.wantText) + } + } +} diff --git a/internal/api/admin_redirects.go b/internal/api/admin_redirects.go new file mode 100644 index 0000000..268c1ae --- /dev/null +++ b/internal/api/admin_redirects.go @@ -0,0 +1,143 @@ +package api + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + + "dana/internal/storage" +) + +// keepOldPath оставляет редирект со старого адреса, если в админке сменили адрес записи. +// Ссылки из поисковиков, соцсетей и закладок продолжают работать. +func (a *API) keepOldPath(ctx context.Context, oldPath, newPath string) { + if oldPath == "" || newPath == "" || oldPath == newPath { + return + } + if _, err := a.store.SaveRedirect(ctx, oldPath, newPath); err != nil { + slog.Error("save redirect after slug change", "from", oldPath, "to", newPath, "err", err) + } +} + +func checkupWebPath(slug string) string { return "/checkup/" + slug + "/" } + +// doctorPathBySlug — канонический адрес опубликованного врача или "". +func (a *API) doctorPathBySlug(ctx context.Context, slug string) string { + if slug == "" { + return "" + } + d, err := a.store.DoctorBySlug(ctx, slug) + if err != nil { + return "" + } + return doctorWebPath(*d) +} + +// ---------- ручные редиректы и журнал 404 ---------- + +type redirectRequest struct { + From string `json:"from_path"` + To string `json:"to_path"` +} + +// redirectPath приводит адрес к виду «/путь/»: принимает и полную ссылку со старого домена. +func redirectPath(raw string) (string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", false + } + if strings.Contains(raw, "://") { + u, err := url.Parse(raw) + if err != nil { + return "", false + } + raw = u.Path + } + raw = strings.SplitN(strings.SplitN(raw, "?", 2)[0], "#", 2)[0] + if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsAny(raw, " \t\r\n\\") { + return "", false + } + // файлы (/index.php, /404.php) оставляем как есть, разделы — со слэшем на конце + last := raw[strings.LastIndex(raw, "/")+1:] + if !strings.Contains(last, ".") { + raw = canonicalPagePath(raw) + } + return raw, len(raw) <= 300 +} + +func (a *API) adminRedirects(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + list, err := a.store.AdminRedirects(r.Context()) + if err != nil { + handleErr(w, err) + return + } + missing, err := a.store.AdminNotFound(r.Context(), 100) + if err != nil { + handleErr(w, err) + return + } + ok(w, map[string]any{"redirects": emptyIfNil(list), "not_found": emptyIfNil(missing)}, nil) +} + +func (a *API) adminCreateRedirect(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + var req redirectRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return + } + from, okFrom := redirectPath(req.From) + to, okTo := redirectPath(req.To) + if !okFrom || !okTo { + fail(w, http.StatusUnprocessableEntity, "validation", "Адреса — пути на сайте, например /services/uzi/ (можно вставить полную ссылку)") + return + } + if from == to || from == "/" { + fail(w, http.StatusUnprocessableEntity, "validation", "Старый и новый адрес должны различаться, главную перенаправить нельзя") + return + } + for _, reserved := range []string{"/api/", "/admin", "/media/", "/assets/"} { + if strings.HasPrefix(from, reserved) { + fail(w, http.StatusUnprocessableEntity, "validation", "Служебные адреса перенаправлять нельзя") + return + } + } + id, err := a.store.SaveRedirect(r.Context(), from, to) + if err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "redirect", id, clientIP(r)) + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id, "from_path": from, "to_path": to}}) +} + +func (a *API) adminDeleteRedirect(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + if err := a.store.DeleteRedirect(r.Context(), id); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "delete", "redirect", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +// adminDeleteNotFound убирает адрес из журнала 404 (например, мусорный запрос бота). +func (a *API) adminDeleteNotFound(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + path := r.URL.Query().Get("path") + if path == "" { + fail(w, http.StatusBadRequest, "bad_request", "Не указан адрес") + return + } + if err := a.store.DeleteNotFound(r.Context(), path); err != nil { + handleErr(w, err) + return + } + ok(w, map[string]any{"path": path}, nil) +} diff --git a/internal/api/admin_redirects_test.go b/internal/api/admin_redirects_test.go new file mode 100644 index 0000000..f2bb963 --- /dev/null +++ b/internal/api/admin_redirects_test.go @@ -0,0 +1,23 @@ +package api + +import "testing" + +func TestRedirectPath(t *testing.T) { + good := map[string]string{ + "/services/uzi": "/services/uzi/", + " /services/uzi/ ": "/services/uzi/", + "https://danamedical.ru/price/lech_bol/?a=1": "/price/lech_bol/", + "/404.php": "/404.php", + "/company/staff/#top": "/company/staff/", + } + for in, want := range good { + if got, ok := redirectPath(in); !ok || got != want { + t.Errorf("redirectPath(%q) = %q, %v; want %q", in, got, ok, want) + } + } + for _, in := range []string{"", "services/uzi", "//evil.example/x", "/a b/", `/a\b/`} { + if got, ok := redirectPath(in); ok { + t.Errorf("redirectPath(%q) accepted as %q", in, got) + } + } +} diff --git a/internal/api/admin_reports.go b/internal/api/admin_reports.go new file mode 100644 index 0000000..7d917a8 --- /dev/null +++ b/internal/api/admin_reports.go @@ -0,0 +1,436 @@ +package api + +import ( + "bytes" + "context" + "encoding/csv" + "errors" + "fmt" + "io" + "log/slog" + "math" + "net/http" + "net/url" + "path" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "dana/internal/storage" +) + +const maxReportFile = 10 << 20 // 10 МБ + +// Запрос на отчёт: книга 1С и необязательные файлы СберЗдоровья и ПроДокторов (до 10 МБ каждый). +const maxReportRequest = maxReportFile + (1+maxProdoctorovFiles)*maxPartnerFile + (1 << 20) + +// Виды отчётов. Файл нужен для отчёта по каналам — это книга Excel с выгрузкой 1С и листами каналов. +var reportKinds = map[string]struct { + title string + needFile bool +}{ + "phone_match": {"Отчёт по каналам", true}, +} + +// Этапы задания — как на странице «Отчёты». +const ( + stageUpload = iota + stageCheck + stageProcess + stageBuild + stageFinish + stagesTotal +) + +// reportJobs — отмена идущих заданий. Задания короткие (секунды), поэтому живут в памяти процесса; +// после перезапуска сервера незаконченные помечаются как прерванные. +type reportJobs struct { + mu sync.Mutex + cancels map[int64]context.CancelFunc +} + +var jobs = &reportJobs{cancels: map[int64]context.CancelFunc{}} + +func (j *reportJobs) add(id int64, c context.CancelFunc) { + j.mu.Lock() + j.cancels[id] = c + j.mu.Unlock() +} +func (j *reportJobs) done(id int64) { j.mu.Lock(); delete(j.cancels, id); j.mu.Unlock() } +func (j *reportJobs) cancel(id int64) { + j.mu.Lock() + c := j.cancels[id] + j.mu.Unlock() + if c != nil { + c() + } +} + +// FailInterruptedReports вызывается при старте сервера. +func (a *API) FailInterruptedReports(ctx context.Context) error { + return a.store.FailInterruptedReports(ctx) +} + +func (a *API) adminReports(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + clinic, valid := requestClinic(w, r) + if !valid { + return + } + list, err := a.store.Reports(r.Context(), clinic.Key, 50) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, nil) +} + +func (a *API) adminReport(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + rep, err := a.store.Report(r.Context(), id) + if err != nil { + handleErr(w, err) + return + } + ok(w, rep, nil) +} + +// adminCreateReport принимает вид отчёта и книгу Excel, заводит задание +// и считает его в фоне; страница опрашивает ход через GET /admin/reports/{id}. +func (a *API) adminCreateReport(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + started := time.Now() + r.Body = http.MaxBytesReader(w, r.Body, maxReportRequest) + if err := r.ParseMultipartForm(maxReportFile); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Файл больше 10 МБ или запрос повреждён") + return + } + clinic, knownClinic := clinicFrom(r.FormValue("clinic")) + if !knownClinic { + fail(w, http.StatusUnprocessableEntity, "validation", "Неизвестная клиника") + return + } + kind := r.FormValue("kind") + meta, known := reportKinds[kind] + if !known { + fail(w, http.StatusUnprocessableEntity, "validation", "Неизвестный вид отчёта") + return + } + var data []byte + var name string + if meta.needFile { + f, hdr, err := r.FormFile("file") + if err != nil { + fail(w, http.StatusUnprocessableEntity, "validation", "Загрузите книгу Excel: лист выгрузки 1С и листы каналов с номерами") + return + } + defer f.Close() + name = path.Base(strings.ReplaceAll(hdr.Filename, "\\", "/")) + if ext := strings.ToLower(path.Ext(name)); ext != ".xlsx" && ext != ".csv" { + fail(w, http.StatusUnprocessableEntity, "validation", "Нужен файл .xlsx или .csv") + return + } + if data, err = io.ReadAll(f); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Файл не дочитался — загрузите ещё раз") + return + } + } + uploads, err := readPartnerUploads(r) + if err != nil { + fail(w, http.StatusUnprocessableEntity, "validation", err.Error()) + return + } + // Даты из 1С — источник истины для всех каналов. Ручной диапазон остаётся + // запасным вариантом только для старых файлов без строки периода. + source := r.FormValue("source") + pbxWanted := source == "all" || source == "pbx" + bitrixWanted := source == "all" || source == "bitrix" || r.FormValue("bitrix") == "1" + // автовыгрузка из кабинетов площадок настроена только на кабинеты DANA + prodoctorovWanted := source == "all" && clinic.partnerCabinets && a.prodoctorov.Configured() + sberhealthWanted := source == "all" && clinic.partnerCabinets && a.sberhealth.Configured() + var period *pbxPeriod + if f, t := r.FormValue("pbx_from"), r.FormValue("pbx_to"); f != "" || t != "" { + pbxWanted = true + } + if pbxWanted || bitrixWanted || uploads.any() { + f, t := r.FormValue("pbx_from"), r.FormValue("pbx_to") + from, to, _, ok := detectPeriodFromFile(name, data) + if !ok && (f != "" || t != "") { + from, to, ok = statsRange(map[string][]string{"from": {f}, "to": {t}}, time.Now()) + if !ok || to.Sub(from) > 93*24*time.Hour { + fail(w, http.StatusUnprocessableEntity, "validation", "Период — даты ГГГГ-ММ-ДД, не длиннее трёх месяцев") + return + } + } + if !ok { + fail(w, http.StatusUnprocessableEntity, "validation", + "Не нашёл период в файле: в строке 2 должна быть запись «Период ДД.ММ.ГГГГ - ДД.ММ.ГГГГ»") + return + } + period = &pbxPeriod{from: from, to: to} + } + id, err := a.store.CreateReport(r.Context(), clinic.Key, kind, meta.title, name, int64(len(data)), u.ID) + if err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "report", id, clientIP(r)) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + jobs.add(id, cancel) + uploadMS := time.Since(started).Milliseconds() + go func() { + defer jobs.done(id) + defer cancel() + a.runReport(ctx, id, clinic, kind, name, data, uploadMS, period, pbxWanted, bitrixWanted, prodoctorovWanted, sberhealthWanted, uploads, u.ID) + }() + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}}) +} + +func (a *API) adminCancelReport(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + changed, err := a.store.CancelReport(r.Context(), id) + if err != nil { + handleErr(w, err) + return + } + jobs.cancel(id) + if !changed { + fail(w, http.StatusConflict, "not_running", "Отчёт уже завершён") + return + } + a.store.Audit(r.Context(), u.ID, "cancel", "report", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +func (a *API) adminDeleteReport(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + if err := a.store.DeleteReport(r.Context(), id); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "delete", "report", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +func (a *API) adminDownloadReport(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + name, data, err := a.store.ReportResult(r.Context(), id) + if err != nil { + handleErr(w, err) + return + } + // в файле отчёта — телефоны и имена пациентов: каждое скачивание фиксируем в аудите + a.store.Audit(r.Context(), u.ID, "download", "report", id, clientIP(r)) + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="report-%d.csv"; filename*=UTF-8''%s`, id, url.PathEscape(name))) + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(data) +} + +// ---------- расчёт ---------- + +// progressTracker пишет этапы в базу: длительность каждого завершённого этапа и общий процент. +type progressTracker struct { + a *API + ctx context.Context + id int64 + stage int + start time.Time + stages []int64 + last int + // bounds — где начинается каждый этап на шкале 0–100: доли по реальному объёму работы. + // По умолчанию этапы равные. + bounds []int +} + +func (p *progressTracker) set(stage, inStage int) { + b := p.bounds + if len(b) != stagesTotal+1 { + b = []int{0, 20, 40, 60, 80, 100} + } + pct := b[stage] + (b[stage+1]-b[stage])*inStage/100 + if pct < p.last { + pct = p.last // процент не должен идти назад — выгрузка Битрикс24/АТС до этапа «Проверка + // данных» уже могла продвинуть его дальше, чем начало этого этапа + } + if stage == p.stage && pct-p.last < 1 && inStage != 0 { + return + } + p.last = pct + if p.a == nil || p.a.store == nil { // в тестах — без базы + return + } + if err := p.a.store.ReportProgress(context.Background(), p.id, stage, pct, p.stages); err != nil { + slog.Warn("report progress", "id", p.id, "err", err) + } +} + +// setExact — процент внутри этапа по обработанным строкам: done из total. +func (p *progressTracker) setExact(stage, done, total int) { + if total <= 0 { + return + } + in := done * 100 / total + if in > 99 { + in = 99 + } + p.set(stage, in) +} + +// next закрывает текущий этап и начинает следующий. +func (p *progressTracker) next() { + p.stages = append(p.stages, time.Since(p.start).Milliseconds()) + p.start = time.Now() + p.stage++ + p.set(p.stage, 0) +} + +func (a *API) runReport(ctx context.Context, id int64, clinic *clinicDef, kind, name string, data []byte, uploadMS int64, period *pbxPeriod, pbxWanted, bitrixWanted, prodoctorovWanted, sberhealthWanted bool, uploads partnerUploads, adminID int64) { + p := &progressTracker{a: a, ctx: ctx, id: id, stage: stageCheck, start: time.Now(), stages: []int64{uploadMS}} + p.set(stageCheck, 0) + var ( + result []byte + summary any + err error + ) + switch kind { + case "phone_match": + o := matchOpts{} + if pbxWanted && period != nil { + var po matchOpts + if po, err = a.pbxMatchOpts(ctx, clinic.Key, period); err == nil { + o.pbxLists = mergeChannelLists(o.pbxLists, po.pbxLists) + o.pbxInfo = po.pbxInfo + } + } + if err == nil && bitrixWanted && period != nil { + var bo matchOpts + onProgress := func(phase string, done, total int) { + if total <= 0 { + return + } + base := 0 + if phase == "contacts" { + base = 50 + } + p.set(stageCheck, base+done*50/total) + } + if bo, err = a.bitrixMatchOpts(ctx, clinic, period.from, period.to, onProgress); err == nil { + o.pbxLists = mergeChannelLists(o.pbxLists, bo.pbxLists) + o.bitrixInfo = bo.bitrixInfo + } + } + if err == nil && (prodoctorovWanted || len(uploads.prodoctorov) > 0) { + if period == nil { + err = errors.New("Для ПроДокторов нужен период выгрузки 1С") + } else { + // файлы, приложенные пользователем, главнее скачивания с сервера (его блокирует защита кабинета) + var po matchOpts + if len(uploads.prodoctorov) > 0 { + po, err = prodoctorovUploadOptsFor(clinic, uploads.prodoctorov, period.from, period.to) + } else { + po, err = a.prodoctorovMatchOpts(ctx, period.from, period.to) + } + if err == nil { + o.pbxLists = mergeChannelLists(o.pbxLists, po.pbxLists) + o.prodoctorovInfo = po.prodoctorovInfo + } + } + } + if err == nil && (sberhealthWanted || len(uploads.sber) > 0) { + if period == nil { + err = errors.New("Для СберЗдоровья нужен период выгрузки 1С") + } else { + var so matchOpts + if len(uploads.sber) > 0 { + so, err = sberhealthUploadOpts(uploads.sber, period.from, period.to) + } else { + so, err = a.sberhealthMatchOpts(ctx, period.from, period.to) + } + if err == nil { + o.pbxLists = mergeChannelLists(o.pbxLists, so.pbxLists) + o.sberhealthInfo = so.sberhealthInfo + } + } + } + if err == nil { + result, summary, err = a.phoneMatchOpts(ctx, p, name, data, o) + } + } + status, errText := "done", "" + switch { + case errors.Is(ctx.Err(), context.Canceled): + return // отменили — статус уже «отменён» + case errors.Is(ctx.Err(), context.DeadlineExceeded): + status, errText = "failed", "Расчёт шёл дольше 5 минут и остановлен" + case err != nil: + status, errText = "failed", err.Error() + } + if status == "done" { + p.stages = append(p.stages, time.Since(p.start).Milliseconds()) // «Завершение» + if kind == "phone_match" { + if sum, isSum := summary.(channelsSummary); isSum { + sum.Dashboard = a.saveWeeklyFromSummary(context.Background(), clinic.Key, name, data, sum, adminID) + summary = sum + } + } + } + stamp := time.Now().In(moscow).Format("2006-01-02") + resultName := "otchet-po-kanalam-" + stamp + ".csv" + if status != "done" { + result, resultName = nil, "" + } + if err := a.store.FinishReport(context.Background(), id, status, p.stages, summary, result, resultName, errText); err != nil { + slog.Error("finish report", "id", id, "err", err) + } +} + +// csvBytes — CSV для Excel: UTF-8 с BOM и точкой с запятой. +func csvBytes(rows [][]string) []byte { + var buf bytes.Buffer + buf.WriteString("\ufeff") + cw := csv.NewWriter(&buf) + cw.Comma = ';' + cw.UseCRLF = true + _ = cw.WriteAll(rows) + return buf.Bytes() +} + +var priceRE = regexp.MustCompile(`-?\d+(?:[.,]\d+)?`) + +// parsePrice понимает «1 500», «1500,00», «1 500 ₽», «от 2000 руб.». +func parsePrice(s string) (float64, bool) { + s = strings.NewReplacer(" ", "", " ", "", " ", "").Replace(s) + m := priceRE.FindString(s) + if m == "" { + return 0, false + } + v, err := strconv.ParseFloat(strings.ReplaceAll(m, ",", "."), 64) + if err != nil || v < 0 || v > 100_000_000 { + return 0, false + } + return v, true +} + +func fmtRub(v float64) string { + if v == math.Trunc(v) { + return strconv.FormatFloat(v, 'f', 0, 64) + } + return strings.ReplaceAll(strconv.FormatFloat(v, 'f', 2, 64), ".", ",") +} diff --git a/internal/api/admin_services.go b/internal/api/admin_services.go new file mode 100644 index 0000000..af05763 --- /dev/null +++ b/internal/api/admin_services.go @@ -0,0 +1,49 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + + "dana/internal/storage" +) + +type newServiceRequest struct { + ParentID int64 `json:"parent_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Lead string `json:"lead"` +} + +// adminCreateService заводит услугу скрытой: на сайт она попадёт, когда её заполнят и включат. +func (a *API) adminCreateService(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + var req newServiceRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return + } + in := storage.NewService{ + ParentID: req.ParentID, + Slug: strings.ToLower(strings.TrimSpace(req.Slug)), + Name: strings.TrimSpace(req.Name), + Lead: strings.TrimSpace(req.Lead), + } + switch { + case in.Name == "" || len([]rune(in.Name)) > 200: + fail(w, http.StatusUnprocessableEntity, "validation", "Укажите название услуги (до 200 символов)") + return + case in.ParentID <= 0: + fail(w, http.StatusUnprocessableEntity, "validation", "Выберите направление") + return + case !slugRE.MatchString(in.Slug) || len(in.Slug) > 100: + fail(w, http.StatusUnprocessableEntity, "validation", "Адрес — латиница, цифры и дефис, например «priem-kardiologa»") + return + } + id, err := a.store.AdminCreateService(r.Context(), in) + if err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "service", id, clientIP(r)) + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}}) +} diff --git a/internal/api/admin_site.go b/internal/api/admin_site.go new file mode 100644 index 0000000..455ee64 --- /dev/null +++ b/internal/api/admin_site.go @@ -0,0 +1,317 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "dana/internal/storage" +) + +// ---------- Страницы ---------- + +// Правовые страницы должны быть доступны всегда: на них ссылаются формы заявок +// (согласие на обработку персональных данных) и баннер cookie. +var requiredPages = map[string]bool{"legaldocs": true, "privacy": true, "consent": true, "cookie": true} + +type pageRequest struct { + Title string `json:"title"` + HTML string `json:"html"` + Published bool `json:"published"` + SEOTitle string `json:"seo_title"` + SEODesc string `json:"seo_description"` + SEOH1 string `json:"seo_h1"` +} + +func (a *API) adminPages(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + list, err := a.store.AdminPages(r.Context()) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, map[string]any{"total": len(list)}) +} + +func (a *API) adminUpdatePage(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + var req pageRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxPostBody)).Decode(&req); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос или слишком большой текст") + return + } + current, err := a.store.AdminPage(r.Context(), id) + if err != nil { + handleErr(w, err) + return + } + in := storage.PageInput{ + Title: strings.TrimSpace(req.Title), + Published: req.Published, + SEOTitle: strings.TrimSpace(req.SEOTitle), + SEODesc: strings.TrimSpace(req.SEODesc), + SEOH1: strings.TrimSpace(req.SEOH1), + } + if in.Title == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Укажите заголовок страницы") + return + } + if requiredPages[current.Slug] && !in.Published { + fail(w, http.StatusUnprocessableEntity, "validation", + "Правовые документы нельзя скрыть: на них ссылаются формы заявок и баннер cookie") + return + } + html := strings.TrimSpace(req.HTML) + if requiredPages[current.Slug] && strings.TrimSpace(tagRE.ReplaceAllString(html, "")) == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Текст правового документа не может быть пустым") + return + } + // Блоки, которые админка не редактирует (например, фото на странице «О клинике»), + // сохраняются как были; текстовые заменяются одним блоком из редактора. + var blocks []json.RawMessage + var old []json.RawMessage + _ = json.Unmarshal(current.Blocks, &old) + for _, b := range old { + var head struct { + Type string `json:"type"` + HTML *string `json:"html"` + } + if json.Unmarshal(b, &head) == nil && head.HTML == nil && head.Type != "html" { + blocks = append(blocks, b) + } + } + if html != "" { + b, _ := json.Marshal(map[string]string{"type": "html", "html": html}) + blocks = append(blocks, b) + } + if blocks == nil { + blocks = []json.RawMessage{} + } + in.Blocks, _ = json.Marshal(blocks) + if err := a.store.AdminUpdatePage(r.Context(), id, in); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "page", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +// ---------- Настройки ---------- + +// Все настройки публичны (/api/v1/settings), поэтому админка пишет только известные +// ключи: пароли и ключи сервисов сюда попасть не должны, они живут в переменных окружения. +type legalSettings struct { + OrgName string `json:"org_name"` + OrgShort string `json:"org_short"` + INN string `json:"inn"` + OGRN string `json:"ogrn"` + Address string `json:"address"` + License string `json:"license"` + Email string `json:"email"` +} + +type settingsRequest struct { + Email string `json:"email"` + MaxURL string `json:"max_url"` + Legal legalSettings `json:"legal"` +} + +var ( + emailRE = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) + onlyDigitsRE = regexp.MustCompile(`^\d+$`) +) + +func decodeSettings(w http.ResponseWriter, r *http.Request) (map[string]any, bool) { + var req settingsRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&req); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return nil, false + } + bad := func(msg string) (map[string]any, bool) { + fail(w, http.StatusUnprocessableEntity, "validation", msg) + return nil, false + } + trim := strings.TrimSpace + req.Email, req.MaxURL = trim(req.Email), trim(req.MaxURL) + l := &req.Legal + l.OrgName, l.OrgShort, l.INN, l.OGRN = trim(l.OrgName), trim(l.OrgShort), trim(l.INN), trim(l.OGRN) + l.Address, l.License, l.Email = trim(l.Address), trim(l.License), trim(l.Email) + + if req.Email != "" && !emailRE.MatchString(req.Email) { + return bad("Проверьте адрес почты для сайта") + } + if req.MaxURL != "" { + u, err := url.Parse(req.MaxURL) + if err != nil || u.Scheme != "https" || !strings.Contains(u.Hostname(), ".") { + return bad("Ссылка на MAX — адрес вида https://max.ru/…") + } + } + // Реквизиты по закону должны быть на сайте медицинской организации — пустыми их не оставляем. + if l.OrgName == "" || l.OrgShort == "" || l.Address == "" || l.License == "" { + return bad("Заполните название организации, адрес и лицензию") + } + if !onlyDigitsRE.MatchString(l.INN) || (len(l.INN) != 10 && len(l.INN) != 12) { + return bad("ИНН — 10 или 12 цифр") + } + if !onlyDigitsRE.MatchString(l.OGRN) || (len(l.OGRN) != 13 && len(l.OGRN) != 15) { + return bad("ОГРН — 13 или 15 цифр") + } + if l.Email != "" && !emailRE.MatchString(l.Email) { + return bad("Проверьте почту в реквизитах") + } + return map[string]any{"email": req.Email, "max_url": req.MaxURL, "legal": req.Legal}, true +} + +func (a *API) adminSettings(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + m, err := a.store.Settings(r.Context()) + if err != nil { + handleErr(w, err) + return + } + ok(w, m, nil) +} + +func (a *API) adminUpdateSettings(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + values, valid := decodeSettings(w, r) + if !valid { + return + } + if err := a.store.AdminSetSettings(r.Context(), values); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "settings", 0, clientIP(r)) + ok(w, values, nil) +} + +// ---------- Филиалы ---------- + +type branchRequest struct { + Slug string `json:"slug"` + Name string `json:"name"` + Address string `json:"address"` + Phones []string `json:"phones"` + WorkHours []string `json:"work_hours"` + Lat *float64 `json:"lat"` + Lon *float64 `json:"lon"` + Sort int `json:"sort"` + Published bool `json:"published"` +} + +func cleanList(items []string) []string { + out := []string{} + for _, s := range items { + if s = strings.TrimSpace(s); s != "" { + out = append(out, s) + } + } + return out +} + +func decodeBranch(w http.ResponseWriter, r *http.Request) (storage.BranchInput, bool) { + var req branchRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return storage.BranchInput{}, false + } + in := storage.BranchInput{ + Name: strings.TrimSpace(req.Name), + Address: strings.TrimSpace(req.Address), + Phones: cleanList(req.Phones), + WorkHours: cleanList(req.WorkHours), + Lat: req.Lat, + Lon: req.Lon, + Sort: req.Sort, + Published: req.Published, + } + if in.Sort == 0 { + in.Sort = 100 + } + bad := func(msg string) (storage.BranchInput, bool) { + fail(w, http.StatusUnprocessableEntity, "validation", msg) + return in, false + } + if in.Name == "" || in.Address == "" { + return bad("Укажите название и адрес филиала") + } + slug, valid := resolveSlug(w, req.Slug, in.Name) + if !valid { + return in, false + } + in.Slug = slug + for _, p := range in.Phones { + // digitsRE (handlers_leads.go) совпадает с не-цифрами: после замены остаются только цифры + if n := len(digitsRE.ReplaceAllString(p, "")); n < 10 || n > 12 { + return bad("Телефон «" + p + "» — нужен номер целиком, например +7 928 962-00-94") + } + } + // Первый телефон первого филиала стоит в шапке сайта — без него показать нечего. + if in.Published && len(in.Phones) == 0 { + return bad("Укажите хотя бы один телефон филиала") + } + if (in.Lat == nil) != (in.Lon == nil) { + return bad("Укажите обе координаты или оставьте обе пустыми") + } + if in.Lat != nil && (*in.Lat < -90 || *in.Lat > 90 || *in.Lon < -180 || *in.Lon > 180) { + return bad("Координаты вне допустимого диапазона: широта −90…90, долгота −180…180") + } + return in, true +} + +func (a *API) adminBranches(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + list, err := a.store.AdminBranches(r.Context()) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, map[string]any{"total": len(list)}) +} + +func (a *API) adminCreateBranch(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + in, valid := decodeBranch(w, r) + if !valid { + return + } + id, err := a.store.AdminCreateBranch(r.Context(), in) + if err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "branch", id, clientIP(r)) + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}}) +} + +func (a *API) adminUpdateBranch(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + in, valid := decodeBranch(w, r) + if !valid { + return + } + if err := a.store.AdminUpdateBranch(r.Context(), id, in); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "branch", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +// ---------- Главный экран админки ---------- + +func (a *API) adminDashboard(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + d, err := a.store.Dashboard(r.Context(), 14) + if err != nil { + handleErr(w, err) + return + } + ok(w, d, nil) +} diff --git a/internal/api/admin_site_test.go b/internal/api/admin_site_test.go new file mode 100644 index 0000000..918a534 --- /dev/null +++ b/internal/api/admin_site_test.go @@ -0,0 +1,70 @@ +package api + +import ( + "net/http/httptest" + "strings" + "testing" +) + +const validLegal = `"legal":{"org_name":"ООО «Дана Медикал»","org_short":"ООО «Дана Медикал»","inn":"0500020358","ogrn":"1240500017702","address":"Махачкала","license":"Л041-01172-05/03997730"}` + +func TestDecodeSettings(t *testing.T) { + cases := []struct { + name, body, wantText string + wantCode int + }{ + {"valid", `{"email":"danaclinic@yandex.ru","max_url":"https://max.ru/dana",` + validLegal + `}`, "", 200}, + {"no max link", `{"email":"",` + validLegal + `}`, "", 200}, + {"bad email", `{"email":"danaclinic",` + validLegal + `}`, "почты", 422}, + {"max over http", `{"max_url":"javascript:alert(1)",` + validLegal + `}`, "MAX", 422}, + {"short inn", `{` + strings.Replace(validLegal, "0500020358", "05000", 1) + `}`, "ИНН", 422}, + {"bad ogrn", `{` + strings.Replace(validLegal, "1240500017702", "12405000177", 1) + `}`, "ОГРН", 422}, + {"no license", `{` + strings.Replace(validLegal, "Л041-01172-05/03997730", " ", 1) + `}`, "лицензию", 422}, + } + for _, tc := range cases { + w := httptest.NewRecorder() + r := httptest.NewRequest("PUT", "/", strings.NewReader(tc.body)) + values, valid := decodeSettings(w, r) + code := 200 + if !valid { + code = w.Code + } + if code != tc.wantCode || (tc.wantText != "" && !strings.Contains(w.Body.String(), tc.wantText)) { + t.Errorf("%s: got %d %s, want %d containing %q", tc.name, code, w.Body.String(), tc.wantCode, tc.wantText) + } + // в настройки пишутся только известные ключи + if valid && len(values) != 3 { + t.Errorf("%s: unexpected keys %v", tc.name, values) + } + } +} + +func TestDecodeBranch(t *testing.T) { + cases := []struct { + name, body, wantText string + wantCode int + }{ + {"valid", `{"name":"Ляхова, 22","address":"Махачкала","phones":[" +7 928 962-00-94 ",""],"lat":42.97,"lon":47.49,"published":true}`, "", 200}, + {"hidden without phone", `{"name":"Новый","address":"Махачкала","published":false}`, "", 200}, + {"published without phone", `{"name":"Новый","address":"Махачкала","published":true}`, "телефон", 422}, + {"short phone", `{"name":"Новый","address":"Махачкала","phones":["962-00"]}`, "целиком", 422}, + {"one coordinate", `{"name":"Новый","address":"Махачкала","lat":42.9}`, "обе координаты", 422}, + {"coordinates out of range", `{"name":"Новый","address":"Махачкала","lat":142.9,"lon":47.5}`, "диапазона", 422}, + {"no address", `{"name":"Новый","address":" "}`, "адрес", 422}, + } + for _, tc := range cases { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body)) + in, valid := decodeBranch(w, r) + code := 200 + if !valid { + code = w.Code + } + if code != tc.wantCode || (tc.wantText != "" && !strings.Contains(w.Body.String(), tc.wantText)) { + t.Errorf("%s: got %d %s, want %d containing %q", tc.name, code, w.Body.String(), tc.wantCode, tc.wantText) + } + if tc.name == "valid" && (len(in.Phones) != 1 || in.Phones[0] != "+7 928 962-00-94" || in.Slug != "lyakhova-22") { + t.Errorf("valid: got %+v", in) + } + } +} diff --git a/internal/api/admin_specialties.go b/internal/api/admin_specialties.go new file mode 100644 index 0000000..b5ef468 --- /dev/null +++ b/internal/api/admin_specialties.go @@ -0,0 +1,153 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "dana/internal/storage" +) + +type specialtyRequest struct { + Slug string `json:"slug"` + Name string `json:"name"` + NamePlural string `json:"name_plural"` + Sort int `json:"sort"` +} + +func decodeSpecialty(w http.ResponseWriter, r *http.Request, withSlug bool) (storage.SpecialtyInput, bool) { + var req specialtyRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return storage.SpecialtyInput{}, false + } + in := storage.SpecialtyInput{ + Slug: strings.ToLower(strings.TrimSpace(req.Slug)), + Name: strings.TrimSpace(req.Name), + NamePlural: strings.TrimSpace(req.NamePlural), + Sort: req.Sort, + } + if in.Sort == 0 { + in.Sort = 500 + } + if in.NamePlural == "" { + in.NamePlural = in.Name + } + if in.Name == "" { + in.Name = in.NamePlural + } + if in.Name == "" || len([]rune(in.Name)) > 100 || len([]rune(in.NamePlural)) > 100 { + fail(w, http.StatusUnprocessableEntity, "validation", "Укажите название специальности (до 100 символов)") + return in, false + } + if withSlug && (!slugRE.MatchString(in.Slug) || len(in.Slug) > 100) { + fail(w, http.StatusUnprocessableEntity, "validation", "Адрес — латиница, цифры и дефис, например «kardiologi»") + return in, false + } + return in, true +} + +func (a *API) adminSpecialties(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + list, err := a.store.AdminSpecialties(r.Context()) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, map[string]any{"total": len(list)}) +} + +func (a *API) adminCreateSpecialty(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + in, valid := decodeSpecialty(w, r, true) + if !valid { + return + } + id, err := a.store.AdminCreateSpecialty(r.Context(), in) + if err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "specialty", id, clientIP(r)) + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}}) +} + +func (a *API) adminUpdateSpecialty(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + in, valid := decodeSpecialty(w, r, false) + if !valid { + return + } + if err := a.store.AdminUpdateSpecialty(r.Context(), id, in); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "specialty", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +func (a *API) adminDeleteSpecialty(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + if err := a.store.AdminDeleteSpecialty(r.Context(), id); err != nil { + if errors.Is(err, storage.ErrInUse) { + fail(w, http.StatusConflict, "in_use", "В этой специальности есть врачи — сначала перенесите или удалите их") + return + } + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "delete", "specialty", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +type newDoctorRequest struct { + Slug string `json:"slug"` + LastName string `json:"last_name"` + FirstName string `json:"first_name"` + MiddleName string `json:"middle_name"` + Position string `json:"position"` + SpecialtyID int64 `json:"specialty_id"` +} + +// adminCreateDoctor заводит врача скрытым; фото, биографию и прочее заполняют в карточке. +func (a *API) adminCreateDoctor(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + var req newDoctorRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return + } + in := storage.NewDoctor{ + Slug: strings.ToLower(strings.TrimSpace(req.Slug)), + LastName: strings.TrimSpace(req.LastName), + FirstName: strings.TrimSpace(req.FirstName), + MiddleName: strings.TrimSpace(req.MiddleName), + Position: strings.TrimSpace(req.Position), + SpecialtyID: req.SpecialtyID, + } + switch { + case in.LastName == "" || in.FirstName == "": + fail(w, http.StatusUnprocessableEntity, "validation", "Укажите фамилию и имя") + return + case in.SpecialtyID <= 0: + fail(w, http.StatusUnprocessableEntity, "validation", "Выберите специальность — от неё зависит адрес страницы врача") + return + case !slugRE.MatchString(in.Slug) || len(in.Slug) > 100: + fail(w, http.StatusUnprocessableEntity, "validation", "Адрес — латиница, цифры и дефис, например «ivanov-ivan»") + return + } + id, err := a.store.AdminCreateDoctor(r.Context(), in) + if err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "doctor", id, clientIP(r)) + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}}) +} diff --git a/internal/api/admin_specialties_test.go b/internal/api/admin_specialties_test.go new file mode 100644 index 0000000..c70fa3a --- /dev/null +++ b/internal/api/admin_specialties_test.go @@ -0,0 +1,52 @@ +package api + +import ( + "net/http/httptest" + "strings" + "testing" +) + +func TestDecodeSpecialtyRules(t *testing.T) { + cases := []struct { + name, body string + withSlug bool + wantCode int + wantText string + }{ + {"plural only fills name", `{"name_plural":"Кардиологи","slug":"kardiologi"}`, true, 200, ""}, + {"no name", `{"name_plural":" ","slug":"x"}`, true, 422, "название"}, + {"cyrillic slug", `{"name_plural":"Кардиологи","slug":"кардиологи"}`, true, 422, "латиница"}, + {"slug ignored on update", `{"name_plural":"Кардиологи"}`, false, 200, ""}, + } + for _, tc := range cases { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body)) + in, valid := decodeSpecialty(w, r, tc.withSlug) + code := 200 + if !valid { + code = w.Code + } + if code != tc.wantCode || (tc.wantText != "" && !strings.Contains(w.Body.String(), tc.wantText)) { + t.Errorf("%s: got %d %s, want %d containing %q", tc.name, code, w.Body.String(), tc.wantCode, tc.wantText) + } + if valid && (in.Name == "" || in.Sort != 500) { + t.Errorf("%s: defaults not applied: %+v", tc.name, in) + } + } +} + +func TestCreateDoctorValidation(t *testing.T) { + a := &API{} + cases := []struct{ body, wantText string }{ + {`{"last_name":"Иванов","slug":"ivanov","specialty_id":2}`, "фамилию и имя"}, + {`{"last_name":"Иванов","first_name":"Иван","slug":"ivanov"}`, "специальность"}, + {`{"last_name":"Иванов","first_name":"Иван","slug":"Иванов","specialty_id":2}`, "латиница"}, + } + for _, tc := range cases { + w := httptest.NewRecorder() + a.adminCreateDoctor(w, httptest.NewRequest("POST", "/", strings.NewReader(tc.body)), nil) + if w.Code != 422 || !strings.Contains(w.Body.String(), tc.wantText) { + t.Errorf("%s: got %d %s", tc.body, w.Code, w.Body.String()) + } + } +} diff --git a/internal/api/admin_stats.go b/internal/api/admin_stats.go new file mode 100644 index 0000000..a4bb282 --- /dev/null +++ b/internal/api/admin_stats.go @@ -0,0 +1,67 @@ +package api + +import ( + "net/http" + "strconv" + "time" + + "dana/internal/storage" +) + +var moscow = func() *time.Location { + if loc, err := time.LoadLocation("Europe/Moscow"); err == nil { + return loc + } + return time.FixedZone("MSK", 3*60*60) +}() + +// statsRange разбирает ?from=YYYY-MM-DD&to=YYYY-MM-DD (обе даты включительно, по Москве). +// По умолчанию — последние 30 дней. Период — не длиннее двух лет. +func statsRange(q map[string][]string, now time.Time) (from, to time.Time, ok bool) { + get := func(k string) string { + if v := q[k]; len(v) > 0 { + return v[0] + } + return "" + } + today := time.Date(now.In(moscow).Year(), now.In(moscow).Month(), now.In(moscow).Day(), 0, 0, 0, 0, moscow) + to = today.AddDate(0, 0, 1) + from = today.AddDate(0, 0, -29) + if v := get("from"); v != "" { + t, err := time.ParseInLocation("2006-01-02", v, moscow) + if err != nil { + return from, to, false + } + from = t + } + if v := get("to"); v != "" { + t, err := time.ParseInLocation("2006-01-02", v, moscow) + if err != nil { + return from, to, false + } + to = t.AddDate(0, 0, 1) + } + if !to.After(from) || to.Sub(from) > 2*366*24*time.Hour { + return from, to, false + } + return from, to, true +} + +func (a *API) adminStats(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + from, to, valid := statsRange(r.URL.Query(), time.Now()) + if !valid { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный период: даты в формате ГГГГ-ММ-ДД, не длиннее двух лет") + return + } + branchID, _ := strconv.ParseInt(r.URL.Query().Get("branch_id"), 10, 64) + st, err := a.store.Stats(r.Context(), storage.StatsFilter{From: from, To: to, BranchID: branchID}) + if err != nil { + handleErr(w, err) + return + } + ok(w, st, map[string]any{ + "from": from.Format("2006-01-02"), + "to": to.AddDate(0, 0, -1).Format("2006-01-02"), + "days": int(to.Sub(from).Hours()/24 + 0.5), + }) +} diff --git a/internal/api/admin_stats_test.go b/internal/api/admin_stats_test.go new file mode 100644 index 0000000..462ee90 --- /dev/null +++ b/internal/api/admin_stats_test.go @@ -0,0 +1,27 @@ +package api + +import ( + "testing" + "time" +) + +func TestStatsRange(t *testing.T) { + now := time.Date(2026, 9, 21, 22, 30, 0, 0, time.UTC) // 22 сентября 01:30 по Москве + from, to, ok := statsRange(map[string][]string{}, now) + if !ok || from.Format("2006-01-02") != "2026-08-24" || to.Format("2006-01-02") != "2026-09-23" { + t.Fatalf("default range = %v..%v ok=%v", from, to, ok) + } + from, to, ok = statsRange(map[string][]string{"from": {"2026-09-01"}, "to": {"2026-09-01"}}, now) + if !ok || to.Sub(from) != 24*time.Hour { + t.Fatalf("one day range = %v..%v", from, to) + } + for _, q := range []map[string][]string{ + {"from": {"01.09.2026"}}, + {"from": {"2026-09-10"}, "to": {"2026-09-01"}}, + {"from": {"2020-01-01"}, "to": {"2026-01-01"}}, + } { + if _, _, ok := statsRange(q, now); ok { + t.Errorf("range %v accepted", q) + } + } +} diff --git a/internal/api/admin_vacancies.go b/internal/api/admin_vacancies.go new file mode 100644 index 0000000..84f05c4 --- /dev/null +++ b/internal/api/admin_vacancies.go @@ -0,0 +1,124 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "dana/internal/storage" +) + +type vacancyRequest struct { + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description"` + Duties string `json:"duties"` + Requirements string `json:"requirements"` + Conditions string `json:"conditions"` + Salary string `json:"salary"` + Sort int `json:"sort"` + Published bool `json:"published"` +} + +// cleanLines убирает пустые строки и лишние пробелы: на сайте каждая строка — пункт списка. +func cleanLines(s string) string { + var out []string + for _, line := range strings.Split(strings.ReplaceAll(s, "\r", ""), "\n") { + line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "-•–—*")) + if line != "" { + out = append(out, line) + } + } + return strings.Join(out, "\n") +} + +func decodeVacancy(w http.ResponseWriter, r *http.Request) (storage.VacancyInput, bool) { + var req vacancyRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256<<10)).Decode(&req); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return storage.VacancyInput{}, false + } + in := storage.VacancyInput{ + Title: strings.TrimSpace(req.Title), + Description: strings.TrimSpace(req.Description), + Duties: cleanLines(req.Duties), + Requirements: cleanLines(req.Requirements), + Conditions: cleanLines(req.Conditions), + Salary: strings.TrimSpace(req.Salary), + Sort: req.Sort, + Published: req.Published, + } + if in.Sort == 0 { + in.Sort = 100 + } + if in.Title == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Укажите название вакансии") + return in, false + } + slug, valid := resolveSlug(w, req.Slug, in.Title) + if !valid { + return in, false + } + in.Slug = slug + if in.Published && in.Description == "" && in.Duties == "" && in.Requirements == "" && in.Conditions == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Чтобы показать вакансию на сайте, опишите её") + return in, false + } + return in, true +} + +func (a *API) adminVacancies(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + list, err := a.store.AdminVacancies(r.Context()) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, map[string]any{"total": len(list)}) +} + +func (a *API) adminCreateVacancy(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + in, valid := decodeVacancy(w, r) + if !valid { + return + } + id, err := a.store.AdminCreateVacancy(r.Context(), in) + if err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "create", "vacancy", id, clientIP(r)) + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id, "slug": in.Slug}}) +} + +func (a *API) adminUpdateVacancy(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + in, valid := decodeVacancy(w, r) + if !valid { + return + } + if err := a.store.AdminUpdateVacancy(r.Context(), id, in); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "vacancy", id, clientIP(r)) + ok(w, map[string]any{"id": id, "slug": in.Slug}, nil) +} + +func (a *API) adminDeleteVacancy(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + if err := a.store.AdminDeleteVacancy(r.Context(), id); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "delete", "vacancy", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} diff --git a/internal/api/admin_weekly.go b/internal/api/admin_weekly.go new file mode 100644 index 0000000..0164f74 --- /dev/null +++ b/internal/api/admin_weekly.go @@ -0,0 +1,281 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "path" + "regexp" + "strconv" + "strings" + "time" + + "dana/internal/storage" +) + +// Еженедельный/ежемесячный дашборд по каналам: отдельной загрузки файлов у него нет — +// источник один, это обычный «Отчёт по каналам» (вкладка «Новый отчёт»). Если в загруженной +// туда выгрузке 1С есть текст «Период ДД.ММ.ГГГГ - ДД.ММ.ГГГГ», посчитанный результат +// автоматически добавляется ещё и сюда, одной строкой истории. Эта страница — только +// просмотр накопленных периодов и правка бюджета по каналам. + +// Dates in 1C are inclusive. Internally all integrations use [from, to). +var periodRE = regexp.MustCompile(`(?i)период\s*:?\s*(?:с\s+)?(\d{1,2}\.\d{1,2}\.\d{4})\s*(?:-|–|—|по)\s*(\d{1,2}\.\d{1,2}\.\d{4})`) + +type reportFilePeriod struct { + From string `json:"from"` + To string `json:"to"` // inclusive, for the user + Label string `json:"label"` + Mode string `json:"mode"` +} + +type reportDashboard struct { + reportFilePeriod + ID int64 `json:"id,omitempty"` + Warning string `json:"warning,omitempty"` +} + +func describeReportPeriod(from, to time.Time) reportFilePeriod { + mode := "weeks" + if from.Day() == 1 && to.Equal(time.Date(from.Year(), from.Month()+1, 1, 0, 0, 0, 0, moscow)) { + mode = "months" + } + last := to.AddDate(0, 0, -1) + return reportFilePeriod{From: from.Format("2006-01-02"), To: last.Format("2006-01-02"), + Label: from.Format("02.01.2006") + "–" + last.Format("02.01.2006"), Mode: mode} +} + +func parseReportPeriod(text string) (from, to time.Time, label string, ok bool) { + m := periodRE.FindStringSubmatch(strings.Join(strings.Fields(text), " ")) + if m == nil { + return + } + f, err1 := time.ParseInLocation("2.1.2006", m[1], moscow) + t, err2 := time.ParseInLocation("2.1.2006", m[2], moscow) + if err1 != nil || err2 != nil || t.Before(f) { + return + } + end := t.AddDate(0, 0, 1) + if end.Sub(f) > 93*24*time.Hour { + return + } + return f, end, f.Format("02.01") + "–" + t.Format("02.01"), true +} + +// Row 2 of the actual 1C sheet is authoritative, not the filename, channel sheets +// or the report generation timestamp. Older exports may put it above the headers elsewhere. +func detectPeriodFromFile(name string, data []byte) (from, to time.Time, label string, ok bool) { + sheets, err := readWorkbook(name, data) + if err != nil { + return + } + for _, s := range sheets { + if serviceSheets[strings.ToLower(strings.TrimSpace(s.Name))] { + continue + } + cols, is1C := find1CColumns(s.Rows) + if !is1C { + continue + } + if cols.header > 1 { + text := strings.Join(s.Rows[1], " ") + if strings.Contains(strings.ToLower(text), "период") { + return parseReportPeriod(text) + } + } + for i := 0; i < cols.header; i++ { + text := strings.Join(s.Rows[i], " ") + if strings.Contains(strings.ToLower(text), "период") { + return parseReportPeriod(text) + } + } + return + } + return +} + +// saveWeeklyFromSummary — лучшая попытка: если в файле нашёлся период, кладёт уже посчитанную +// сводку «Отчёта по каналам» ещё и в историю дашборда. Ошибки сюда не прерывают основной отчёт, +// только пишутся в лог — для admin ничего не меняется, кроме того, что период не появится в дашборде. +func (a *API) saveWeeklyFromSummary(ctx context.Context, clinic, name string, data []byte, sum channelsSummary, adminID int64) *reportDashboard { + from, to, label, found := detectPeriodFromFile(name, data) + if !found { + return nil + } + result := &reportDashboard{reportFilePeriod: describeReportPeriod(from, to)} + cc, err := a.weeklyCallCenter(ctx, clinic, from, to, sum) + if err != nil { + slog.Warn("weekly dashboard: колл-ц", "err", err) + } + if id, err := a.store.UpsertWeeklyReport(ctx, clinic, from, to, label, name, cc, weeklyChannelsFromSummary(sum), adminID); err != nil { + slog.Warn("weekly dashboard: сохранить период", "err", err) + result.Warning = "Расчёт готов, но период не сохранился в дашборде. Повторите загрузку файла." + } else { + result.ID = id + } + return result +} + +// Read-only upload preview: no CRM/PBX requests and no database writes. +func (a *API) adminReportPeriod(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + r.Body = http.MaxBytesReader(w, r.Body, maxReportFile+(1<<20)) + if err := r.ParseMultipartForm(maxReportFile); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Файл больше 10 МБ или запрос повреждён") + return + } + if r.MultipartForm != nil { + defer r.MultipartForm.RemoveAll() + } + f, header, err := r.FormFile("file") + if err != nil { + fail(w, http.StatusUnprocessableEntity, "validation", "Выберите выгрузку 1С") + return + } + defer f.Close() + data, err := io.ReadAll(f) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Файл не дочитался") + return + } + name := path.Base(strings.ReplaceAll(header.Filename, "\\", "/")) + from, to, _, found := detectPeriodFromFile(name, data) + if !found { + fail(w, http.StatusUnprocessableEntity, "validation", "Не найден корректный период в строке 2 листа 1С: Период ДД.ММ.ГГГГ - ДД.ММ.ГГГГ") + return + } + ok(w, describeReportPeriod(from, to), nil) +} + +func weeklyChannelsFromSummary(sum channelsSummary) []storage.WeeklyChannel { + channels := make([]storage.WeeklyChannel, 0, len(sum.Channels)+1) + for _, c := range sum.Channels { + channels = append(channels, storage.WeeklyChannel{ + Key: weeklyChannelKey(c.Name), Title: c.Name, + Leads: c.Leads, Patients: c.Patients, Services: c.Services, PerPat: c.PerPat, + Revenue: c.Revenue, AvgCheck: c.AvgCheck, + }) + } + channels = append(channels, storage.WeeklyChannel{ + Key: "итого-интернет", Title: sum.Internet.Name, + Leads: sum.Internet.Leads, Patients: sum.Internet.Patients, Services: sum.Internet.Services, + PerPat: sum.Internet.PerPat, Revenue: sum.Internet.Revenue, AvgCheck: sum.Internet.AvgCheck, + }) + return channels +} + +// weeklyChannelKey — заголовок канала (как в книге) обратно в ключ knownChannels, для стабильной +// привязки бюджета к каналу между неделями. Неизвестные каналы используют сам заголовок в нижнем регистре. +func weeklyChannelKey(title string) string { + for k, d := range knownChannels { + if d.title == title { + return k + } + } + return strings.ToLower(strings.TrimSpace(title)) +} + +func (a *API) adminWeeklyReports(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + clinic, valid := requestClinic(w, r) + if !valid { + return + } + list, err := a.store.WeeklyReports(r.Context(), clinic.Key) + if err != nil { + handleErr(w, err) + return + } + ok(w, list, nil) +} + +func (a *API) adminDeleteWeeklyReport(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + if err := a.store.DeleteWeeklyReport(r.Context(), id); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "delete", "weekly_report", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +func (a *API) adminSetWeeklyBudget(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") + return + } + var body struct { + Channel string `json:"channel"` + Amount float64 `json:"amount"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Channel == "" || body.Amount < 0 { + fail(w, http.StatusUnprocessableEntity, "validation", "Нужны channel и неотрицательный amount") + return + } + if err := a.store.SetWeeklyBudget(r.Context(), id, body.Channel, body.Amount); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "weekly_report_budget", id, clientIP(r)) + ok(w, map[string]any{"id": id}, nil) +} + +// weeklyCallCenterSales — пациенты/услуги/выручка блока «Колл-ц»: совпадения номеров колл-центра +// («Колл-центр (все номера)») с 1С, как в таблице «Показатели», а не вся выгрузка 1С за период. +func weeklyCallCenterSales(sum channelsSummary) storage.WeeklyCallCenter { + title := knownChannels["все номера"].title + for _, c := range sum.Channels { + if c.Name == title { + return storage.WeeklyCallCenter{Patients: c.Patients, Services: c.Services, Revenue: c.Revenue, AvgCheck: c.AvgCheck} + } + } + return storage.WeeklyCallCenter{} +} + +// weeklyCallCenter — блок «Колл-ц»: весь колл-центр за период, без привязки к рекламному каналу. +// Пациенты/услуги/выручка — совпадения колл-центра из уже посчитанной сводки отчёта, звонки и время — из АТС. +func (a *API) weeklyCallCenter(ctx context.Context, clinic string, from, to time.Time, sum channelsSummary) (storage.WeeklyCallCenter, error) { + cc := weeklyCallCenterSales(sum) + if !a.pbxFor(clinic).Configured() { + return cc, nil + } + calls, err := a.store.PBXCalls(ctx, clinic, from, to) + if err != nil { + return cc, err + } + uniq := map[string]bool{} + var talkSum, waitSum, answered int + for _, c := range calls { + cc.Total++ + switch { + case isOutgoing(c): + cc.Outgoing++ + default: + cc.Incoming++ + if c.Client != "" { + uniq[c.Client] = true + } + if isMissed(c) { + cc.Missed++ + } else { + answered++ + talkSum += c.Duration + waitSum += c.Wait + } + } + } + cc.Unique = len(uniq) + if cc.Incoming > 0 { + cc.MissedPct = float64(cc.Missed) / float64(cc.Incoming) * 100 + } + if answered > 0 { + cc.TalkSec = talkSum / answered + cc.WaitSec = waitSum / answered + } + return cc, nil +} diff --git a/internal/api/adminui.go b/internal/api/adminui.go new file mode 100644 index 0000000..1c9d857 --- /dev/null +++ b/internal/api/adminui.go @@ -0,0 +1,18 @@ +package api + +import ( + "embed" + "io/fs" +) + +//go:embed adminui/* +var adminUIFS embed.FS + +// adminFS отдаёт файлы интерфейса админки, вшитые в бинарник. +func adminFS() fs.FS { + sub, err := fs.Sub(adminUIFS, "adminui") + if err != nil { + panic(err) + } + return sub +} diff --git a/internal/api/adminui/admin.css b/internal/api/adminui/admin.css new file mode 100644 index 0000000..de2780e --- /dev/null +++ b/internal/api/adminui/admin.css @@ -0,0 +1,1275 @@ +/* Админка DANA в визуальном языке референса BioTrack: воздушная оболочка на молочно-розовом + градиенте, белые карточки с большими скруглениями, органическая лента навигации. */ + +@font-face { font-family: 'Montserrat Alternates'; font-weight: 300; font-display: swap; + src: url(fonts/montserrat-alternates-cyrillic-300-normal.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; } +@font-face { font-family: 'Montserrat Alternates'; font-weight: 300; font-display: swap; + src: url(fonts/montserrat-alternates-latin-300-normal.woff2) format('woff2'); } +@font-face { font-family: 'Montserrat Alternates'; font-weight: 400; font-display: swap; + src: url(fonts/montserrat-alternates-cyrillic-400-normal.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; } +@font-face { font-family: 'Montserrat Alternates'; font-weight: 400; font-display: swap; + src: url(fonts/montserrat-alternates-latin-400-normal.woff2) format('woff2'); } +@font-face { font-family: 'Montserrat Alternates'; font-weight: 500; font-display: swap; + src: url(fonts/montserrat-alternates-cyrillic-500-normal.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; } +@font-face { font-family: 'Montserrat Alternates'; font-weight: 500; font-display: swap; + src: url(fonts/montserrat-alternates-latin-500-normal.woff2) format('woff2'); } +@font-face { font-family: 'Montserrat Alternates'; font-weight: 600; font-display: swap; + src: url(fonts/montserrat-alternates-cyrillic-600-normal.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; } +@font-face { font-family: 'Montserrat Alternates'; font-weight: 600; font-display: swap; + src: url(fonts/montserrat-alternates-latin-600-normal.woff2) format('woff2'); } + +:root { + --bg-milk: #f6f6f5; + --bg-cool: #eeeeef; + --bg-powder: #efcfd2; + --card: #ffffff; + --text: #33393b; + --muted: #929793; + --muted-2: #b5b8b6; + --line: #efefec; + --field: #f6f6f4; + --blue: #8dcbe9; + --blue-deep: #6cacED; + --logo-blue: #a7c9ed; + --mint: #bfe8d6; + --mint-text: #3f8f6d; + --rose: #e78c9f; + --rose-soft: #fbe3e8; + --rose-text: #b8566c; + --sky-soft: #dff0f8; + --sky-text: #3f86a8; + --butter: #f4e9b2; + --sage: #eaeef0; + --danger: #c2495d; + --warn: #b7813a; + --shadow: 0 10px 30px rgba(51, 57, 59, 0.05), 0 1px 2px rgba(51, 57, 59, 0.03); + --shadow-lift: 0 16px 40px rgba(51, 57, 59, 0.08); + --r-card: 26px; + --r-field: 14px; + /* Montserrat Alternates (шрифт сайта DANA) — для крупного: заголовки, цифры, логотип. + У него стилизованные кириллические «т» и «д», в мелком тексте они читаются плохо. */ + --font-display: 'Montserrat Alternates', 'Segoe UI', system-ui, sans-serif; + --font: 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif; + --ease: cubic-bezier(.22, .8, .24, 1); + /* совместимость с классами, которые использует код разделов */ + --accent: var(--text); + --accent-dark: #1f2426; +} + +* { box-sizing: border-box; } +/* hidden должен побеждать display: grid/flex у бейджей, подсказок и выпадающих окон */ +[hidden] { display: none !important; } +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; + min-height: 100vh; + font: 400 14px/1.5 var(--font); + color: var(--text); + background: + radial-gradient(1200px 900px at 100% 0%, rgba(239, 207, 210, .95), transparent 62%), + radial-gradient(900px 700px at 105% 100%, rgba(240, 214, 216, .7), transparent 60%), + linear-gradient(115deg, #f9f9f8 0%, #f1f1f1 38%, #f1e6e6 70%, #efd3d5 100%); + background-attachment: fixed; +} +button, input, select, textarea { font: inherit; color: inherit; } +a { color: inherit; } +:focus-visible { outline: 2px solid var(--blue-deep); outline-offset: 2px; } +.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + +/* ---------- вход ---------- */ +#login { + max-width: 380px; margin: 12vh auto 0; padding: 34px 32px 28px; + background: var(--card); border-radius: 30px; box-shadow: var(--shadow); +} +#login h2 { margin: 18px 0 20px; font-family: var(--font-display); font-weight: 400; font-size: 26px; letter-spacing: -0.03em; } +#login label { display: block; margin-bottom: 14px; } +#login span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 12px; } +.err { color: var(--danger); margin-top: 10px; min-height: 20px; font-size: 13px; } + +/* ---------- каркас ---------- */ +#app { display: flex; min-height: 100vh; padding: 18px 22px 18px 16px; gap: 22px; } +.shell { flex: 1; min-width: 0; display: flex; flex-direction: column; } + +/* органическая лента навигации: белая форма с плавными «перетяжками» по контуру */ +.side { + position: sticky; top: 18px; align-self: flex-start; + /* Keep tooltip descendants above the adjacent page's stacking contexts. */ + z-index: 50; + width: 76px; height: calc(100vh - 36px); min-height: 560px; flex-shrink: 0; + display: flex; flex-direction: column; align-items: center; + padding: 22px 0 18px; + filter: drop-shadow(0 12px 28px rgba(51, 57, 59, 0.06)); +} +.side__shape { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; } +.side__logo { + font-family: var(--font-display); + display: flex; flex-direction: column; align-items: center; line-height: .92; + font-weight: 600; font-size: 17px; letter-spacing: -0.04em; text-decoration: none; +} +.side__logo b { color: var(--logo-blue); font-weight: 600; } +.side__logo span { color: var(--text); font-size: 13px; font-weight: 500; letter-spacing: -0.02em; } +.side__nav { display: flex; flex-direction: column; gap: 14px; margin-top: 78px; } +.side__foot { margin-top: auto; padding-top: 60px; display: flex; flex-direction: column; align-items: center; gap: 16px; } +.side__btn { + position: relative; width: 46px; height: 46px; border: 0; border-radius: 16px; + display: grid; place-items: center; background: transparent; color: #7e8481; cursor: pointer; + transition: background-color .25s var(--ease), color .25s var(--ease); +} +.side__btn svg { width: 21px; height: 21px; stroke: currentColor; fill: none; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; } +.side__btn:hover { color: var(--text); background: #f3f5f5; } +.side__btn[aria-current="page"] { background: var(--sage); color: var(--text); } +.side__badge { + position: absolute; top: 6px; right: 6px; min-width: 16px; height: 16px; padding: 0 4px; + border-radius: 8px; background: var(--rose); color: #fff; font-size: 9px; font-weight: 600; + display: grid; place-items: center; line-height: 1; +} +.side__btn .tip { + position: absolute; left: calc(100% + 12px); top: 50%; transform: translate(-4px, -50%); + padding: 7px 12px; border-radius: 12px; background: var(--card); color: var(--text); + font-size: 12px; white-space: nowrap; box-shadow: var(--shadow-lift); + opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s var(--ease); z-index: 30; +} +.side__btn:hover .tip, .side__btn:focus-visible .tip { opacity: 1; transform: translate(0, -50%); } +.switch { + position: relative; width: 46px; height: 26px; border: 0; border-radius: 13px; padding: 0; + background: #e3e6e5; cursor: pointer; transition: background-color .25s; +} +.switch::after { + content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; + background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.12); transition: transform .25s var(--ease); +} +.switch[aria-checked="true"] { background: var(--blue-deep); } +.switch[aria-checked="true"]::after { transform: translateX(20px); } +.switch .tip { left: calc(100% + 12px); } +.switch:hover .tip, .switch:focus-visible .tip { opacity: 1; transform: translate(0, -50%); } +.switch .tip { + position: absolute; top: 50%; transform: translate(-4px, -50%); padding: 7px 12px; border-radius: 12px; + background: var(--card); color: var(--text); font-size: 12px; white-space: nowrap; box-shadow: var(--shadow-lift); + opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s var(--ease); z-index: 30; +} + +/* ---------- верхняя панель ---------- */ +.top { display: flex; align-items: center; gap: 12px; min-height: 58px; padding-left: 4px; position: relative; z-index: 20; } +.search { position: relative; width: min(330px, 40vw); } +.search__shape { display: flex; align-items: center; } +.search input { + flex: 1; height: 42px; border: 0; border-radius: 21px 0 0 21px; padding: 0 18px; + background: var(--card); font-size: 12px; outline: none; min-width: 0; +} +.search input::placeholder { color: var(--muted); } +/* «перетяжка» между полем и кругом с лупой, как у капсулы поиска в референсе */ +.search__neck { width: 22px; height: 42px; flex-shrink: 0; display: block; } +.search__btn { + width: 46px; height: 46px; margin-left: -4px; border-radius: 50%; border: 0; cursor: pointer; + background: var(--card); display: grid; place-items: center; flex-shrink: 0; +} +.search__btn span { width: 34px; height: 34px; border-radius: 50%; background: var(--sage); display: grid; place-items: center; } +.search__btn svg, .round svg { width: 17px; height: 17px; stroke: var(--text); fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } +.search__results { + position: absolute; left: 0; top: calc(100% + 8px); width: min(460px, 86vw); max-height: 60vh; overflow: auto; + background: var(--card); border-radius: 20px; box-shadow: var(--shadow-lift); padding: 8px; +} +.search__results button { + display: flex; width: 100%; gap: 10px; align-items: baseline; text-align: left; + border: 0; background: none; padding: 9px 12px; border-radius: 12px; cursor: pointer; +} +.search__results button:hover, .search__results button:focus-visible { background: var(--field); } +.search__results small { color: var(--muted); font-size: 11px; margin-left: auto; white-space: nowrap; } +.search__empty { padding: 12px; color: var(--muted); font-size: 12px; } +.top__spacer { flex: 1; } +.round { + position: relative; width: 44px; height: 44px; border-radius: 50%; border: 0; cursor: pointer; + background: var(--card); display: grid; place-items: center; transition: box-shadow .25s, transform .25s var(--ease); +} +.round:hover { box-shadow: var(--shadow-lift); transform: translateY(-1px); } +.round__dot { position: absolute; top: 10px; right: 11px; width: 7px; height: 7px; border-radius: 50%; background: var(--rose); box-shadow: 0 0 0 2px #fff; } +.who { display: flex; align-items: center; gap: 12px; margin-left: 18px; } +.who__avatar { + width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; + background: linear-gradient(135deg, #dcebf5, #f4d9de); color: var(--text); font-weight: 500; font-size: 14px; + box-shadow: 0 0 0 3px #fff; +} +.who__name { font-weight: 500; font-size: 14px; line-height: 1.2; } +.who__meta { color: var(--muted); font-size: 11px; margin-top: 3px; display: flex; gap: 10px; } +.popover { + position: absolute; top: calc(100% + 10px); right: 0; width: 320px; max-height: 70vh; overflow: auto; + background: var(--card); border-radius: 22px; box-shadow: var(--shadow-lift); padding: 16px; z-index: 40; +} +.popover h4 { margin: 2px 4px 10px; font-weight: 500; font-size: 13px; } +.popover__item { + display: flex; gap: 12px; align-items: center; width: 100%; padding: 10px; border: 0; background: none; + border-radius: 14px; text-align: left; cursor: pointer; +} +.popover__item:hover { background: var(--field); } +.popover__item b { font-family: var(--font-display); font-weight: 500; font-size: 18px; min-width: 32px; letter-spacing: -0.02em; } +.popover__item span { font-size: 12px; color: var(--text); } +.popover__item small { display: block; color: var(--muted); font-size: 11px; } +.popover__empty { color: var(--muted); font-size: 12px; padding: 8px 4px; } + +/* ---------- переключатель подразделов ---------- */ +.subtabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 18px; padding: 5px; width: fit-content; max-width: 100%; + background: rgba(255, 255, 255, .6); border-radius: 18px; backdrop-filter: blur(6px); } +.subtabs:empty { display: none; } +.subtabs button { border: 0; background: none; padding: 8px 16px; border-radius: 14px; cursor: pointer; color: var(--muted); font-size: 13px; } +.subtabs button:hover { color: var(--text); } +.subtabs button[aria-current="page"] { background: linear-gradient(100deg, #f08a3c, #f6a45e); color: #fff; box-shadow: 0 6px 16px rgba(240, 138, 60, .28); } +.page-title { margin: 10px 0 4px; font-family: var(--font-display); font-weight: 500; font-size: 40px; letter-spacing: -0.04em; line-height: 1.05; } +main { flex: 1; min-width: 0; } + +/* ---------- карточки, таблицы, формы (все разделы) ---------- */ +.card { + background: var(--card); border-radius: var(--r-card); padding: 22px 24px; margin-bottom: 18px; + box-shadow: var(--shadow); border: 0; +} +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: 11px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; } +th { white-space: nowrap; font-size: 11px; font-weight: 500; color: var(--muted); letter-spacing: 0; text-transform: none; } +tr:last-child td { border-bottom: 0; } +tbody tr { transition: background-color .2s; } +tbody tr:hover { background: #fafaf8; } +input, select, textarea { + padding: 9px 13px; border: 1px solid #ebebe7; border-radius: var(--r-field); background: #fff; width: 100%; + transition: border-color .2s, box-shadow .2s; +} +input:focus, select:focus, textarea:focus { outline: none; border-color: #d3d7d5; box-shadow: 0 0 0 4px rgba(141, 203, 233, .22); } +/* Native controls retain keyboard/form behaviour; only their presentation changes. */ +input[type=checkbox], .rp-source-opt input[type=radio] { + appearance: none; width: 20px; min-width: 20px; height: 20px; flex: 0 0 20px; + padding: 0; margin: 0; border: 1.5px solid #c7d3df; border-radius: 6px; + background: #fff center / 14px 14px no-repeat; vertical-align: middle; + cursor: pointer; transition: background-color .18s, border-color .18s, box-shadow .18s; +} +.rp-source-opt input[type=radio] { border-radius: 50%; } +input[type=checkbox]:checked, .rp-source-opt input[type=radio]:checked { + border-color: #4389e9; background-color: #4389e9; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='m3.5 8 3 3 6-6' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); +} +input[type=checkbox]:indeterminate { + border-color: #4389e9; background-color: #4389e9; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 8h8' fill='none' stroke='white' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); +} +input[type=checkbox]:not(:disabled):hover, .rp-source-opt input[type=radio]:not(:disabled):hover { border-color: #4389e9; } +input[type=checkbox]:focus-visible, .rp-source-opt input[type=radio]:focus-visible { + outline: 2px solid #4389e9; outline-offset: 3px; box-shadow: 0 0 0 4px #e9f2ff; +} +input[type=checkbox]:disabled, .rp-source-opt input[type=radio]:disabled { opacity: .45; cursor: not-allowed; } +@media (forced-colors: active) { + input[type=checkbox], .rp-source-opt input[type=radio] { appearance: auto; background: none; accent-color: auto; } +} + +button.btn { + background: var(--text); color: #fff; border: 0; padding: 9px 18px; border-radius: 999px; cursor: pointer; + font-size: 13px; transition: background-color .2s, transform .2s var(--ease), box-shadow .2s; +} +button.btn:hover { background: var(--accent-dark); box-shadow: 0 6px 16px rgba(51, 57, 59, .16); } +button.btn:active { transform: translateY(1px); } +button.btn.ghost { background: #fff; color: var(--text); box-shadow: inset 0 0 0 1px #e7e8e4; } +button.btn.ghost:hover { background: var(--field); box-shadow: inset 0 0 0 1px #dcddd9; } +button.btn.danger { background: #fff; color: var(--danger); box-shadow: inset 0 0 0 1px #f0dfe2; } +button.btn.danger:hover { background: var(--rose-soft); } +button.btn:disabled { opacity: .45; cursor: default; box-shadow: none; } +.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; } +.toolbar input[type=search] { max-width: 320px; } +.toolbar select { max-width: 280px; } +.spacer { flex: 1; } +.muted { color: var(--muted); } +.right { text-align: right; white-space: nowrap; } +.pill { + display: inline-block; white-space: nowrap; font-size: 11px; padding: 3px 10px; border-radius: 999px; + background: #e6f6ee; color: var(--mint-text); +} +.pill.off { background: var(--rose-soft); color: var(--rose-text); } +.pill.wait { background: #fbf3d9; color: var(--warn); } +.pill.sky { background: var(--sky-soft); color: var(--sky-text); } +.pill.grey { background: #eeefed; color: var(--muted); } +.dir-row td { background: #fafaf7; font-weight: 500; } +.note { margin: 0 0 14px; color: var(--muted); font-size: 12px; line-height: 1.55; } +.warn { color: var(--warn); font-size: 12px; } +h3.sub { font-weight: 500; font-size: 15px; margin: 2px 0 12px; } +.pager { display: flex; gap: 10px; align-items: center; justify-content: flex-end; margin-top: 14px; } +.num { width: 110px; } .sortnum { width: 70px; } +.toast { + position: fixed; bottom: 22px; right: 22px; background: var(--text); color: #fff; padding: 12px 18px; + border-radius: 16px; opacity: 0; transition: .25s; pointer-events: none; z-index: 100; font-size: 13px; +} +.toast.show { opacity: 1; } +.toast.bad { background: var(--danger); } + +dialog { border: 0; border-radius: 28px; padding: 0; max-width: 580px; width: 92%; box-shadow: 0 30px 80px rgba(51, 57, 59, .18); color: var(--text); } +dialog.wide { max-width: 940px; } +dialog::backdrop { background: rgba(240, 230, 231, .55); backdrop-filter: blur(6px); } +.dlg-head { padding: 22px 26px 8px; font-family: var(--font-display); font-weight: 500; font-size: 17px; letter-spacing: -0.02em; } +.dlg-body { padding: 14px 26px; max-height: 70vh; overflow: auto; } +.dlg-foot { padding: 16px 26px 22px; display: flex; gap: 10px; justify-content: flex-end; } +.field { margin-bottom: 14px; } +.field span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 12px; } +.row2 { display: flex; gap: 12px; } .row2 > * { flex: 1; } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; max-width: 900px; } +.form-grid .wide { grid-column: 1 / -1; } +.img-field { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.img-preview { width: 96px; height: 72px; object-fit: cover; border-radius: 14px; background: var(--field); } +.img-field input[type=file] { width: auto; flex: 1; } +.file-link { font-size: 13px; word-break: break-all; } +.rv-text { white-space: pre-line; max-height: 4.6em; overflow: hidden; } +.logo-cell img { max-width: 90px; max-height: 40px; object-fit: contain; } +.doc-photo { width: 44px; height: 52px; object-fit: cover; border-radius: 14px; background: var(--field); display: block; } +.checklist { border: 1px solid #ebebe7; border-radius: var(--r-field); overflow: hidden; } +.checklist input[type=search] { border: 0; border-bottom: 1px solid var(--line); border-radius: 0; } +.checklist-box { max-height: 340px; overflow: auto; padding: 6px 12px; } +.checklist-group { font-weight: 500; margin: 12px 0 4px; display: flex; align-items: center; gap: 8px; } +.checklist label { display: flex; gap: 8px; align-items: flex-start; padding: 3px 0 3px 16px; cursor: pointer; } +.checklist label input { width: auto; margin-top: 3px; } +.checklist .count { font-weight: 400; color: var(--muted); font-size: 11px; } +.rte { border: 1px solid #ebebe7; border-radius: var(--r-field); background: #fff; overflow: hidden; } +.rte-bar { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px; border-bottom: 1px solid var(--line); background: #fbfbfa; position: sticky; top: -14px; z-index: 1; } +.rte-bar button { font-size: 12px; padding: 5px 10px; border: 0; border-radius: 10px; background: #fff; box-shadow: inset 0 0 0 1px #ebebe7; cursor: pointer; } +.rte-bar button:hover { box-shadow: inset 0 0 0 1px var(--text); } +.rte-bar .sep { width: 1px; background: var(--line); margin: 2px 4px; } +.rte-area { min-height: 320px; padding: 14px 16px; outline: none; line-height: 1.65; font-family: 'Segoe UI', system-ui, sans-serif; } +.rte-area:focus { box-shadow: inset 0 0 0 2px rgba(141, 203, 233, .5); } +.rte-area h2 { font-size: 19px; margin: 18px 0 8px; } .rte-area h3 { font-size: 16px; margin: 14px 0 6px; } +.rte-area p { margin: 0 0 10px; } .rte-area img { max-width: 100%; height: auto; border-radius: 12px; } +.rte-area a { color: var(--sky-text); } +.rte-src { display: block; width: 100%; min-height: 320px; border: 0; border-radius: 0; font: 12px/1.5 ui-monospace, Consolas, monospace; } + +/* ---------- главный экран ---------- */ +.dash { display: grid; grid-template-columns: minmax(0, 40fr) minmax(0, 60fr); gap: 10px 26px; align-items: start; } +.dash__heart { position: relative; min-height: 640px; } +.dash__right { display: flex; flex-direction: column; gap: 18px; min-width: 0; } + +.summary { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; padding: 38px 6px 12px; flex-wrap: wrap; } +.summary__title { + margin: 0; font-family: var(--font-display); font-weight: 500; font-size: clamp(46px, 5.4vw, 78px); line-height: .94; letter-spacing: -0.065em; +} +.summary__stats { display: flex; gap: clamp(18px, 3vw, 40px); padding-bottom: 14px; } +.stat { background: none; border: 0; padding: 0; text-align: center; cursor: pointer; } +.stat b { display: block; font-family: var(--font-display); font-weight: 500; font-size: clamp(22px, 2.1vw, 30px); letter-spacing: -0.04em; line-height: 1.1; } +.stat span { display: block; color: var(--muted); font-size: 11px; margin-top: 4px; } +.stat:hover b { color: #000; } + +.heart { position: absolute; inset: 0 0 0 0; } +.heart__img { position: absolute; left: 4%; top: 7%; width: 88%; max-width: 560px; height: auto; object-fit: contain; + filter: drop-shadow(0 30px 40px rgba(150, 40, 50, .18)); } +.heart__missing { + position: absolute; left: 6%; top: 12%; width: 80%; aspect-ratio: 5 / 6; border-radius: 48% 52% 46% 54% / 40% 42% 58% 60%; + border: 1.5px dashed #dcc4c8; background: radial-gradient(circle at 45% 40%, rgba(255,255,255,.7), rgba(255,255,255,.15) 70%); + display: grid; place-items: center; text-align: center; padding: 16%; color: var(--muted); font-size: 12px; line-height: 1.5; +} +.heart__missing > div { max-width: 52%; margin: 0 22% 8% 0; } +.heart__missing b { display: block; color: var(--text); font-weight: 500; font-size: 14px; margin-bottom: 6px; } +.heart__missing code { font-size: 11px; background: #fff; padding: 1px 6px; border-radius: 6px; } +.marker { + position: absolute; width: 16px; height: 16px; margin: -8px 0 0 -8px; border-radius: 50%; + background: #fff; box-shadow: 0 0 0 7px rgba(255, 255, 255, .38), 0 2px 8px rgba(0,0,0,.12); z-index: 2; +} +.marker::after { content: ''; position: absolute; inset: -7px; border-radius: 50%; border: 1px solid rgba(255,255,255,.7); animation: pulse 3.2s var(--ease) infinite; } +.marker__line { position: absolute; height: 1px; background: rgba(255,255,255,.75); transform-origin: 0 50%; z-index: 1; } +@keyframes pulse { 0% { transform: scale(.8); opacity: .9; } 70% { transform: scale(1.9); opacity: 0; } 100% { opacity: 0; } } +.vital { + position: absolute; z-index: 3; min-width: 124px; padding: 14px 18px 13px; border: 0; cursor: pointer; + background: rgba(255, 255, 255, .94); border-radius: 22px; box-shadow: var(--shadow); text-align: center; + transition: transform .3s var(--ease), box-shadow .3s; +} +.vital:hover { transform: translateY(-3px); box-shadow: var(--shadow-lift); } +.vital__label { display: block; color: var(--muted); font-size: 11px; } +.vital__value { display: block; font-family: var(--font-display); font-weight: 400; font-size: 24px; letter-spacing: -0.04em; line-height: 1.25; margin-top: 2px; } +.vital__unit { display: block; font-size: 11px; color: var(--text); } + +.trends { padding: 22px 26px 14px; } +.trends__head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; } +.trends__head h2 { margin: 0; font-weight: 500; font-size: 13px; } +.trends__demo { font-size: 11px; color: var(--warn); background: #fbf3d9; border-radius: 999px; padding: 2px 10px; } +.trends__legend { margin-left: auto; display: flex; gap: 14px; font-size: 11px; color: var(--muted); } +.trends__legend i { display: inline-block; width: 18px; height: 8px; border-radius: 4px; margin-right: 6px; vertical-align: middle; } +.chart { position: relative; margin-top: 10px; } +.chart svg { display: block; width: 100%; height: auto; overflow: visible; } +.chart__line { fill: none; stroke: #8b8f8d; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } +.chart__axis { font-size: 15px; fill: #a9ada9; font-family: var(--font); } +.chart__frame { fill: none; stroke: #e2e3e0; stroke-width: 1; stroke-dasharray: 3 4; } +.chart__label { font-size: 10px; fill: var(--muted); font-family: var(--font); } +.chart__hit { fill: transparent; cursor: crosshair; } +.chart__dot { fill: #fff; stroke: var(--text); stroke-width: 1.5; } +.chart__guide { stroke: #d9dad7; stroke-width: 1; stroke-dasharray: 2 3; } +.tooltip { + position: absolute; pointer-events: none; transform: translate(-50%, calc(-100% - 12px)); white-space: nowrap; + background: var(--card); border-radius: 14px; padding: 8px 12px; font-size: 11px; box-shadow: var(--shadow-lift); z-index: 5; +} +.tooltip b { display: block; font-weight: 500; font-size: 13px; } + +.leads-card { padding: 20px 22px 10px; overflow-x: auto; } +.leads-table th { padding-top: 4px; } +.leads-table td { font-size: 12.5px; padding: 12px; } +.leads-table tbody tr { cursor: pointer; } +.leads-table td small { display: block; font-size: 10px; color: var(--muted); } +.leads-table .strong { font-weight: 500; } +.leads-table .soft { color: var(--muted); } +.card-link { border: 0; background: none; padding: 0; color: var(--muted); font-size: 12px; cursor: pointer; margin-left: auto; } +.card-link:hover { color: var(--text); } +.lead-detail dt { color: var(--muted); font-size: 11px; margin-top: 10px; } +.lead-detail dd { margin: 2px 0 0; } +.lead-detail { margin: 0 0 14px; } + +/* ---------- шапка: дата у календаря, подзаголовок раздела ---------- */ +.round--date { width: auto; border-radius: 22px; padding: 0 16px 0 14px; gap: 10px; grid-auto-flow: column; } +.round--date svg { width: 19px; height: 19px; } +.cal-date { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.2; text-align: left; } +.cal-date b { font-weight: 500; font-size: 13px; white-space: nowrap; } +.cal-date small { color: var(--muted); font-size: 11px; } +.page-sub { margin: 2px 0 18px; color: var(--muted); font-size: 15px; } + +/* ---------- раздел «Врачи» ---------- */ +.dr-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 20px; align-items: start; } +.dr-card { padding: 18px 18px 8px; } +.dr-toolbar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; } +.dr-search { position: relative; flex: 1 1 280px; max-width: 400px; } +.dr-search > span { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); display: grid; pointer-events: none; } +.dr-search svg { width: 17px; height: 17px; stroke: var(--muted); fill: none; stroke-width: 1.6; stroke-linecap: round; } +.dr-search input { height: 44px; padding-left: 40px; border-radius: 14px; } +#dr-filter { height: 44px; width: auto; min-width: 220px; border-radius: 14px; } +.dr-count { color: var(--muted); font-size: 14px; } +.dr-note { + display: flex; gap: 12px; align-items: center; margin-bottom: 10px; padding: 10px 12px 10px 14px; + background: #eef5fc; border-radius: 14px; color: #5b6b78; font-size: 12px; line-height: 1.5; +} +.dr-note p { margin: 0; flex: 1; } +.dr-note__icon { display: grid; flex-shrink: 0; } +.dr-note__icon svg { width: 20px; height: 20px; fill: #5b9cf0; stroke: #fff; stroke-width: 1.8; stroke-linecap: round; } +.dr-note__close { border: 0; background: none; width: 30px; height: 30px; border-radius: 10px; display: grid; place-items: center; cursor: pointer; flex-shrink: 0; } +.dr-note__close:hover { background: rgba(255, 255, 255, .8); } +.dr-note__close svg { width: 16px; height: 16px; stroke: var(--text); fill: none; stroke-width: 1.8; stroke-linecap: round; } +.dr-scroll { overflow-x: auto; } +.dr-table { min-width: 860px; } +.dr-table thead th { background: #f8f9fa; padding-top: 12px; padding-bottom: 12px; font-size: 12px; } +.dr-table thead th:first-child { border-radius: 12px 0 0 12px; } +.dr-table thead th:last-child { border-radius: 0 12px 12px 0; } +.dr-table th button { border: 0; background: none; padding: 0; font: inherit; color: inherit; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; } +.dr-table th button svg { width: 14px; height: 14px; stroke: var(--muted-2); fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } +.dr-table th[aria-sort="ascending"] button, .dr-table th[aria-sort="descending"] button { color: var(--text); } +.dr-table th[aria-sort="ascending"] svg, .dr-table th[aria-sort="descending"] svg { stroke: var(--text); } +.dr-table td { padding: 10px; } +.dr-who { display: flex; gap: 14px; align-items: center; min-width: 240px; } +.dr-photo { width: 52px; height: 52px; border-radius: 14px; object-fit: cover; object-position: 50% 15%; background: var(--field); flex-shrink: 0; } +.dr-photo--empty { display: grid; place-items: center; color: var(--muted); font-size: 13px; font-weight: 500; } +.dr-name { border: 0; background: none; padding: 0; text-align: left; font-weight: 500; font-size: 14px; cursor: pointer; } +.dr-name:hover { color: #3a7bd5; } +.dr-pos { color: var(--muted); font-size: 12.5px; margin-top: 2px; } +.chip { display: inline-block; white-space: nowrap; padding: 6px 12px; border-radius: 10px; background: #f3f4f5; font-size: 12px; } +.dr-num { font-weight: 500; } +.dr-branches { font-size: 12px; color: var(--muted); display: inline-block; max-width: 200px; } +.pill.warn-pill { background: #fdf0e3; color: #d0772b; padding: 5px 12px; } +.pill.dot { padding: 5px 12px 5px 10px; } +.pill.dot::before { content: ""; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: currentColor; margin-right: 7px; vertical-align: 1px; } +.dr-actions { white-space: nowrap; text-align: right; } +.dr-actions > * { vertical-align: middle; } +.soft-btn { + display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px; border: 0; border-radius: 12px; + background: #edf4fd; color: #3a6fb7; font-size: 12.5px; cursor: pointer; transition: background-color .2s; +} +.soft-btn:hover { background: #e0ecfb; } +.soft-btn svg { width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; } +.icon-btn { + width: 38px; height: 38px; margin-left: 6px; border-radius: 50%; border: 1px solid #ebecea; background: #fff; cursor: pointer; + display: inline-grid; place-items: center; transition: background-color .2s, border-color .2s; +} +.icon-btn:hover { background: var(--field); border-color: #dcddd9; } +.icon-btn svg { width: 16px; height: 16px; stroke: var(--text); fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } +.icon-btn svg circle { fill: var(--text); stroke: none; } +.dr-empty { padding: 28px 12px; text-align: center; } +.menu { position: absolute; z-index: 60; min-width: 230px; padding: 6px; background: var(--card); border-radius: 16px; box-shadow: var(--shadow-lift); } +.menu button { display: block; width: 100%; text-align: left; border: 0; background: none; padding: 9px 12px; border-radius: 10px; cursor: pointer; font-size: 13px; } +.menu button:hover, .menu button:focus-visible { background: var(--field); outline: none; } +.menu button.danger { color: var(--danger); } + +.dr-side { display: flex; flex-direction: column; gap: 18px; position: sticky; top: 18px; } +.dr-side .card { margin-bottom: 0; } +.dr-quick { padding: 20px 18px; } +.dr-quick h2 { margin: 0 0 14px 4px; font-weight: 600; font-size: 16px; } +.quick { + display: flex; align-items: center; gap: 12px; width: 100%; height: 48px; padding: 0 16px; margin-top: 10px; + border: 1px solid #f0f1ef; border-radius: 14px; background: #fff; cursor: pointer; font-size: 14px; text-align: left; + transition: background-color .2s, box-shadow .2s, transform .2s var(--ease); +} +.quick:first-of-type { margin-top: 0; } +.quick:hover { background: var(--field); } +.quick svg { width: 20px; height: 20px; stroke: #3a6fb7; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; } +.quick.primary { border: 0; color: #fff; background: linear-gradient(100deg, #3f86f2, #6aa8f5); box-shadow: 0 10px 22px rgba(63, 134, 242, .28); } +.quick.primary:hover { box-shadow: 0 14px 26px rgba(63, 134, 242, .36); transform: translateY(-1px); } +.quick.primary svg { stroke: #fff; } +.dr-summary { padding: 20px 18px 18px; } +.dr-summary__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } +.dr-summary__head span { font-size: 14px; } +.dr-summary__head b { display: block; font-family: var(--font-display); font-weight: 500; font-size: 32px; letter-spacing: -0.04em; line-height: 1.1; } +.dr-summary__icon { width: 56px; height: 56px; border-radius: 50%; background: #eaf2fd; display: grid; place-items: center; } +.dr-summary__icon svg { width: 28px; height: 28px; fill: #5b9cf0; stroke: #5b9cf0; stroke-width: 1; } +.dr-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } +.mini-stat { border: 0; border-radius: 14px; padding: 10px 10px 8px; text-align: left; cursor: pointer; transition: box-shadow .2s, transform .2s var(--ease); } +.mini-stat span { display: flex; align-items: center; gap: 5px; font-size: 11px; white-space: nowrap; } +.mini-stat i { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } +.mini-stat b { display: block; font-family: var(--font-display); font-weight: 500; font-size: 22px; letter-spacing: -0.03em; margin-top: 4px; color: var(--text); } +.mini-stat:hover { transform: translateY(-1px); } +.mini-stat[aria-pressed="true"] { box-shadow: inset 0 0 0 1.5px currentColor; } +.mini-stat.ok { background: #eaf7f0; color: #2f7d5a; } .mini-stat.ok i { background: #3fb27f; } +.mini-stat.warn { background: #fdf2e6; color: #b76a24; } .mini-stat.warn i { background: #f0913a; } +.mini-stat.rose { background: #fdeef1; color: #b8566c; } .mini-stat.rose i { background: #e8708a; } + +.sp-dlg { max-width: 620px; } +.sp-row { display: flex; align-items: center; gap: 8px; padding: 10px 4px; border-bottom: 1px solid var(--line); } +.sp-row:last-child { border-bottom: 0; } +.sp-row > div { flex: 1; min-width: 0; } +.sp-row b { font-weight: 500; display: block; } +.sp-row small { color: var(--muted); font-size: 11.5px; } +.sp-row .icon-btn { margin-left: 0; } + +@media (max-width: 1280px) { + .dr-layout { grid-template-columns: minmax(0, 1fr); } + .dr-side { position: static; display: grid; grid-template-columns: 1fr 1fr; order: -1; } +} +@media (max-width: 760px) { + .dr-side { grid-template-columns: 1fr; } + .round--date .cal-date { display: none; } + .round--date { width: 44px; padding: 0; } +} + +/* ---------- «Слайды на главной» ---------- */ +.bn-hero { + position: relative; overflow: hidden; min-height: 260px; margin: 4px 0 -34px; padding: 34px 28px 64px; + border-radius: 30px 30px 0 0; + background: linear-gradient(100deg, rgba(246, 248, 252, 1) 0%, rgba(233, 241, 250, .9) 45%, rgba(214, 229, 246, .7) 100%); +} +.bn-hero.has-photo { background: url(assets/hall.webp) right 42% / 68% auto no-repeat, #f5f7fa; } +.bn-hero--services.has-photo { background: url(assets/lobby.webp) right 48% / 68% auto no-repeat, #f5f7fa; } +/* левая часть остаётся светлой: текст читается поверх фото */ +.bn-hero::before { + content: ""; position: absolute; inset: 0; pointer-events: none; + background: linear-gradient(90deg, rgba(245, 247, 250, 1) 0%, rgba(245, 247, 250, 1) 32%, rgba(245, 247, 250, .6) 40%, rgba(245, 247, 250, 0) 50%); +} +.bn-hero::after { + content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 70px; pointer-events: none; + background: linear-gradient(to bottom, rgba(247, 248, 250, 0), rgba(247, 248, 250, .9)); +} +.bn-hero > * { position: relative; z-index: 1; } +.bn-hero__text { max-width: 520px; } +.bn-hero h1 { margin: 0; font-family: var(--font-display); font-weight: 500; font-size: clamp(34px, 3.6vw, 52px); letter-spacing: -0.05em; line-height: 1.02; } +.bn-hero__sub { margin: 6px 0 14px; font-size: clamp(16px, 1.4vw, 20px); color: #4d5558; } +.bn-hero__note { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.55; } +.bn-add { + position: absolute; top: 0; right: 0; display: inline-flex; align-items: center; gap: 10px; height: 48px; padding: 0 26px 0 22px; + border: 0; border-radius: 0 0 0 22px; color: #fff; font-size: 14px; cursor: pointer; + background: linear-gradient(100deg, #3f86f2, #6aa8f5); box-shadow: 0 10px 22px rgba(63, 134, 242, .28); + transition: box-shadow .2s, filter .2s; +} +.bn-add:hover { box-shadow: 0 14px 26px rgba(63, 134, 242, .36); filter: brightness(1.04); } +.bn-add svg { width: 18px; height: 18px; stroke: #fff; fill: none; stroke-width: 1.8; stroke-linecap: round; } +.bn-card { position: relative; z-index: 2; padding: 18px 18px 10px; } +.bn-toolbar { display: flex; align-items: center; gap: 12px; margin: 0 4px 12px; flex-wrap: wrap; } +.bn-toolbar b { font-weight: 500; font-size: 15px; } +.bn-toolbar span { font-size: 12px; } +#bn-filter { width: auto; min-width: 220px; height: 42px; border-radius: 14px; } +.bn-table { min-width: 1000px; } +.bn-table thead th { font-size: 12px; } +.bn-table td { padding: 10px 8px; } +.bn-table tbody tr.dragging { opacity: .45; } +.bn-table tbody tr.drop td { box-shadow: inset 0 2px 0 var(--blue-deep); } +.grip { border: 0; background: none; width: 28px; height: 36px; border-radius: 8px; cursor: grab; display: grid; place-items: center; color: var(--muted); } +.grip:hover { background: var(--field); color: var(--text); } +.grip svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: round; } +.bn-thumb { width: 220px; aspect-ratio: 20 / 7; border-radius: 12px; overflow: hidden; background: var(--field); position: relative; } +.bn-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } +.bn-thumb--made { color: #fff; padding: 10px 12px; display: flex; flex-direction: column; justify-content: center; } +.bn-thumb--made b { font-weight: 600; font-size: 13px; line-height: 1.15; max-width: 62%; } +.bn-thumb--made small { font-size: 10px; opacity: .85; margin-top: 4px; max-width: 62%; } +.bn-thumb__doc { position: absolute; right: 0; bottom: 0; width: 40% !important; height: 100% !important; object-fit: cover; object-position: 50% 20%; } +.bn-title b { font-weight: 500; font-size: 13.5px; display: block; max-width: 220px; } +.bn-small { font-size: 12.5px; } +.bn-link { max-width: 170px; word-break: break-all; } +.bn-dates { white-space: pre-line; } +.bn-sort { display: inline-grid; place-items: center; min-width: 40px; height: 34px; padding: 0 8px; border-radius: 10px; background: #f3f4f5; font-size: 13px; } +.pill.bn-pill { padding: 7px 14px; font-size: 12px; } +.bn-edit { + display: inline-flex; align-items: center; gap: 8px; height: 40px; padding: 0 16px; border-radius: 12px; border: 1px solid #e7e8e4; + background: #fff; cursor: pointer; font-size: 13px; transition: background-color .2s; +} +.bn-edit:hover { background: var(--field); } +.bn-edit svg { width: 16px; height: 16px; stroke: var(--text); fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } + +@media (max-width: 900px) { + .bn-hero { padding: 26px 20px 60px; } + .bn-hero::before { background: linear-gradient(90deg, rgba(247, 248, 250, 1) 0%, rgba(247, 248, 250, .9) 55%, rgba(247, 248, 250, .4) 100%); } + .bn-thumb { width: 170px; } +} +@media (max-width: 760px) { + .bn-add { position: relative; border-radius: 999px; margin-top: 18px; } + .bn-hero { border-radius: 24px 24px 0 0; } +} + +/* ---------- «Услуги»: направления-аккордеон ---------- */ +.hero-subtabs .subtabs { margin: 18px 0 0; background: rgba(255, 255, 255, .75); } +.bn-hero--services { padding-bottom: 58px; } +/* общие шапки с фото у разделов с вкладками («Контент сайта», «Отзывы», «Настройки») */ +.bn-hero--group { padding-bottom: 58px; } +.bn-hero--lobby.has-photo { background: url(assets/lobby.webp) right 48% / 68% auto no-repeat, #f5f7fa; } +.hero-body > .card:first-child { position: relative; z-index: 2; } +.sv-card { padding: 18px 18px 14px; } +.sv-toolbar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; } +.sv-toolbar select { height: 44px; width: auto; min-width: 190px; border-radius: 14px; } +.sv-count { color: var(--muted); font-size: 13px; } +.sv-count u { text-decoration-color: #c9ccc8; text-underline-offset: 3px; color: var(--text); } +.sv-toolbar .bn-edit svg { width: 17px; height: 17px; } +.sv-group { border: 1px solid #f0f1ef; border-radius: 20px; margin-bottom: 10px; background: #fcfcfc; transition: box-shadow .2s, background-color .2s; } +.sv-group.open { background: #fff; box-shadow: 0 8px 22px rgba(51, 57, 59, .05); padding-bottom: 8px; } +.sv-top { display: flex; align-items: center; gap: 6px; padding-right: 12px; } +.sv-head { flex: 1; display: flex; align-items: center; gap: 16px; padding: 10px 12px 10px 10px; border: 0; background: none; cursor: pointer; text-align: left; border-radius: 20px; min-width: 0; } +.sv-icon { width: 58px; height: 58px; border-radius: 16px; display: grid; place-items: center; flex-shrink: 0; } +.sv-icon svg { width: 28px; height: 28px; stroke: currentColor; fill: none; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; } +.sv-head__text { flex: 1; min-width: 0; } +.sv-head__text b { display: block; font-weight: 600; font-size: 16px; } +.sv-head__text small { color: var(--muted); font-size: 12.5px; } +.sv-chev { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; } +.sv-chev svg { width: 18px; height: 18px; stroke: var(--text); fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; transition: transform .25s var(--ease); } +.sv-group.open .sv-chev svg { transform: rotate(180deg); } +.sv-head:hover .sv-chev { background: var(--field); } +.sv-dir-edit { margin-left: 0; opacity: 0; transition: opacity .2s; } +.sv-group:hover .sv-dir-edit, .sv-dir-edit:focus-visible { opacity: 1; } +.sv-table { min-width: 900px; } +.sv-table thead th { background: #f8f9fa; padding-top: 10px; padding-bottom: 10px; font-size: 12px; } +.sv-table td { padding: 8px 8px; font-size: 13px; } +.sv-table .sv-name { font-weight: 500; } +.sv-path { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sv-table .bn-edit { height: 36px; } +.sv-table .icon-btn { width: 36px; height: 36px; } +.sv-table tbody tr.dragging { opacity: .45; } +.sv-table tbody tr.drop td { box-shadow: inset 0 2px 0 var(--blue-deep); } +.sv-table, .sv-group .dr-scroll { margin: 0 8px; width: calc(100% - 16px); } + +@media (max-width: 760px) { + .sv-toolbar .dr-search { flex-basis: 100%; max-width: none; } + .sv-toolbar select { flex: 1 1 40%; min-width: 0; } + .sv-icon { width: 46px; height: 46px; } + .sv-dir-edit { opacity: 1; } +} + +.legal-row { display: flex; align-items: center; gap: 14px; padding: 12px 4px; border-bottom: 1px solid var(--line); } +.legal-row:last-child { border-bottom: 0; } +.legal-row > div { flex: 1; min-width: 0; } +.legal-row b { display: block; font-weight: 500; } +.legal-row small { color: var(--muted); font-size: 12px; } +.legal-row small a { color: var(--sky-text); } + +/* ---------- «Статистика» ---------- */ +.bn-hero--stats.has-photo { background: url(assets/hall.webp) right 40% / 62% auto no-repeat, #f5f7fa; } +.bn-hero--stats { padding-bottom: 28px; margin-bottom: 18px; border-radius: 30px; } +.bn-hero--stats::after { display: none; } +.st-controls { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 22px; } +.st-presets { display: flex; gap: 4px; padding: 5px; background: rgba(255, 255, 255, .8); border-radius: 18px; backdrop-filter: blur(6px); } +.st-presets button { border: 0; background: none; padding: 9px 16px; border-radius: 14px; cursor: pointer; color: var(--muted); font-size: 13px; } +.st-presets button:hover { color: var(--text); } +.st-presets button[aria-pressed="true"] { background: linear-gradient(100deg, #3f86f2, #6aa8f5); color: #fff; box-shadow: 0 6px 16px rgba(63, 134, 242, .28); } +.st-dates { display: flex; align-items: center; gap: 8px; padding: 0 14px; height: 48px; background: rgba(255, 255, 255, .9); border-radius: 16px; } +.st-dates svg { width: 18px; height: 18px; stroke: var(--text); fill: none; stroke-width: 1.6; stroke-linecap: round; display: block; } +.st-dates input { border: 0; padding: 4px; width: 132px; background: transparent; } +#st-branch { width: auto; min-width: 200px; height: 48px; border-radius: 16px; margin-left: auto; background: rgba(255, 255, 255, .92); } +.st-kpis { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; margin-bottom: 14px; } +.kpi { display: flex; gap: 14px; align-items: flex-start; padding: 18px; margin: 0; } +.kpi__icon { width: 48px; height: 48px; border-radius: 14px; display: grid; place-items: center; flex-shrink: 0; } +.kpi__icon svg { width: 24px; height: 24px; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } +.kpi__label { display: block; font-size: 13px; } +.kpi__value { font-family: var(--font-display); font-weight: 500; font-size: 30px; letter-spacing: -0.04em; line-height: 1.2; margin-right: 8px; } +.kpi__delta { font-size: 12px; color: var(--muted); white-space: nowrap; } +.kpi__delta.up { color: #2f9a6a; } .kpi__delta.down { color: var(--danger); } +.kpi small { display: block; color: var(--muted); font-size: 11.5px; margin-top: 2px; } +.st-grid2 { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); gap: 14px; } +.st-grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } +.st-card { margin-bottom: 14px; padding: 20px 22px; min-width: 0; } +.st-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; } +.st-head h2 { margin: 0; font-weight: 600; font-size: 16px; } +.st-head select { width: auto; height: 38px; border-radius: 12px; } +.st-chart svg { display: block; width: 100%; height: auto; } +.st-grid { stroke: #eef0f1; stroke-width: 1; } +.st-axis { font-size: 11px; fill: #a3a8ac; font-family: var(--font); } +.st-line { fill: none; stroke: #4f8ff0; stroke-width: 2.4; stroke-linejoin: round; stroke-linecap: round; } +.st-guide { stroke: #c9d3dd; stroke-dasharray: 3 3; } +.st-dot { fill: #fff; stroke: #4f8ff0; stroke-width: 2.4; } +.st-donut { display: flex; align-items: center; gap: 22px; } +.st-donut svg { width: 170px; height: 170px; flex-shrink: 0; } +.st-donut__n { font-family: var(--font-display); font-weight: 500; font-size: 26px; fill: var(--text); } +.st-donut__u { font-size: 11px; fill: var(--muted); } +.st-legend { list-style: none; margin: 0; padding: 0; flex: 1; min-width: 0; } +.st-legend li { display: grid; grid-template-columns: 12px minmax(0, 1fr) 44px 36px; gap: 10px; align-items: center; padding: 6px 0; font-size: 13px; } +.st-legend i { width: 11px; height: 11px; border-radius: 50%; } +.st-legend span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.st-legend em, .st-bar em { font-style: normal; color: var(--muted); text-align: right; font-size: 12.5px; } +.st-legend b, .st-bar b { font-weight: 500; text-align: right; } +.st-bar { display: grid; grid-template-columns: minmax(90px, 1.1fr) minmax(60px, 1.3fr) 40px 32px; gap: 10px; align-items: center; padding: 8px 0; font-size: 13px; } +.st-bar span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.st-bar__track { height: 12px; border-radius: 6px; background: #eef1f4; overflow: hidden; } +.st-bar__track i { display: block; height: 100%; border-radius: 6px; background: linear-gradient(90deg, #4f8ff0, #8bbaf5); } +.st-top th { background: #f8f9fa; font-size: 12px; } +.st-top td { font-size: 13px; padding: 9px 10px; } +.st-empty { color: var(--muted); font-size: 13px; padding: 24px 0; text-align: center; margin: 0; } +.st-note { color: var(--muted); font-size: 11.5px; margin: 10px 0 0; line-height: 1.5; } +.st-footnote { margin: 4px 6px 20px; } +.st-tip { pointer-events: none; } + +@media (max-width: 1280px) { + .st-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .st-grid3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 1024px) { + .st-grid2 { grid-template-columns: minmax(0, 1fr); } + #st-branch { margin-left: 0; } +} +@media (max-width: 760px) { + .st-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } + .kpi { flex-direction: column; gap: 8px; padding: 14px; } + .st-grid3 { grid-template-columns: minmax(0, 1fr); } + .st-donut { flex-direction: column; align-items: stretch; } + .st-donut svg { margin: 0 auto; } + .st-presets { overflow-x: auto; max-width: 100%; } + .st-dates input { width: 118px; } +} + +/* ---------- «Чек-апы» ---------- */ +.ck-card { padding: 18px 18px 10px; } +.ck-table { min-width: 860px; } +.ck-table thead th { background: #f8f9fa; padding-top: 12px; padding-bottom: 12px; font-size: 12px; } +.ck-table thead th:first-child { border-radius: 12px 0 0 12px; } .ck-table thead th:last-child { border-radius: 0 12px 12px 0; } +.ck-table td { padding: 8px 10px; font-size: 13.5px; } +.ck-prog { display: flex; align-items: center; gap: 16px; min-width: 300px; } +.ck-thumb { width: 112px; height: 62px; border-radius: 12px; object-fit: cover; object-position: 80% 70%; background: var(--field); flex-shrink: 0; display: block; } +.ck-thumb--empty { display: grid; place-items: center; color: var(--muted); font-size: 11px; } +.ck-price { font-weight: 500; display: block; } +.ck-old { font-size: 12px; } +#ck-sort, #ck-aud { height: 44px; width: auto; min-width: 180px; border-radius: 14px; } + +/* ---------- «Дашборд врачей»: пастель — голубой, мятный, персиковый, пудровый ---------- */ +#tab-doctors-dash { --dd-ink: #243a5e; --dd-soft: #7d8aa3; --dd-line: #e6ecf5; } +.dd-top { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; margin: 6px 0 16px; } +.dd-title { margin: 0; font-family: var(--font-display); font-weight: 500; font-size: 30px; letter-spacing: -0.04em; color: var(--dd-ink); } +.dd-top [data-subtabs-slot] .subtabs { margin: 0; } +#dd-spec { width: auto; min-width: 230px; height: 44px; border-radius: 16px; border-color: var(--dd-line); background: rgba(255, 255, 255, .9); } +.dd-board { + display: grid; grid-template-columns: minmax(0, 40fr) minmax(0, 60fr); gap: 0; margin-bottom: 18px; + border-radius: 34px; overflow: hidden; box-shadow: 0 18px 50px rgba(64, 98, 150, .08); + background: linear-gradient(135deg, #f6f9fe 0%, #eef4fc 55%, #fbf2f2 100%); + border: 1px solid rgba(255, 255, 255, .9); +} +.dd-intro { padding: 34px 34px 30px; border-right: 1px solid var(--dd-line); } +.dd-intro h2 { margin: 0; font-family: var(--font-display); font-weight: 500; font-size: clamp(34px, 3.4vw, 50px); letter-spacing: -0.05em; line-height: 1.02; color: var(--dd-ink); } +.dd-desc { margin: 16px 0 28px; max-width: 420px; color: var(--dd-soft); font-size: 13.5px; line-height: 1.6; } +.dd-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } +.dd-stat { border: 0; background: none; padding: 0; text-align: left; cursor: pointer; color: var(--dd-ink); } +.dd-stat__icon { width: 50px; height: 50px; border-radius: 50%; display: grid; place-items: center; margin-bottom: 14px; box-shadow: 0 8px 18px rgba(64, 98, 150, .08); transition: transform .25s var(--ease); } +.dd-stat:hover .dd-stat__icon { transform: translateY(-2px); } +.dd-stat__icon svg { width: 22px; height: 22px; stroke: currentColor; fill: none; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; } +.dd-stat b { font-family: var(--font-display); font-weight: 500; font-size: 26px; letter-spacing: -0.04em; } +.dd-stat__of { font-size: 11px; color: var(--dd-soft); margin-left: 4px; } +.dd-stat__label { display: block; font-size: 12px; color: var(--dd-soft); margin-top: 4px; line-height: 1.35; } +.dd-overview { padding: 30px 30px 22px; min-width: 0; background: rgba(255, 255, 255, .55); } +.dd-overview__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; } +.dd-overview h2 { margin: 0 0 10px; font-family: var(--font-display); font-weight: 500; font-size: 24px; letter-spacing: -0.03em; color: var(--dd-ink); } +.dd-overview #dd-spec { height: 34px; min-width: 0; padding: 4px 30px 4px 12px; font-size: 12px; border-radius: 12px; } +.dd-total { padding: 10px 16px; border-radius: 18px; background: rgba(255, 255, 255, .85); box-shadow: 0 6px 18px rgba(64, 98, 150, .06); } +.dd-total b { display: block; font-family: var(--font-display); font-weight: 500; font-size: 24px; letter-spacing: -0.03em; color: var(--dd-ink); line-height: 1.15; } +.dd-total span { font-size: 11.5px; color: var(--dd-soft); } +.dd-chart { position: relative; margin-top: 8px; } +.dd-chart svg { display: block; width: 100%; height: auto; } +.dd-grid { stroke: #e7edf5; stroke-width: 1; } +.dd-axis { font-size: 10.5px; fill: #a0abbf; font-family: var(--font); } +.dd-cat { font-size: 11.5px; fill: #3d4f6e; font-family: var(--font); } +.dd-val { font-size: 11px; fill: #8f9bb2; font-family: var(--font); } +.dd-guide { stroke: #c4d0e1; stroke-dasharray: 3 3; } +.dd-hill { transition: opacity .2s; } +.dd-chart svg:hover .dd-hill { opacity: .55; } +.dd-chart svg:hover .dd-hill.on { opacity: 1; } +.dd-tip { transform: translate(-50%, calc(-100% - 10px)); } +.dd-tip__row { display: block; font-size: 11px; color: var(--muted); } +.dd-attention { display: grid; grid-template-columns: auto repeat(3, minmax(0, 1fr)); gap: 10px; align-items: center; margin-top: 6px; } +.dd-attention > div { display: contents; } +.dd-attention h3 { margin: 0 6px 0 0; font-size: 13px; font-weight: 600; color: var(--dd-ink); } +.dd-att { display: grid; grid-template-columns: 34px 1fr; grid-template-rows: auto auto; column-gap: 10px; align-items: center; width: 100%; text-align: left; + border: 0; background: rgba(255, 255, 255, .85); border-radius: 18px; padding: 10px 12px; margin-bottom: 0; cursor: pointer; + box-shadow: 0 6px 16px rgba(64, 98, 150, .06); transition: transform .2s var(--ease), box-shadow .2s; } +.dd-att:hover { transform: translateY(-1px); box-shadow: 0 10px 22px rgba(64, 98, 150, .1); } +.dd-att__icon { grid-row: span 2; width: 34px; height: 34px; border-radius: 50%; background: #fdeee2; color: #c9803f; display: grid; place-items: center; } +.dd-att__icon svg { width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } +.dd-att b { font-family: var(--font-display); font-weight: 500; font-size: 20px; color: var(--dd-ink); line-height: 1.1; } +.dd-att span:last-child { font-size: 11.5px; color: var(--dd-soft); line-height: 1.3; } +.dd-details-card { border-radius: 30px; padding: 26px 28px 18px; } +.dd-h2 { margin: 0 0 12px; font-family: var(--font-display); font-weight: 500; font-size: 22px; letter-spacing: -0.03em; color: #243a5e; } +.dd-row { display: grid; grid-template-columns: minmax(170px, 1.35fr) minmax(120px, 1.2fr) minmax(170px, 1.5fr) 44px 130px; gap: 16px; align-items: center; + width: 100%; padding: 13px 6px; border: 0; border-bottom: 1px solid #eef2f7; background: none; text-align: left; font-size: 13px; color: #243a5e; } +button.dd-row { cursor: pointer; border-radius: 12px; } +button.dd-row:hover { background: #f7f9fd; } +.dd-row:last-child { border-bottom: 0; } +.dd-row__label { font-weight: 500; } +.dd-row__bar { height: 8px; border-radius: 4px; background: #edf2f8; overflow: hidden; } +.dd-row__bar i { display: block; height: 100%; border-radius: 4px; } +.dd-row__text { color: #7d8aa3; } +.dd-row__pct { text-align: right; color: #7d8aa3; font-variant-numeric: tabular-nums; } +.dd-row__st { justify-self: start; font-size: 11.5px; padding: 4px 12px; border-radius: 999px; white-space: nowrap; } +.dd-row.ok .dd-row__bar i { background: linear-gradient(90deg, #8fd8b8, #6cc59f); } +.dd-row.ok .dd-row__st { background: #e3f5ec; color: #2f8a62; } +.dd-row.warn .dd-row__bar i { background: linear-gradient(90deg, #f7c9a0, #f0ad78); } +.dd-row.warn .dd-row__st { background: #fdeee1; color: #b86f32; } +.dd-row.empty .dd-row__st { background: #eef2f8; color: #8f9bb2; } +.dd-actions { display: flex; gap: 10px; flex-wrap: wrap; margin: 16px 0 24px; padding: 12px; border-radius: 26px; + background: rgba(255, 255, 255, .7); backdrop-filter: blur(6px); box-shadow: 0 10px 30px rgba(64, 98, 150, .06); } +.dd-act { flex: 1 1 150px; display: inline-flex; align-items: center; justify-content: center; gap: 10px; height: 46px; border: 0; border-radius: 16px; + background: #fff; color: #243a5e; font-size: 13px; cursor: pointer; transition: background-color .2s, transform .2s var(--ease); } +.dd-act:hover { background: #eef4fc; transform: translateY(-1px); } +.dd-act svg { width: 18px; height: 18px; stroke: #4a7fc4; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } + +@media (max-width: 1280px) { + .dd-board { grid-template-columns: minmax(0, 1fr); } + .dd-intro { border-right: 0; border-bottom: 1px solid var(--dd-line); } +} +@media (max-width: 900px) { + .dd-attention { grid-template-columns: minmax(0, 1fr); } + .dd-row { grid-template-columns: minmax(0, 1fr) 44px; gap: 6px 12px; } + .dd-row__bar { grid-column: 1 / -1; grid-row: 2; } + .dd-row__text { grid-column: 1; grid-row: 3; } + .dd-row__st { grid-column: 2; grid-row: 3; justify-self: end; } +} +@media (max-width: 760px) { + .dd-intro, .dd-overview { padding: 22px 18px; } + .dd-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } + .dd-board { border-radius: 26px; } + .dd-details-card { padding: 20px 16px; } +} + +/* ---------- «Отчёты» ---------- */ +#tab-reports { --rp-ink: #1f3558; --rp-soft: #7a879d; --rp-line: #e4eaf3; --rp-blue: #3f8df0; } +.rp-head { position: relative; margin: 8px 0 18px; } +.rp-head h1 { margin: 0; font-family: var(--font-display); font-weight: 600; font-size: 38px; letter-spacing: -0.04em; color: var(--rp-ink); } +#tab-reports { position: relative; isolation: isolate; } +/* атмосферные ленты: правый верхний угол, за карточками, не ловят клики */ +.rp-waves { position: absolute; top: -84px; right: -22px; width: min(820px, 64%); height: 420px; z-index: -1; pointer-events: none; } +#tab-reports > :not(.rp-waves) { position: relative; z-index: 1; } +.rp-tabs { display: inline-flex; gap: 4px; padding: 5px; margin-bottom: 18px; background: #fff; border-radius: 18px; box-shadow: var(--shadow); } +.rp-tabs button { border: 0; background: none; padding: 10px 22px; border-radius: 14px; cursor: pointer; font-size: 13.5px; color: var(--rp-ink); } +.rp-tabs button:hover { background: #f4f7fb; } +.rp-tabs button[aria-selected="true"] { background: #e3eefc; color: #2f6fd0; } +/* клиника отчётов: у DANA и «Очевидно» свои звонки, отчёты и дашборд */ +.rp-head:has(.rp-clinic) { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 18px; } +.rp-clinic { display: inline-flex; gap: 4px; padding: 4px; background: #fff; border-radius: 14px; box-shadow: var(--shadow); } +.rp-clinic button { border: 0; background: none; padding: 8px 16px; border-radius: 10px; cursor: pointer; font-size: 13.5px; color: var(--rp-ink); } +.rp-clinic button:hover { background: #f4f7fb; } +.rp-clinic button[aria-pressed="true"] { background: #e3eefc; color: #2f6fd0; font-weight: 600; } +.rp-share { width: min(560px, calc(100vw - 32px)); border: 0; border-radius: 20px; padding: 24px; box-shadow: var(--shadow); color: var(--rp-ink); } +.rp-share::backdrop { background: rgba(20, 30, 50, .35); } +.rp-share h2 { margin: 0 0 8px; font-size: 20px; } +.rp-share__state { font-weight: 600; } +.rp-share__out { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 12px 0; } +.rp-share__url { flex: 1 1 260px; min-width: 0; padding: 9px 12px; border: 1px solid #d6deea; border-radius: 10px; font: inherit; font-size: 13px; } +.rp-share__out p { flex-basis: 100%; margin: 0; } +.rp-share__actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; margin-top: 16px; } +.rp-grid { display: grid; grid-template-columns: minmax(0, 36fr) minmax(0, 64fr); gap: 18px; align-items: stretch; margin-bottom: 18px; } +.rp-grid .card { margin: 0; border-radius: 26px; } +.rp-upload { display: flex; flex-direction: column; gap: 12px; padding: 22px; align-self: start; } +.rp-drop { min-height: 230px; border: 1.5px dashed #b9d0f0; border-radius: 20px; background: linear-gradient(180deg, #f8fbff, #f3f7fd); + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 22px; text-align: center; cursor: pointer; + color: var(--rp-ink); transition: border-color .2s, background-color .2s; } +.rp-drop:hover, .rp-drop.over { border-color: var(--rp-blue); background: #eef5ff; } +.rp-drop__icon svg { width: 44px; height: 44px; stroke: var(--rp-blue); fill: none; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; } +.rp-drop b { font-size: 17px; font-weight: 600; margin-top: 6px; } +.rp-drop small { color: var(--rp-soft); font-size: 12.5px; } +.rp-drop__name { display: inline-flex; align-items: center; gap: 12px; font-size: 16px; word-break: break-all; } +.rp-ok { width: 24px; height: 24px; border-radius: 50%; background: #3fbd82; display: inline-grid; place-items: center; flex-shrink: 0; } +.rp-ok svg { width: 15px; height: 15px; stroke: #fff; fill: none; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; } +.rp-link { align-self: center; display: inline-flex; align-items: center; gap: 8px; border: 0; background: none; color: #2f7be0; cursor: pointer; font-size: 13.5px; padding: 4px 8px; border-radius: 10px; } +.rp-link:hover { background: #eef5ff; } +.rp-link svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.rp-partners { display: flex; flex-direction: column; gap: 10px; width: 100%; } +.rp-partner { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; border: 1px solid #e4ecf7; border-radius: 12px; background: #fafcff; } +.rp-partner__head { display: flex; flex-direction: column; gap: 2px; } +.rp-partner__head b { font-size: 13.5px; font-weight: 600; } +.rp-partner__head small { color: var(--rp-soft); font-size: 12px; line-height: 1.4; } +.rp-partner__files { display: flex; flex-direction: column; gap: 4px; } +.rp-partner__file { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 12.5px; } +.rp-partner__file span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rp-partner__file button { border: 0; background: none; color: var(--rp-soft); cursor: pointer; font-size: 16px; line-height: 1; padding: 2px 6px; } +.rp-partner .rp-link { align-self: flex-start; padding-left: 0; } +.rp-hint { margin: 0; color: var(--rp-soft); font-size: 12px; line-height: 1.5; text-align: center; } +.rp-start { display: flex; align-items: center; justify-content: center; gap: 10px; height: 54px; border: 0; border-radius: 18px; cursor: pointer; color: #fff; font-size: 15px; + background: linear-gradient(95deg, #2f7fe8 0%, #4aa0f3 60%, #8fb9f0 82%, #f2b58e 100%); box-shadow: 0 14px 26px rgba(47, 127, 232, .28); transition: transform .2s var(--ease), box-shadow .2s, opacity .2s; } +.rp-start:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 18px 32px rgba(47, 127, 232, .34); } +.rp-start:disabled { opacity: .45; cursor: default; box-shadow: none; } +.rp-start svg { width: 16px; height: 16px; fill: #fff; } +.rp-progress { padding: 24px 26px 22px; display: flex; flex-direction: column; gap: 16px; + background: linear-gradient(135deg, #ffffff 0%, #fbfcff 55%, #fdf5f7 100%); } +.rp-progress__head { display: flex; justify-content: space-between; gap: 14px; } +.rp-progress h2 { margin: 0; font-size: 19px; font-weight: 600; color: var(--rp-ink); } +.rp-progress__head p { margin: 4px 0 0; color: var(--rp-soft); font-size: 13px; } +.rp-clock { display: inline-flex; align-items: center; gap: 8px; color: var(--rp-soft); font-size: 14px; white-space: nowrap; height: 24px; } +.rp-clock svg { width: 20px; height: 20px; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: round; } +.rp-progress__body { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 26px; align-items: center; } +.rp-ring { position: relative; width: 190px; height: 190px; } +.rp-ring svg { width: 100%; height: 100%; } +#rp-ring-bar { transition: stroke-dasharray .45s var(--ease); } +.rp-ring__text { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; } +.rp-ring__text b { font-family: var(--font-display); font-weight: 600; font-size: 34px; letter-spacing: -0.03em; color: var(--rp-ink); } +.rp-ring__text span { font-size: 13px; color: var(--rp-soft); } +.rp-stages { list-style: none; margin: 0; padding: 0 0 0 22px; border-left: 1px solid var(--rp-line); display: flex; flex-direction: column; gap: 14px; } +.rp-stage { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; gap: 14px; align-items: start; } +.rp-stage b { display: block; font-weight: 500; font-size: 14px; color: var(--rp-ink); } +.rp-stage small { display: block; font-size: 12px; color: var(--rp-soft); min-height: 1em; } +.rp-stage time { font-size: 13px; color: var(--rp-soft); font-variant-numeric: tabular-nums; } +.rp-stage__mark { width: 22px; height: 22px; border-radius: 50%; border: 2px solid #cfd6e0; display: grid; place-items: center; margin-top: 1px; background: #fff; } +.rp-stage.done .rp-stage__mark { background: #3fbd82; border-color: #3fbd82; } +.rp-stage.done .rp-stage__mark svg { width: 14px; height: 14px; stroke: #fff; fill: none; stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round; } +.rp-stage.now .rp-stage__mark { border: 5px solid var(--rp-blue); animation: rp-pulse 1.4s ease-in-out infinite; } +.rp-stage.stop .rp-stage__mark { border-color: var(--rose); } +.rp-stage.wait b { color: #5d6b82; } +@keyframes rp-pulse { 50% { box-shadow: 0 0 0 6px rgba(63, 141, 240, .15); } } +.rp-result { display: flex; flex-direction: column; gap: 12px; } +.rp-download { display: inline-flex; align-items: center; gap: 10px; align-self: flex-start; padding: 11px 18px; border-radius: 14px; text-decoration: none; + background: #e8f6ee; color: #227a52; font-size: 13.5px; } +.rp-download:hover { background: #dcf1e5; } +.rp-download svg, .rp-btn-ghost svg { width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.rp-btn-ghost { display: inline-flex; align-items: center; gap: 8px; align-self: flex-start; height: 40px; padding: 0 16px; border-radius: 12px; border: 1px solid #e3e7ee; background: #fff; cursor: pointer; font-size: 13px; color: var(--rp-ink); } +.rp-btn-ghost:hover { background: #f6f8fb; } +.rp-sum { font-size: 12.5px; } +.rp-sum th { font-size: 11.5px; background: #f7f9fc; padding: 8px 10px; } +.rp-sum td { padding: 7px 10px; } +.rp-sum__total td { color: var(--rp-soft); } +.rp-note { margin: 0; font-size: 13px; color: var(--rp-soft); line-height: 1.5; } +.rp-control { margin: 0; font-size: 12.5px; padding: 8px 12px; border-radius: 12px; background: #f4f6fa; color: var(--rp-soft); } +.rp-control.ok { background: #e8f6ee; color: #227a52; } +.rp-control.bad { background: var(--rose-soft); color: var(--rose-text); } +.rp-sum__total td { font-weight: 500; } +.rp-info { display: flex; gap: 14px; align-items: center; margin-top: auto; padding: 14px 18px; border-radius: 18px; background: linear-gradient(90deg, #fdeef3, #fbeff6); } +.rp-info svg { width: 28px; height: 28px; stroke: #7065d8; fill: none; stroke-width: 1.5; stroke-linecap: round; display: block; } +.rp-info p { margin: 0; font-size: 12.5px; color: #6f6680; line-height: 1.5; } +.rp-info b { display: block; font-weight: 500; font-size: 13.5px; color: var(--rp-ink); } +.rp-recent { border-radius: 26px; padding: 20px 22px 12px; } +.rp-recent__head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; } +.rp-recent h2 { margin: 0; font-size: 18px; font-weight: 600; color: var(--rp-ink); } +.rp-table { min-width: 760px; } +.rp-table thead th { background: #f4f7fb; font-size: 12px; padding-top: 11px; padding-bottom: 11px; } +.rp-table thead th:first-child { border-radius: 12px 0 0 12px; } .rp-table thead th:last-child { border-radius: 0 12px 12px 0; } +.rp-table td { font-size: 13.5px; padding: 10px 12px; } +.rp-file { color: var(--rp-soft); } +.rp-file svg { width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 1.5; vertical-align: -3px; margin-right: 8px; } +.rp-table .bn-edit { height: 36px; text-decoration: none; color: var(--rp-ink); } +.rp-table .bn-edit svg { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 1.8; } +.rp-table .icon-btn svg circle { fill: var(--text); } + +@media (max-width: 1100px) { + .rp-grid { grid-template-columns: minmax(0, 1fr); } +} +@media (max-width: 640px) { + .rp-progress__body { grid-template-columns: minmax(0, 1fr); justify-items: center; } + .rp-stages { width: 100%; border-left: 0; padding-left: 0; } + .rp-waves { width: 100%; top: -70px; right: -12px; opacity: .7; } + .rp-tabs { display: flex; overflow-x: auto; } + .rp-tabs button { padding: 10px 14px; white-space: nowrap; } + .rp-head h1 { font-size: 30px; } +} + +/* нижнее меню телефона: 9 разделов и выход должны помещаться в ширину 360 px */ +@media (max-width: 460px) { + #app .side { left: 6px; right: 6px; padding: 0 6px; } + #app .side__nav { gap: 0; min-width: 0; } + #app .side .side__btn { width: 32px; height: 40px; border-radius: 12px; flex-shrink: 1; } + #app .side .side__btn svg { width: 19px; height: 19px; } + #app .side__foot { margin-left: 0; } +} + +/* ---------- «Звонки АТС» ---------- */ +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.px-card { border-radius: 26px; padding: 22px 24px 16px; } +.px-card h2 { margin: 0 0 10px; font-size: 18px; font-weight: 600; color: #1f3558; } +.px-head { display: flex; justify-content: space-between; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 14px; } +.px-head h2 { margin: 0; } +.px-bad { color: var(--rose-text) !important; } +/* карточка АТС */ +.px-ats { display: grid; grid-template-columns: auto 1fr auto; gap: 6px 20px; align-items: center; padding: 20px 24px; } +.px-ats__icon { width: 72px; height: 72px; border-radius: 22px; display: grid; place-items: center; background: #e3f6ec; color: #1f9d63; } +.px-ats__icon svg { width: 32px; height: 32px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.px-ats__body h2 { margin: 0 0 6px; font-size: 19px; } +.px-conn { display: flex; align-items: center; gap: 8px; margin: 0 0 6px; font-size: 14px; color: #33475f; } +.px-dot { width: 9px; height: 9px; border-radius: 50%; background: #22b07d; box-shadow: 0 0 0 3px rgba(34, 176, 125, .16); } +.px-conn.off .px-dot { background: #c9ced6; box-shadow: none; } +.px-copy { border: 0; background: none; padding: 4px; border-radius: 8px; color: #6f7f94; cursor: pointer; display: grid; } +.px-copy:hover { background: #eef3fa; color: #1f3558; } +.px-copy svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.px-state { margin: 0; font-size: 12.5px; line-height: 1.55; max-width: 780px; } +.px-ats #px-check { align-self: start; } +.px-ats .px-setup { grid-column: 1 / -1; margin: 10px 0 0; } +.px-setup { background: #f4f8fe; border-radius: 16px; padding: 14px 18px; font-size: 13px; line-height: 1.55; } +.px-setup p { margin: 0 0 8px; } +.px-setup pre { margin: 0 0 8px; padding: 10px 14px; background: #fff; border-radius: 12px; font-size: 12.5px; overflow-x: auto; } +/* загрузка звонков */ +.px-syncbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 22px; font-size: 13px; color: #33475f; } +.px-note { font-size: 12px; } +.px-btn { height: 44px; padding: 0 26px; border: 0; border-radius: 14px; color: #fff; font-size: 14px; cursor: pointer; + background: linear-gradient(100deg, #2f76f0, #4f95f6); box-shadow: 0 8px 18px rgba(47, 118, 240, .25); transition: filter .2s, box-shadow .2s; } +.px-btn:hover { filter: brightness(1.05); box-shadow: 0 10px 22px rgba(47, 118, 240, .32); } +.px-btn:disabled { opacity: .55; cursor: default; } +.px-show { height: 44px; padding: 0 20px; border: 1px solid #e3eaf5; border-radius: 14px; background: #f4f8fe; color: #2f76f0; font-size: 14px; cursor: pointer; } +.px-show:hover { background: #e9f1fd; } +.px-period { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +/* поля периода и календарь */ +.px-range { position: relative; display: flex; align-items: center; gap: 8px; } +.px-dash { color: #8b98aa; } +.px-date { display: inline-flex; align-items: center; gap: 10px; height: 44px; min-width: 150px; padding: 0 14px; border: 1px solid #e3e8ef; + border-radius: 14px; background: #fff; color: #1f3558; font-size: 14px; cursor: pointer; transition: border-color .2s, box-shadow .2s; } +.px-date:hover, .px-range.open .px-date.active { border-color: #b9d0f4; box-shadow: 0 0 0 3px rgba(63, 134, 242, .1); } +.px-date.empty span { color: #9aa6b6; } +.px-date svg { width: 17px; height: 17px; fill: none; stroke: #5b6b80; stroke-width: 1.7; stroke-linecap: round; } +.dp { position: absolute; top: calc(100% + 10px); right: 0; z-index: 40; width: 380px; max-width: calc(100vw - 32px); padding: 18px 18px 14px; + border-radius: 22px; background: #fff; box-shadow: 0 22px 50px rgba(31, 53, 88, .16), 0 2px 8px rgba(31, 53, 88, .06); } +.px-syncbar .dp { left: 0; right: auto; } +.dp__head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } +.dp__head b { font-size: 17px; font-weight: 600; color: #1f3558; } +.dp__nav { width: 40px; height: 40px; border: 0; border-radius: 50%; background: #eef4fd; color: #2f76f0; display: grid; place-items: center; cursor: pointer; } +.dp__nav:hover { background: #e0ebfb; } +.dp__nav svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +.dp__grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px 4px; } +.dp__wd { display: grid; place-items: center; height: 34px; border-radius: 10px; font-size: 13px; color: #3b4a5e; font-weight: 500; } +.dp__day { height: 40px; border: 0; border-radius: 12px; background: none; color: #1f3558; font-size: 14.5px; cursor: pointer; transition: background-color .15s, color .15s; } +.dp__day:hover { background: #eef4fd; } +/* выходные — серые (Сб, Вс) */ +.dp__wd.dp__we { background: #f0f1f3; color: #6b7280; } +.dp__day.dp__we { background: #f4f5f7; color: #6b7280; } +.dp__day.dp__we:hover { background: #e7e9ec; } +.dp__day.dp__out { color: #b3bcc8; } +.dp__day.dp__today { box-shadow: inset 0 0 0 1.5px #b9d0f4; } +.dp__day.dp__in { background: #dfeafc; color: #1f3558; } +.dp__day.dp__start, .dp__day.dp__end { background: #1565e6; color: #fff; font-weight: 600; box-shadow: 0 6px 14px rgba(21, 101, 230, .28); } +.dp__day.dp__end:not(.dp__start) { background: #3f86f2; } +.dp__foot { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; } +.dp__link { border: 0; background: none; color: #2f76f0; font-size: 14px; cursor: pointer; padding: 8px 4px; } +.dp__today-btn { border: 0; border-radius: 999px; padding: 9px 22px; background: #e6effc; color: #2f76f0; font-size: 14px; cursor: pointer; } +.dp__today-btn:hover { background: #d9e7fb; } +/* карточки показателей */ +.px-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 14px; margin: 4px 0 16px; } +.px-kpi { position: relative; display: flex; flex-direction: column; min-width: 0; padding: 16px 16px 8px; border: 1px solid #edf1f6; border-radius: 20px; background: #fff; overflow: hidden; } +.px-kpi__top { display: flex; align-items: flex-start; gap: 10px; } +.px-kpi__row { display: flex; align-items: center; justify-content: space-between; gap: 6px; } +.px-kpi__icon { flex-shrink: 0; width: 42px; height: 42px; border-radius: 50%; display: grid; place-items: center; color: var(--c); + background: color-mix(in srgb, var(--c) 13%, #fff); } +.px-kpi__icon svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.px-kpi__main { min-width: 0; flex: 1; } +.px-kpi__value { display: block; font-size: 25px; font-weight: 600; letter-spacing: -.02em; color: #14233a; line-height: 1.15; } +.px-kpi__label { display: block; font-size: 13px; color: #33475f; margin-top: 2px; } +.px-kpi__hint { display: block; margin: 10px 0 6px; font-size: 11.5px; color: #6f7f94; line-height: 1.4; min-height: 16px; } +.px-trend { display: inline-flex; align-items: center; gap: 3px; flex-shrink: 0; font-size: 12px; font-weight: 600; color: #8b98aa; white-space: nowrap; } +.px-trend svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; } +.px-trend.good { color: #1f9d63; } +.px-trend.bad { color: #e0485f; } +.px-kpis:has(+ .px-cmp:not([hidden])) { margin-bottom: 8px; } +.px-cmp { margin: 0 2px 14px; font-size: 12px; color: #6f7f94; } +.px-spark { display: block; width: 100%; height: 40px; margin-top: auto; } +/* таблица каналов */ +.px-table { min-width: 640px; } +.px-channels { min-width: 900px; } +.px-table td small { font-size: 11px; } +.px-lost { color: #e0485f; font-weight: 600; } +.px-total td { font-weight: 600; background: #f3f6fb; } +.px-total td:first-child { border-radius: 12px 0 0 12px; } +.px-total td:last-child { border-radius: 0 12px 12px 0; } +.px-ch { display: inline-flex; align-items: center; gap: 8px; padding: 5px 12px 5px 10px; border-radius: 999px; background: var(--ch-soft); + color: color-mix(in srgb, var(--ch) 82%, #1f3558); font-weight: 600; font-size: 13px; white-space: nowrap; } +.px-ch::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--ch); } +.px-menu-cell { position: relative; width: 44px; text-align: right; } +.px-more { width: 34px; height: 34px; border: 0; border-radius: 10px; background: none; color: #3b4a5e; cursor: pointer; display: inline-grid; place-items: center; } +.px-more:hover { background: #eef3fa; } +.px-more svg { width: 20px; height: 20px; fill: currentColor; } +.px-pop { position: absolute; right: 8px; top: calc(100% - 4px); z-index: 30; min-width: 220px; padding: 6px; border-radius: 14px; background: #fff; + box-shadow: 0 16px 36px rgba(31, 53, 88, .16); display: flex; flex-direction: column; } +.px-pop button { border: 0; background: none; text-align: left; padding: 10px 12px; border-radius: 10px; font-size: 13.5px; color: #1f3558; cursor: pointer; } +.px-pop button:hover, .px-pop button:focus-visible { background: #eef4fd; } +.px-foot { display: flex; align-items: flex-start; gap: 10px; margin: 14px 2px 4px; font-size: 12.5px; color: #6f7f94; line-height: 1.5; } +.px-foot span { flex-shrink: 0; color: #2f76f0; } +.px-foot svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; } +.px-table select { height: 36px; border-radius: 10px; min-width: 180px; } +.px-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } +.px-grid .card { margin: 0 0 18px; } +.px-grid .px-head { margin-bottom: 10px; } +.rp-source-opt { display: grid; gap: 10px; } +.rp-file-period { margin: 0; padding: 12px 14px; border: 1px solid #dbe9fa; border-radius: 13px; background: #f1f7ff; color: #355e91; font-size: 13px; line-height: 1.5; text-align: center; } +.rp-file-period[data-state=warning] { border-color: #f1dfbf; background: #fff8ed; color: #8c652d; } +.rp-pbx-opt { + --rp-source-color: #4389e9; --rp-source-tint: #eaf3ff; + --rp-source-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='8' y='3' width='8' height='5' rx='1.5'/%3E%3Crect x='2' y='16' width='8' height='5' rx='1.5'/%3E%3Crect x='14' y='16' width='8' height='5' rx='1.5'/%3E%3Cpath d='M12 8v4M6 16v-4h12v4'/%3E%3C/g%3E%3C/svg%3E"); + display: grid; grid-template-columns: 42px minmax(0, 1fr) 20px; align-items: start; gap: 12px; + padding: 16px; border: 1px solid #e5ebf1; border-radius: 18px; background: #fff; + cursor: pointer; font-size: 13px; box-shadow: 0 2px 6px rgba(31, 53, 88, .025); + transition: border-color .18s, background-color .18s, box-shadow .18s; +} +.rp-pbx-opt:has(input[value=bitrix]) { + --rp-source-color: #8973cf; --rp-source-tint: #f0ecfb; + --rp-source-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='9' cy='8' r='3'/%3E%3Cpath d='M3 20v-2a6 6 0 0 1 12 0v2M16 5a3 3 0 0 1 0 6M18 14a5 5 0 0 1 3 4v2'/%3E%3C/g%3E%3C/svg%3E"); +} +.rp-pbx-opt:has(input[value=pbx]) { + --rp-source-color: #4d9c7d; --rp-source-tint: #eaf6ef; + --rp-source-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m8 3 3 5-3 2a14 14 0 0 0 6 6l2-3 5 3-1 4c-1 2-6 0-10-4S4 7 4 5l4-2Z' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); +} +.rp-pbx-opt::before { + content: ''; grid-column: 1; grid-row: 1; width: 42px; height: 42px; border-radius: 13px; + background: var(--rp-source-tint) var(--rp-source-icon) center / 23px 23px no-repeat; +} +.rp-pbx-opt > span { grid-column: 2; grid-row: 1; min-width: 0; } +.rp-source-opt .rp-pbx-opt input { grid-column: 3; grid-row: 1; margin-top: 2px; } +.rp-pbx-opt b { display: block; font-weight: 600; font-size: 14px; color: #24384e; line-height: 1.4; } +.rp-pbx-opt small { display: block; margin-top: 5px; color: #6f7f90; font-size: 12px; line-height: 1.55; } +.rp-pbx-opt:hover { border-color: #bfd5ef; background: #fbfdff; } +.rp-pbx-opt:has(input:checked) { border-color: #83b3ed; background: #f1f7ff; box-shadow: 0 0 0 1px #d7e8fc; } +.rp-pbx-opt:has(input:focus-visible) { outline: 2px solid #4389e9; outline-offset: 3px; } +.rp-pbx-opt.px-off, .rp-pbx-opt:has(input:disabled) { opacity: .55; cursor: not-allowed; } +@media (max-width: 460px) { + .rp-pbx-opt { padding: 13px; gap: 10px; grid-template-columns: 32px minmax(0, 1fr) 20px; } + .rp-pbx-opt::before { width: 32px; height: 36px; background-size: 21px 21px; } +} + +.rp-pbx-dates { display: flex; align-items: center; gap: 8px; justify-content: center; } +.rp-pbx-dates input { width: 150px; height: 40px; border-radius: 12px; } +.card.wk-board { --wk-channel-width: 220px; --wk-metric-width: 205px; margin-top: 12px; padding: 8px; border-radius: 20px; background: #fdfdfc; box-shadow: none; } +[data-rp-view="weekly"] #wk-status { flex-direction: row; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-bottom: 0; padding: 14px 18px; border-radius: 18px; background: #fdfdfc; box-shadow: none; } +[data-rp-view="weekly"] #wk-status h2 { font-family: var(--font); font-size: 16px; } +[data-rp-view="weekly"] #wk-status p { max-width: 740px; color: #87919b; font-size: 12px; } +[data-rp-view="weekly"] #wk-filter { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; gap: 8px; } +[data-rp-view="weekly"] #wk-filter input { width: 144px; height: 36px; background: #f3f4f4; border-color: transparent; border-radius: 9px; font-size: 13px; } +.wk-board .dr-scroll { max-height: calc(100vh - 260px); min-height: 280px; overflow: auto; border-radius: 14px; scrollbar-width: thin; scrollbar-color: #d7ddde #f6f6f5; } +.wk-table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 2px; table-layout: auto; font-family: 'Segoe UI', Arial, sans-serif; } +.wk-table th, .wk-table td { white-space: nowrap; text-align: right; border: 0; } +.wk-table tbody tr:hover { background: transparent; } +.wk-table th { height: 42px; padding: 7px 10px; } +.wk-table td { height: 32px; padding: 5px 10px; border-radius: 5px; background: linear-gradient(135deg, #f6f6f6, #f2f3f3); color: #263649; font-size: 14px; line-height: 1.2; } +.wk-table thead th { position: sticky; top: 0; z-index: 8; padding: 7px 10px; background: #f1f2f3; border-radius: 10px; color: #142236; font-size: 14px; font-weight: 600; } +.wk-table .wk-brand { left: 0; z-index: 10; min-width: calc(var(--wk-channel-width) + var(--wk-metric-width) + 2px); text-align: left; padding: 0 20px 8px; border-radius: 0; background: #fdfdfc; } +.wk-brand span { display: block; font-size: 24px; font-weight: 400; letter-spacing: -.025em; line-height: 1.1; } +.wk-brand small { display: block; margin-top: 2px; color: #8b959e; font-size: 13px; font-weight: 400; } +.wk-table .wk-period { min-width: 110px; text-align: center; } +.wk-period span { display: block; } +.wk-table .wk-metric { position: sticky; left: calc(var(--wk-channel-width) + 2px); z-index: 3; width: var(--wk-metric-width); min-width: var(--wk-metric-width); max-width: var(--wk-metric-width); white-space: normal; line-height: 1.25; text-align: left; font-weight: 400; } +.wk-table .wk-channel { position: sticky; left: 0; z-index: 5; width: var(--wk-channel-width); min-width: var(--wk-channel-width); max-width: var(--wk-channel-width); white-space: normal; vertical-align: middle; text-align: left; padding: 20px; border-radius: 13px; background: linear-gradient(125deg, var(--wk-tint), #fafaf8); color: #17283b; box-shadow: none; } +.wk-channel--phone { --wk-tint: #eef4f0; --wk-accent: #93bfa7; } +.wk-channel--pin { --wk-tint: #fbf4eb; --wk-accent: #efb26e; } +.wk-channel--ya { --wk-tint: #fcf1ed; --wk-accent: #ed6659; } +.wk-channel--web { --wk-tint: #eff4f1; --wk-accent: #83b8a2; } +.wk-channel--source { --wk-tint: #f2f4f8; --wk-accent: #6d9de0; } +.wk-channel__icon { display: grid; width: 48px; height: 48px; margin-bottom: 9px; place-items: center; background: transparent; color: var(--wk-accent); } +.wk-channel__icon img { display: block; width: 32px; height: 32px; object-fit: contain; } +.wk-channel__icon svg { display: block; width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.wk-channel__icon--brand { background: transparent; color: var(--wk-source-color, #637a91); box-shadow: none; } +.wk-channel__icon--banner svg { width: 32px; height: 32px; } +.wk-channel__title { display: block; font-size: 18px; font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; } +.wk-channel__hint { display: block; margin-top: 8px; color: #566575; font-size: 13px; line-height: 1.4; } +.wk-table .wk-value { min-width: 110px; font-variant-numeric: tabular-nums; } +.wk-table .wk-heat { background: var(--wk-heat); } +.wk-table .wk-budget { display: block; width: 90px; max-width: 100%; margin-left: auto; height: 22px; border: 1px solid transparent; border-radius: 4px; text-align: right; padding: 0; background: transparent; color: #40546a; font-size: inherit; font-family: inherit; font-variant-numeric: tabular-nums; appearance: textfield; } +.wk-budget::-webkit-inner-spin-button, .wk-budget::-webkit-outer-spin-button { appearance: none; margin: 0; } +.wk-budget::placeholder { color: #89949d; text-align: center; opacity: 1; } +.wk-table .wk-budget:hover, .wk-table .wk-budget:focus { background: #fff; border-color: #d5dfdd; } +.wk-table .wk-gap td { height: 10px; padding: 0; background: #fdfdfc; } +.wk-del { position: absolute; top: 2px; right: 2px; width: 18px; height: 18px; border: 0; background: transparent; color: #9aa6b4; cursor: pointer; font-size: 14px; line-height: 1; padding: 1px; border-radius: 5px; opacity: 0; transition: opacity .15s; } +.wk-period:hover .wk-del, .wk-del:focus-visible { opacity: 1; } +.wk-del:hover { background: #fdecea; color: #c0392b; } +@media (max-width: 760px) { + .card.wk-board { --wk-channel-width: 112px; --wk-metric-width: 136px; margin: 12px -4px 0; padding: 6px; border-radius: 18px; } + .wk-board .dr-scroll { max-height: calc(100dvh - 190px); } + [data-rp-view="weekly"] #wk-filter { width: 100%; } + [data-rp-view="weekly"] #wk-filter input { flex: 1; min-width: 0; } + [data-rp-view="weekly"] #wk-filter-reset { flex-basis: 100%; text-align: left; } + .wk-table .wk-brand { padding: 2px 10px 8px; } + .wk-brand span { font-size: 20px; } + .wk-brand small { font-size: 11px; } + .wk-table .wk-channel { padding: 12px; } + .wk-table td, .wk-table thead th { font-size: 12px; } + .wk-channel__icon { width: 38px; height: 38px; } + .wk-channel__icon svg { width: 21px; height: 21px; } + .wk-channel__icon img { width: 28px; height: 28px; } + .wk-channel__title { font-size: 13px; } + .wk-channel__hint { font-size: 11px; } + .wk-table .wk-period, .wk-table .wk-value { min-width: 96px; } + .wk-table .wk-budget { width: 76px; } + .wk-del { opacity: 1; font-size: 12px; } +} +@media (max-width: 1400px) { .px-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +@media (max-width: 1100px) { .px-grid { grid-template-columns: 1fr; } } +@media (max-width: 760px) { + .px-ats { grid-template-columns: auto 1fr; } + .px-ats #px-check { grid-column: 1 / -1; justify-self: start; } + .px-ats__icon { width: 56px; height: 56px; border-radius: 18px; } + .px-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } + .px-kpi { padding: 12px 12px 6px; } + .px-kpi__icon { width: 38px; height: 38px; } + .px-kpi__value { font-size: 21px; } + .px-kpi__top { flex-wrap: wrap; } + .px-range { flex-wrap: nowrap; width: 100%; } + .px-date { min-width: 0; flex: 1; padding: 0 10px; gap: 6px; font-size: 13px; } + .px-period { width: 100%; } + .px-show, .px-btn { width: 100%; } + .dp, .px-syncbar .dp { left: 0; right: 0; width: auto; max-width: none; padding: 14px 12px 10px; } + .dp__day { height: 38px; } +} + +/* ---------- плавное появление ---------- */ +.reveal { opacity: 0; transform: translateY(10px); animation: reveal .7s var(--ease) forwards; } +@keyframes reveal { to { opacity: 1; transform: none; } } +.chart__line.draw { stroke-dasharray: var(--len); stroke-dashoffset: var(--len); animation: draw 1.4s var(--ease) .2s forwards; } +@keyframes draw { to { stroke-dashoffset: 0; } } + +html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="off"] *::after { animation: none !important; transition: none !important; } +html[data-motion="off"] .reveal { opacity: 1; transform: none; } +html[data-motion="off"] .chart__line.draw { stroke-dashoffset: 0; } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; } + .reveal { opacity: 1; transform: none; } + .chart__line.draw { stroke-dashoffset: 0; } +} + +/* ---------- адаптивность ---------- */ +@media (max-width: 1180px) { + #app { padding: 14px 16px 14px 12px; gap: 16px; } + .dash { gap: 10px 18px; } + .dash__heart { min-height: 560px; } + .summary { padding-top: 22px; } + .summary__stats { gap: 18px; } + .who__meta { display: none; } + .vital { min-width: 110px; padding: 11px 14px; } + .vital__value { font-size: 21px; } + /* «Врач или услуга» — второстепенная колонка: на планшете уступает место остальным */ + .leads-table th:nth-child(5), .leads-table td:nth-child(5) { display: none; } + .leads-table td, .leads-table th { padding-left: 8px; padding-right: 8px; } +} +/* одна колонка — только на узких экранах: на планшете композиция референса сохраняется */ +@media (max-width: 900px) { + .dash { grid-template-columns: minmax(0, 1fr); } + .dash__heart { min-height: 520px; max-width: 620px; width: 100%; margin: 0 auto; order: 2; } + .dash__right { display: contents; } + .summary { order: 1; padding-top: 18px; } + .trends { order: 3; } .leads-card { order: 4; } + .who__meta { display: none; } +} +@media (max-width: 760px) { + #app { flex-direction: column; padding: 10px 12px 80px; gap: 10px; } + .side { + position: fixed; left: 10px; right: 10px; bottom: 10px; top: auto; width: auto; height: 64px; min-height: 0; + flex-direction: row; padding: 0 12px; z-index: 50; background: #fff; border-radius: 26px; filter: none; box-shadow: var(--shadow-lift); + } + .side__shape, .side__logo, .side .switch { display: none; } + .side__nav { flex-direction: row; margin: 0; gap: 2px; flex: 1; justify-content: space-between; } + .side__foot { margin: 0 0 0 4px; padding-top: 0; flex-direction: row; } + .side__btn { width: 40px; height: 40px; } + .side__btn .tip { display: none; } + .top { flex-wrap: wrap; } + .search { width: 100%; order: 3; } + .who { margin-left: 4px; } + .summary__title { font-size: 44px; } + .summary__stats { width: 100%; justify-content: space-between; } + .dash__heart { min-height: 440px; } + .vital { min-width: 104px; padding: 10px 12px; } + .vital__value { font-size: 20px; } + .card { padding: 18px 16px; border-radius: 22px; } + .leads-card, .card { overflow-x: auto; } + .leads-table { min-width: 640px; } + .trends__legend { margin-left: 0; width: 100%; } + .page-title { font-size: 28px; } +} diff --git a/internal/api/adminui/assets/README.txt b/internal/api/adminui/assets/README.txt new file mode 100644 index 0000000..81980d0 --- /dev/null +++ b/internal/api/adminui/assets/README.txt @@ -0,0 +1,4 @@ +heart.webp — прозрачный рендер сердца для главного экрана админки (1025×1400, альфа-канал). +Если файл убрать, на его месте появится пометка «Нужен 3D-рендер сердца». +hall.webp — фото холла клиники в шапке раздела «Слайды на главной». +lobby.webp — фото ресепшена в шапке раздела «Услуги и цены» → «Услуги». diff --git a/internal/api/adminui/assets/channels/2gis.png b/internal/api/adminui/assets/channels/2gis.png new file mode 100644 index 0000000..d391ec6 Binary files /dev/null and b/internal/api/adminui/assets/channels/2gis.png differ diff --git a/internal/api/adminui/assets/channels/SOURCES.md b/internal/api/adminui/assets/channels/SOURCES.md new file mode 100644 index 0000000..ce70f27 --- /dev/null +++ b/internal/api/adminui/assets/channels/SOURCES.md @@ -0,0 +1,19 @@ +# Advertising source icons + +Original service website icons, downloaded 2026-10-01. Used to identify the corresponding reporting channels; no redrawing or recolouring. ICO frames were exported to PNG; Zoon and MAX SVGs were rendered to PNG at 128 px. Other PNGs are unchanged pixel data. + +- 2GIS: https://d-assets.2gis.ru/favicon.png +- Yandex: https://yastatic.net/s3/home-static/_/f0/f0597b6727cc67dceebc4e3a87caf571.png +- Prodoctorov: https://prodoctorov.ru/favicon.ico +- NaPopravku: https://napopravku.ru/favicon.ico +- SberHealth: https://www.google.com/s2/favicons?domain=sberhealth.ru&sz=128 (cached website icon; primary website blocked automated requests) +- Zoon: https://zoon.ru/favicon.svg +- Doctu: https://doctu.ru/favicon.ico +- Instagram: https://www.google.com/s2/favicons?domain=instagram.com&sz=128 (cached website icon; primary request timed out) +- Google: https://www.google.com/favicon.ico +- VK: https://m.vk.com/images/icons/pwa/apple/default.png?15 +- Telegram: https://www.google.com/s2/favicons?domain=telegram.org&sz=128 (cached website icon; primary request timed out) +- MAX: https://max.ru/favicon.svg +- Очевидно (канал «Сайт» дашборда «Очевидно»), ochevidno-site.png: apple-touch-icon сайта https://ochevidno.ru/ — https://static.tildacdn.com/tild3036-3036-4539-b033-326366363163/apple-touch-icon.png (downloaded 2026-10-14, unchanged) + +All marks belong to their respective owners. Icons are served locally by the admin application; opening a report sends no requests to these services. diff --git a/internal/api/adminui/assets/channels/doctu.png b/internal/api/adminui/assets/channels/doctu.png new file mode 100644 index 0000000..5551f34 Binary files /dev/null and b/internal/api/adminui/assets/channels/doctu.png differ diff --git a/internal/api/adminui/assets/channels/doctu.svg b/internal/api/adminui/assets/channels/doctu.svg new file mode 100644 index 0000000..053fe99 --- /dev/null +++ b/internal/api/adminui/assets/channels/doctu.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/internal/api/adminui/assets/channels/google.png b/internal/api/adminui/assets/channels/google.png new file mode 100644 index 0000000..17d3f98 Binary files /dev/null and b/internal/api/adminui/assets/channels/google.png differ diff --git a/internal/api/adminui/assets/channels/instagram.png b/internal/api/adminui/assets/channels/instagram.png new file mode 100644 index 0000000..9b3aac0 Binary files /dev/null and b/internal/api/adminui/assets/channels/instagram.png differ diff --git a/internal/api/adminui/assets/channels/max.png b/internal/api/adminui/assets/channels/max.png new file mode 100644 index 0000000..40c8a91 Binary files /dev/null and b/internal/api/adminui/assets/channels/max.png differ diff --git a/internal/api/adminui/assets/channels/napopravku.png b/internal/api/adminui/assets/channels/napopravku.png new file mode 100644 index 0000000..a07a045 Binary files /dev/null and b/internal/api/adminui/assets/channels/napopravku.png differ diff --git a/internal/api/adminui/assets/channels/ochevidno-site.png b/internal/api/adminui/assets/channels/ochevidno-site.png new file mode 100644 index 0000000..4d2a376 Binary files /dev/null and b/internal/api/adminui/assets/channels/ochevidno-site.png differ diff --git a/internal/api/adminui/assets/channels/prodoctorov.png b/internal/api/adminui/assets/channels/prodoctorov.png new file mode 100644 index 0000000..a3ebdd4 Binary files /dev/null and b/internal/api/adminui/assets/channels/prodoctorov.png differ diff --git a/internal/api/adminui/assets/channels/sber.png b/internal/api/adminui/assets/channels/sber.png new file mode 100644 index 0000000..ea6373d Binary files /dev/null and b/internal/api/adminui/assets/channels/sber.png differ diff --git a/internal/api/adminui/assets/channels/telegram.png b/internal/api/adminui/assets/channels/telegram.png new file mode 100644 index 0000000..15bdf0a Binary files /dev/null and b/internal/api/adminui/assets/channels/telegram.png differ diff --git a/internal/api/adminui/assets/channels/vk.png b/internal/api/adminui/assets/channels/vk.png new file mode 100644 index 0000000..7034d9b Binary files /dev/null and b/internal/api/adminui/assets/channels/vk.png differ diff --git a/internal/api/adminui/assets/channels/website.svg b/internal/api/adminui/assets/channels/website.svg new file mode 100644 index 0000000..71ace38 --- /dev/null +++ b/internal/api/adminui/assets/channels/website.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/internal/api/adminui/assets/channels/yandex.png b/internal/api/adminui/assets/channels/yandex.png new file mode 100644 index 0000000..d4fc7fd Binary files /dev/null and b/internal/api/adminui/assets/channels/yandex.png differ diff --git a/internal/api/adminui/assets/channels/zoon.png b/internal/api/adminui/assets/channels/zoon.png new file mode 100644 index 0000000..318914d Binary files /dev/null and b/internal/api/adminui/assets/channels/zoon.png differ diff --git a/internal/api/adminui/assets/hall.webp b/internal/api/adminui/assets/hall.webp new file mode 100644 index 0000000..7659e60 Binary files /dev/null and b/internal/api/adminui/assets/hall.webp differ diff --git a/internal/api/adminui/assets/heart.webp b/internal/api/adminui/assets/heart.webp new file mode 100644 index 0000000..579f3cc Binary files /dev/null and b/internal/api/adminui/assets/heart.webp differ diff --git a/internal/api/adminui/assets/lobby.webp b/internal/api/adminui/assets/lobby.webp new file mode 100644 index 0000000..0312697 Binary files /dev/null and b/internal/api/adminui/assets/lobby.webp differ diff --git a/internal/api/adminui/banners.js b/internal/api/adminui/banners.js new file mode 100644 index 0000000..ee8b1e2 --- /dev/null +++ b/internal/api/adminui/banners.js @@ -0,0 +1,189 @@ +/* «Слайды на главной»: шапка-витрина, превью слайдов, фильтр по статусу, порядок перетаскиванием. + Форма слайда — прежние bannerFields / normalizeBanner / editBanner. */ + +const BN_THEME_BG = { navy: '#2f4a68', lilac: '#8f86c9', orange: '#f08a3c' }; +const BN_ICONS = { + grip: '', + pencil: '', + dots: '', + plus: '', +}; +const bnIcon = (n) => ``; + +let bannersCache = []; + +const Banners = { + state(b) { + const now = Date.now(); + if (!b.published) return ['hidden', 'Скрыт', 'grey']; + if (b.ends_at && new Date(b.ends_at).getTime() <= now) return ['expired', 'Показ окончен', 'off']; + if (b.starts_at && new Date(b.starts_at).getTime() > now) return ['waiting', 'Скоро', 'wait']; + return ['live', 'На сайте', '']; + }, + + render() { + const f = $('#bn-filter').value; + const list = bannersCache.filter((b) => !f || Banners.state(b)[0] === f); + const live = bannersCache.filter((b) => Banners.state(b)[0] === 'live').length; + $('#bn-count').textContent = `${list.length} ${plural(list.length, 'слайд', 'слайда', 'слайдов')}`; + $('#bn-live').textContent = `на сайте сейчас: ${live}`; + const tb = $('#bn-body'); tb.textContent = ''; + if (!list.length) { + const tr = el('tr'); tr.append(el('td', { colSpan: 9, className: 'muted dr-empty', textContent: 'Слайдов с таким статусом нет.' })); tb.append(tr); + } + // перетаскивать можно только полный список: иначе порядок скрытых строк непонятен + const draggable = !f; + for (const b of list) tb.append(Banners.row(b, draggable)); + }, + + thumb(b) { + const box = el('div', { className: 'bn-thumb' }); + if (b.layout === 'image' && b.image) { + box.append(el('img', { src: b.image, alt: '', loading: 'lazy' })); + return box; + } + box.classList.add('bn-thumb--made'); + box.style.background = BN_THEME_BG[b.theme] || BN_THEME_BG.navy; + box.append(el('b', { textContent: b.title || 'Без заголовка' })); + if (b.subtitle) box.append(el('small', { textContent: b.subtitle })); + if (b.layout === 'doctor' && b.image) box.append(el('img', { className: 'bn-thumb__doc', src: b.image, alt: '', loading: 'lazy' })); + return box; + }, + + row(b, draggable) { + const tr = el('tr'); + tr.dataset.id = b.id; + const grip = el('td', { className: 'bn-grip' }); + if (draggable) { + const h = el('button', { type: 'button', className: 'grip', title: 'Перетащите, чтобы поменять порядок' }); + h.setAttribute('aria-label', 'Переместить слайд «' + (b.title || 'картинка') + '». Стрелки вверх и вниз меняют порядок'); + h.innerHTML = bnIcon('grip'); + h.onkeydown = (e) => { + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; + e.preventDefault(); + Banners.move(b.id, e.key === 'ArrowUp' ? -1 : 1); + }; + grip.append(h); + tr.draggable = true; + tr.addEventListener('dragstart', (e) => { Banners.dragId = b.id; tr.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; }); + tr.addEventListener('dragend', () => { tr.classList.remove('dragging'); Banners.dragId = null; }); + tr.addEventListener('dragover', (e) => { if (Banners.dragId && Banners.dragId !== b.id) { e.preventDefault(); tr.classList.add('drop'); } }); + tr.addEventListener('dragleave', () => tr.classList.remove('drop')); + tr.addEventListener('drop', (e) => { e.preventDefault(); tr.classList.remove('drop'); Banners.dropOn(b.id); }); + } + const pic = el('td'); pic.append(Banners.thumb(b)); + const title = el('td', { className: 'bn-title' }); + title.append(el('b', { textContent: b.title || '(картинка без заголовка)' })); + const from = ruDate(localDate(b.starts_at)), to = ruDate(localDate(b.ends_at)); + const show = from && to ? `${from} —\n${to}` : to ? 'до ' + to : from ? 'с ' + from : 'всегда'; + const sort = el('td'); sort.append(el('span', { className: 'bn-sort', textContent: b.sort })); + const [, label, cls] = Banners.state(b); + const st = el('td'); st.append(el('span', { className: 'pill bn-pill ' + cls, textContent: label })); + const act = el('td', { className: 'dr-actions' }); + const edit = el('button', { type: 'button', className: 'bn-edit' }); + edit.innerHTML = bnIcon('pencil') + 'Изменить'; + edit.onclick = () => editBanner(b); + const more = el('button', { type: 'button', className: 'icon-btn', title: 'Ещё' }); + more.setAttribute('aria-label', 'Ещё действия со слайдом'); + more.setAttribute('aria-haspopup', 'menu'); + more.innerHTML = bnIcon('dots'); + more.onclick = (e) => { e.stopPropagation(); Banners.menu(b, more); }; + act.append(edit, more); + tr.append(grip, pic, title, + el('td', { className: 'muted bn-small', textContent: LAYOUTS[b.layout] || b.layout }), + el('td', { className: 'muted bn-small bn-link', textContent: b.link_url || '—' }), + el('td', { className: 'muted bn-small bn-dates', textContent: show }), + sort, st, act); + return tr; + }, + + menu(b, anchor) { + Doctors.closeMenu(); + const m = el('div', { className: 'menu', id: 'dr-menu', role: 'menu' }); + const item = (text, fn, danger) => { + const x = el('button', { type: 'button', role: 'menuitem', className: danger ? 'danger' : '', textContent: text }); + x.onclick = () => { Doctors.closeMenu(); fn(); }; + m.append(x); + }; + if (b.link_url) item('Открыть ссылку слайда', () => window.open(b.link_url, '_blank', 'noopener')); + item(b.published ? 'Скрыть с сайта' : 'Показать на сайте', () => Banners.save(b, { published: !b.published }, + b.published ? 'Слайд скрыт' : 'Слайд на сайте')); + item('Удалить слайд', () => delBanner(b), true); + document.body.append(m); + const r = anchor.getBoundingClientRect(); + m.style.top = (r.bottom + window.scrollY + 6) + 'px'; + m.style.left = Math.max(12, r.right + window.scrollX - m.offsetWidth) + 'px'; + m.querySelector('button').focus(); + anchor.setAttribute('aria-expanded', 'true'); + Doctors.menuAnchor = anchor; + }, + + body(b, change = {}) { + return { + position: b.position || 'home_hero', layout: b.layout, theme: b.theme, title: b.title, subtitle: b.subtitle, + badge: b.badge, chips: b.chips || [], image_id: b.image_id ?? null, mobile_image_id: b.mobile_image_id ?? null, + link_url: b.link_url, button_text: b.button_text, disclaimer: b.disclaimer, + starts_at: b.starts_at || null, ends_at: b.ends_at || null, sort: b.sort, published: b.published, ...change, + }; + }, + + async save(b, change, message) { + try { + await api('/admin/banners/' + b.id, { method: 'PUT', body: JSON.stringify(Banners.body(b, change)) }); + toast(message); + await loadBanners(); + } catch (e) { toast(e.message, true); } + }, + + // Новый порядок: 10, 20, 30… — сохраняем только слайды, у которых номер изменился. + async reorder(ids) { + const byId = new Map(bannersCache.map((b) => [b.id, b])); + const changed = ids.map((id, i) => [byId.get(id), (i + 1) * 10]).filter(([b, sort]) => b.sort !== sort); + if (!changed.length) return; + try { + for (const [b, sort] of changed) { + await api('/admin/banners/' + b.id, { method: 'PUT', body: JSON.stringify(Banners.body(b, { sort })) }); + } + toast('Порядок слайдов сохранён'); + } catch (e) { toast(e.message, true); } + await loadBanners(); + }, + dropOn(targetId) { + const ids = bannersCache.map((b) => b.id); + const from = ids.indexOf(Banners.dragId), to = ids.indexOf(targetId); + if (from < 0 || to < 0) return; + ids.splice(to, 0, ids.splice(from, 1)[0]); + Banners.reorder(ids); + }, + async move(id, dir) { + const ids = bannersCache.map((b) => b.id); + const i = ids.indexOf(id), j = i + dir; + if (j < 0 || j >= ids.length) return; + [ids[i], ids[j]] = [ids[j], ids[i]]; + await Banners.reorder(ids); + $(`#bn-body tr[data-id="${id}"] .grip`)?.focus(); + }, + + // Фото холла в шапке — assets/hall.webp; без файла остаётся мягкий градиент. + async hero() { + const ok = await fetch('assets/hall.webp', { method: 'HEAD', credentials: 'same-origin' }).then((r) => r.ok).catch(() => false); + $('#bn-hero').classList.toggle('has-photo', ok); + }, +}; + +async function loadBanners() { + const r = await api('/admin/banners?position=home_hero'); + bannersCache = (r.data || []).slice().sort((a, b) => a.sort - b.sort || a.id - b.id); + Banners.render(); +} + +$('#bn-filter').onchange = () => Banners.render(); +$('#bn-add').insertAdjacentHTML('afterbegin', bnIcon('plus')); +$('#bn-add').onclick = () => { + const next = (bannersCache.reduce((m, b) => Math.max(m, b.sort), 0) || 0) + 10; + openDialog('Новый слайд', bannerFields({ published: true, sort: next, layout: 'image', theme: 'navy' }), async (d) => { + await api('/admin/banners', { method: 'POST', body: JSON.stringify(normalizeBanner(d)) }); + toast('Слайд добавлен'); loadBanners(); + }); +}; +Banners.hero(); diff --git a/internal/api/adminui/clinic.js b/internal/api/adminui/clinic.js new file mode 100644 index 0000000..f32f9a1 --- /dev/null +++ b/internal/api/adminui/clinic.js @@ -0,0 +1,32 @@ +/* Клиника раздела «Отчёты»: DANA или «Очевидно». У каждой свои звонки АТС, отчёты по каналам + и дашборд; сервер получает её в ?clinic= (без параметра — DANA). Выбор запоминается в браузере. */ +const ReportClinic = { + titles: { dana: 'DANA', ochevidno: 'Очевидно' }, + brands: { dana: 'DANA Medical', ochevidno: 'Очевидно' }, + key: 'dana', + + init() { + try { + const saved = localStorage.getItem('rp-clinic'); + if (saved && ReportClinic.titles[saved]) ReportClinic.key = saved; + } catch { /* без хранилища — DANA */ } + for (const b of document.querySelectorAll('#rp-clinic button[data-clinic]')) b.onclick = () => ReportClinic.set(b.dataset.clinic); + const share = document.getElementById('rp-share'); + if (share) share.onclick = () => Reports.shareDialog(); + ReportClinic.sync(); + }, + get title() { return ReportClinic.titles[ReportClinic.key]; }, + get brand() { return ReportClinic.brands[ReportClinic.key]; }, + q(path) { return path + (path.includes('?') ? '&' : '?') + 'clinic=' + encodeURIComponent(ReportClinic.key); }, + sync() { + for (const b of document.querySelectorAll('#rp-clinic button[data-clinic]')) b.setAttribute('aria-pressed', String(b.dataset.clinic === ReportClinic.key)); + }, + set(key) { + if (!ReportClinic.titles[key] || key === ReportClinic.key) return; + ReportClinic.key = key; + try { localStorage.setItem('rp-clinic', key); } catch { /* выбор действует до перезагрузки */ } + ReportClinic.sync(); + Reports.onClinicChange(); + }, +}; +ReportClinic.init(); diff --git a/internal/api/adminui/dashboard.js b/internal/api/adminui/dashboard.js new file mode 100644 index 0000000..71db2ad --- /dev/null +++ b/internal/api/adminui/dashboard.js @@ -0,0 +1,289 @@ +/* Главный экран админки по композиции референса: слева крупная визуализация сердца + с плавающими показателями, справа заголовок, сводка клиники, графики заявок и последние заявки. + Компоненты: ClinicSummary, HeartVisualization, VitalBadge, TrendsCard, LeadsTable. */ + +const LEAD_STATUS = { + new: ['Новая', 'off'], + in_progress: ['В работе', 'sky'], + done: ['Обработана', ''], + spam: ['Спам', 'grey'], +}; +const plural = (n, one, few, many) => { + const a = Math.abs(n) % 100, b = a % 10; + return a > 10 && a < 20 ? many : b === 1 ? one : b >= 2 && b <= 4 ? few : many; +}; +const svgEl = (tag, attrs = {}) => { + const n = document.createElementNS('http://www.w3.org/2000/svg', tag); + for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); + return n; +}; + +/* ---------- ClinicSummary: заголовок и три показателя без карточек ---------- */ +function ClinicSummary(d) { + const box = el('div', { className: 'summary reveal' }); + const h = el('h1', { className: 'summary__title' }); + h.append('Панель', el('br'), 'клиники'); + const stats = el('div', { className: 'summary__stats' }); + for (const [value, label, tab] of [ + [d.doctors, plural(d.doctors, 'врач', 'врача', 'врачей'), 'doctors'], + [d.services, plural(d.services, 'услуга', 'услуги', 'услуг'), 'services'], + [d.branches, plural(d.branches, 'филиал', 'филиала', 'филиалов'), 'settings'], + ]) { + const b = el('button', { type: 'button', className: 'stat' }); + b.append(el('b', { textContent: value }), el('span', { textContent: label })); + b.onclick = () => go(tab); + stats.append(b); + } + box.append(h, stats); + return box; +} + +/* ---------- VitalBadge: маленькая белая карточка рядом с маркером ---------- */ +function VitalBadge({ label, value, unit, tab, x, y }) { + const b = el('button', { type: 'button', className: 'vital reveal' }); + b.style.left = x; b.style.top = y; + b.append(el('span', { className: 'vital__label', textContent: label }), el('span', { className: 'vital__value', textContent: value })); + if (unit) b.append(el('span', { className: 'vital__unit', textContent: unit })); + b.onclick = () => go(tab); + return b; +} + +/* ---------- HeartVisualization ---------- */ +// Реалистичный рендер сердца — файл assets/heart.webp (прозрачный фон). Если его нет, +// показываем честную пометку, а не рисованную заглушку. +const HEART_SRC = 'assets/heart.webp'; +let heartAvailable = null; +async function heartExists() { + if (heartAvailable === null) { + heartAvailable = await fetch(HEART_SRC, { method: 'HEAD', credentials: 'same-origin' }).then((r) => r.ok).catch(() => false); + } + return heartAvailable; +} + +async function HeartVisualization(d) { + const box = el('div', { className: 'heart' }); + const hasHeart = await heartExists(); + if (hasHeart) { + box.append(el('img', { className: 'heart__img reveal', src: HEART_SRC, alt: '', decoding: 'async' })); + } else { + const miss = el('div', { className: 'heart__missing reveal' }); + miss.innerHTML = '
Нужен 3D-рендер сердцаФайл assets/heart.webp: прозрачный фон, около 1400 px по высоте.
'; + box.append(miss); + } + // маркеры на поверхности и карточки рядом — расположены асимметрично, как в референсе + const spots = [ + { mx: 36, my: 27, badge: { label: 'Новые заявки', value: d.new_leads, unit: d.leads_today ? `сегодня ${d.leads_today}` : 'ждут ответа', tab: 'leads', x: '52%', y: '6%' } }, + { mx: 50, my: 49, badge: { label: 'Отзывы на проверке', value: d.pending_reviews, unit: 'ждут публикации', tab: 'reviews', x: '64%', y: '42%' } }, + { mx: 41, my: 74, badge: { label: 'Акции на сайте', value: d.active_promos, unit: d.active_promos ? 'действуют сейчас' : 'нет активных', tab: 'promos', x: '2%', y: '80%' } }, + ]; + spots.forEach((s, i) => { + const b = VitalBadge(s.badge); + b.style.animationDelay = (0.5 + i * 0.15) + 's'; + // маркеры отмечают точки на поверхности сердца — без рендера им не на чем стоять + if (!hasHeart) { box.append(b); return; } + const m = el('span', { className: 'marker reveal' }); + m.style.left = s.mx + '%'; m.style.top = s.my + '%'; + m.style.animationDelay = (0.4 + i * 0.15) + 's'; + m.setAttribute('aria-hidden', 'true'); + box.append(m, b); + }); + return box; +} + +/* ---------- TrendsCard: два лёгких графика с полосой обычного уровня ---------- */ +// «Обычный уровень» — середина значений за период (от 25-го до 75-го перцентиля). +// Это ориентир для глаза, а не норма. +function quantile(sorted, q) { + const i = (sorted.length - 1) * q, lo = Math.floor(i), hi = Math.ceil(i); + return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo); +} +function smoothPath(pts) { + let d = `M ${pts[0][0]} ${pts[0][1]}`; + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2; + const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6]; + const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6]; + d += ` C ${c1[0]} ${c1[1]}, ${c2[0]} ${c2[1]}, ${p2[0]} ${p2[1]}`; + } + return d; +} + +let chartSeq = 0; +function LineChart({ values, dates, band, framed, showX, tipLabel, title }) { + const id = 'c' + (++chartSeq); + const W = 1000, H = framed ? 178 : 150, L = 62, R = 16, T = framed ? 26 : 16, B = showX ? 38 : 14; + const wrap = el('div', { className: 'chart' }); + const s = svgEl('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': `${title}: ${values.join(', ')}` }); + // шкала по фактическому размаху с запасом, а не от нуля: так видно, как день отличается от обычного + const vmax = Math.max(...values), vmin = Math.min(...values); + const pad = Math.max((vmax - vmin) * 0.18, 1); + const max = vmax + pad, min = Math.max(0, vmin - pad); + const span = (max - min) || 1; + const y = (v) => T + (H - T - B) * (1 - (v - min) / span); + const x = (i) => L + (W - L - R) * (values.length === 1 ? 0.5 : i / (values.length - 1)); + const sorted = [...values].sort((a, b) => a - b); + const lo = quantile(sorted, 0.25), hi = quantile(sorted, 0.75); + const yTop = y(hi), yBot = Math.max(y(lo), yTop + 10); + + const defs = svgEl('defs'); + const bandGrad = svgEl('linearGradient', { id: id + 'b', x1: '0', x2: '1' }); + band.stops.forEach(([o, c, a]) => bandGrad.append(svgEl('stop', { offset: o, 'stop-color': c, 'stop-opacity': a }))); + const peakGrad = svgEl('linearGradient', { id: id + 'p', x1: '0', x2: '0', y1: String(y(vmax)), y2: String(yTop), gradientUnits: 'userSpaceOnUse' }); + peakGrad.append(svgEl('stop', { offset: '0', 'stop-color': '#f2566a', 'stop-opacity': '0.95' }), svgEl('stop', { offset: '1', 'stop-color': '#f2566a', 'stop-opacity': '0' })); + const lineGrad = svgEl('linearGradient', { id: id + 'l', x1: '0', x2: '1' }); + [['0', 0.25], ['0.1', 1], ['0.9', 1], ['1', 0.25]].forEach(([o, a]) => lineGrad.append(svgEl('stop', { offset: o, 'stop-color': '#8b8f8d', 'stop-opacity': a }))); + const clip = svgEl('clipPath', { id: id + 'k' }); + clip.append(svgEl('rect', { x: 0, y: 0, width: W, height: yTop })); + defs.append(bandGrad, peakGrad, lineGrad, clip); + s.append(defs); + + if (framed) s.append(svgEl('rect', { class: 'chart__frame', x: L - 18, y: 6, width: W - L - R + 30, height: H - B - 6 + 4, rx: 18 })); + s.append(svgEl('rect', { x: L, y: yTop, width: W - L - R, height: yBot - yTop, fill: `url(#${id}b)`, rx: 2 })); + // подписи — границы полосы, как в референсе (280 / 52) + const fmt = (v) => (Math.round(v * 10) / 10).toString().replace('.', ','); + s.append(Object.assign(svgEl('text', { class: 'chart__axis', x: L - 26, y: yTop + 4, 'text-anchor': 'end' }), { textContent: fmt(hi) })); + s.append(Object.assign(svgEl('text', { class: 'chart__axis', x: L - 26, y: yBot + 4, 'text-anchor': 'end' }), { textContent: fmt(lo) })); + + const pts = values.map((v, i) => [x(i), y(v)]); + const d = smoothPath(pts); + // розово-красная заливка пиков: всё, что выше полосы, закрашено до её верхней границы + s.append(svgEl('path', { d: `${d} L ${x(values.length - 1)} ${yTop} L ${x(0)} ${yTop} Z`, fill: `url(#${id}p)`, 'clip-path': `url(#${id}k)` })); + const line = svgEl('path', { class: 'chart__line draw', d, stroke: `url(#${id}l)` }); + s.append(line); + + if (showX) { + const df = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }); + dates.forEach((dt, i) => { + if (i % 2) return; + s.append(Object.assign(svgEl('text', { class: 'chart__axis', x: x(i), y: H - 10, 'text-anchor': 'middle' }), { textContent: df.format(new Date(dt + 'T12:00:00')).replace('.', '') })); + }); + } + + // подсказка с измерением при наведении + const guide = svgEl('line', { class: 'chart__guide', y1: T - 6, y2: H - B, visibility: 'hidden' }); + const dot = svgEl('circle', { class: 'chart__dot', r: 4.5, visibility: 'hidden' }); + s.append(guide, dot); + const tip = el('div', { className: 'tooltip', hidden: true }); + const step = (W - L - R) / Math.max(values.length - 1, 1); + values.forEach((v, i) => { + const hit = svgEl('rect', { class: 'chart__hit', x: x(i) - step / 2, y: 0, width: step, height: H, tabindex: '0' }); + const show = () => { + guide.setAttribute('x1', x(i)); guide.setAttribute('x2', x(i)); guide.setAttribute('visibility', 'visible'); + dot.setAttribute('cx', x(i)); dot.setAttribute('cy', y(v)); dot.setAttribute('visibility', 'visible'); + const date = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', weekday: 'short' }).format(new Date(dates[i] + 'T12:00:00')); + tip.innerHTML = ''; + tip.append(el('b', { textContent: `${v} ${plural(v, tipLabel[0], tipLabel[1], tipLabel[2])}` }), date); + tip.style.left = (x(i) / W * 100) + '%'; tip.style.top = (y(v) / H * 100) + '%'; + tip.hidden = false; + }; + const hide = () => { tip.hidden = true; guide.setAttribute('visibility', 'hidden'); dot.setAttribute('visibility', 'hidden'); }; + hit.addEventListener('mouseenter', show); hit.addEventListener('focus', show); + hit.addEventListener('mouseleave', hide); hit.addEventListener('blur', hide); + s.append(hit); + }); + wrap.append(s, tip); + // длина линии для анимации отрисовки + requestAnimationFrame(() => { try { line.style.setProperty('--len', Math.ceil(line.getTotalLength())); } catch { /* старый браузер */ } }); + return wrap; +} + +// Пример для пустой базы: до запуска сайта заявок почти нет, а пустой график ничего не показывает. +function demoDays(days) { + const leads = [5, 12, 7, 16, 19, 9, 5, 11, 17, 22, 12, 6, 13, 20]; + const appts = [2, 7, 4, 10, 12, 5, 3, 6, 11, 14, 7, 3, 8, 12]; + return days.map((d, i) => ({ date: d.date, leads: leads[i % leads.length], appointments: appts[i % appts.length] })); +} + +function TrendsCard(d) { + const card = el('section', { className: 'card trends reveal', ariaLabel: 'Заявки за 14 дней' }); + const real = d.days.reduce((a, x) => a + x.leads, 0); + const demo = real < 5; + const days = demo ? demoDays(d.days) : d.days; + const head = el('div', { className: 'trends__head' }); + head.append(el('h2', { textContent: 'Заявки за 14 дней' })); + if (demo) head.append(el('span', { className: 'trends__demo', textContent: `Пример данных: заявок пока ${real}` })); + const legend = el('div', { className: 'trends__legend' }); + legend.innerHTML = 'все заявки' + + 'записи на приёмполоса — обычный уровень'; + head.append(legend); + card.append(head); + const dates = days.map((x) => x.date); + card.append(LineChart({ + values: days.map((x) => x.leads), dates, framed: true, showX: true, tipLabel: ['заявка', 'заявки', 'заявок'], title: 'Все заявки по дням', + band: { stops: [['0', '#f4e9b2', 0], ['0.1', '#f2e2a0', 1], ['0.4', '#f5c29f', 1], ['0.55', '#f0a8ab', 0.95], ['0.85', '#f2e2a0', 1], ['1', '#f4e9b2', 0]] }, + })); + card.append(LineChart({ + values: days.map((x) => x.appointments), dates, framed: false, showX: false, tipLabel: ['запись', 'записи', 'записей'], title: 'Записи на приём по дням', + band: { stops: [['0', '#bfe8d6', 0], ['0.12', '#b3e6cc', 1], ['0.45', '#93c9ad', 1], ['0.7', '#b3e6cc', 1], ['1', '#bfe8d6', 0]] }, + })); + return card; +} + +/* ---------- LeadsTable: последние заявки ---------- */ +async function LeadsTable() { + const card = el('section', { className: 'card leads-card reveal' }); + const head = el('div', { className: 'trends__head', style: 'margin-bottom:6px' }); + head.append(el('h2', { textContent: 'Последние заявки' })); + const all = el('button', { type: 'button', className: 'card-link', textContent: 'Все заявки →' }); + all.onclick = () => go('leads'); + head.append(all); + card.append(head); + const r = await api('/admin/leads?limit=4'); + const list = r.data || []; + const table = el('table', { className: 'leads-table' }); + table.innerHTML = 'Дата / времяПациентТелефонОткудаВрач или услугаСтатус'; + const tb = el('tbody'); + if (!list.length) { + const tr = el('tr'); tr.append(el('td', { colSpan: 6, className: 'soft', textContent: 'Заявок пока нет — они появятся здесь сразу после отправки формы на сайте.' })); tb.append(tr); + } + const df = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }); + const tf = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }); + for (const l of list) { + const tr = el('tr'); + const when = el('td'); when.append(df.format(new Date(l.created_at)), el('small', { textContent: tf.format(new Date(l.created_at)) })); + const [label, cls] = LEAD_STATUS[l.status] || [l.status, 'grey']; + const st = el('td'); st.append(el('span', { className: 'pill ' + cls, textContent: label })); + tr.append(when, el('td', { className: 'strong', textContent: l.name || '—' }), el('td', { textContent: l.phone }), + el('td', { className: 'soft', textContent: l.form_title || '—' }), el('td', { className: 'soft', textContent: l.doctor_name || l.service_name || '—' }), st); + tr.tabIndex = 0; + const open = () => { go('leads'); Leads.open(l); }; + tr.onclick = open; tr.onkeydown = (e) => { if (e.key === 'Enter') open(); }; + tb.append(tr); + } + table.append(tb); + card.append(table); + return card; +} + +/* ---------- сборка экрана ---------- */ +const Dashboard = { + // Сводка нужна и шапке (календарь, колокольчик, бейдж заявок) — на любой странице админки. + async summary() { + const r = await api('/admin/dashboard'); + const d = r.data; + d.days = d.days || []; d.upcoming = d.upcoming || []; d.todo = d.todo || []; + Topbar.calendar(d.upcoming); + Topbar.todo(d.todo); + const badge = $('#ld-badge'); + if (badge) { badge.textContent = d.new_leads; badge.hidden = !d.new_leads; } + return d; + }, + + async load() { + const root = $('#tab-home'); + try { + const d = await Dashboard.summary(); + const grid = el('div', { className: 'dash' }); + const left = el('div', { className: 'dash__heart' }); + const right = el('div', { className: 'dash__right' }); + left.append(await HeartVisualization(d)); + right.append(ClinicSummary(d), TrendsCard(d), await LeadsTable()); + grid.append(left, right); + root.textContent = ''; + root.append(grid); + } catch (e) { + root.textContent = ''; + root.append(el('div', { className: 'card', textContent: 'Не удалось загрузить сводку: ' + e.message })); + } + }, +}; diff --git a/internal/api/adminui/doctors-dash.js b/internal/api/adminui/doctors-dash.js new file mode 100644 index 0000000..44dface --- /dev/null +++ b/internal/api/adminui/doctors-dash.js @@ -0,0 +1,236 @@ +/* «Дашборд врачей»: полнота карточек, онлайн-запись, отзывы, заявки. + Всё считается по текущим данным базы при каждом открытии. */ + +const DD_ICONS = { + id: '', + photo: '', + clock: '', + award: '', + alert: '', + star: '', + link: '', + users: '', + check: '', + chat: '', + calendar: '', + download: '', +}; +const ddIcon = (n) => ``; +const DoctorDash = { + q: null, + + async open() { + try { + const [r] = await Promise.all([api('/admin/doctors-quality'), adminDoctors.length ? null : loadAdminDoctors()]); + DoctorDash.q = r.data; + DoctorDash.fillFilter(); + DoctorDash.render(); + } catch (e) { toast(e.message, true); } + }, + + fillFilter() { + const sel = $('#dd-spec'); const keep = sel.value; + sel.textContent = ''; + sel.append(el('option', { value: '', textContent: 'Все специальности' })); + for (const s of specialtiesCache.filter((x) => x.doctors)) sel.append(el('option', { value: s.id, textContent: `${s.name_plural} (${s.doctors})` })); + sel.value = keep; if (sel.value !== keep) sel.value = ''; + }, + + // врачи с данными из обоих источников + rows() { + const byId = new Map(DoctorDash.q.doctors.map((x) => [x.id, x])); + const spec = $('#dd-spec').value; + return adminDoctors.filter((d) => !spec || String(d.specialty_id) === spec) + .map((d) => ({ d, q: byId.get(d.id) || {} })); + }, + + render() { + const rows = DoctorDash.rows(), n = rows.length, Q = DoctorDash.q; + const cnt = (f) => rows.filter(f).length; + const basic = cnt(DoctorDash.metrics[0][1]); + $('#dd-count').textContent = `${n} ${plural(n, 'врач', 'врача', 'врачей')}`; + + // четыре круглых показателя + const stats = [ + ['id', '#e3eefb', '#4a7fc4', basic, 'ФИО, должность и специальность', ''], + ['photo', '#fbe7ec', '#c7627a', cnt(({ d }) => !!d.photo), 'с фото', 'no-photo'], + ['clock', '#fdeedd', '#c9803f', cnt(({ d }) => d.practice_start_year != null), 'указан стаж', 'no-experience'], + ['award', '#e2f4ec', '#3c9670', cnt(({ d }) => !!d.category), 'с категорией', 'no-category'], + ]; + const box = $('#dd-stats'); box.textContent = ''; + for (const [icon, bg, ink, v, label, filter] of stats) { + const b = el('button', { type: 'button', className: 'dd-stat' }); + b.innerHTML = `${ddIcon(icon)} + ${v}из ${n}${label}`; + b.onclick = () => DoctorDash.toList(filter); + box.append(b); + } + + DoctorDash.chart(rows); + + // требуют внимания + const noPhoto = n - stats[1][3]; + const withServices = cnt(({ d }) => d.service_ids.length > 0); + const att = [ + ['photo', noPhoto, plural(noPhoto, 'карточка без фото', 'карточки без фото', 'карточек без фото'), () => DoctorDash.toList('no-photo')], + ['chat', Q.reviews_pending_total, plural(Q.reviews_pending_total, 'отзыв ждёт публикации', 'отзыва ждут публикации', 'отзывов ждут публикации'), () => go('reviews')], + ['link', withServices, `${plural(withServices, 'врач', 'врача', 'врачей')} с указанными услугами`, () => DoctorDash.toList('no-services')], + ]; + const ab = $('#dd-attention'); ab.textContent = ''; + for (const [icon, v, label, fn] of att) { + const b = el('button', { type: 'button', className: 'dd-att' }); + b.innerHTML = `${ddIcon(icon)}${v}${label}`; + b.onclick = fn; + ab.append(b); + } + + DoctorDash.details(rows); + }, + + // Инфографика: по каждому разделу — мягкий «холм», высота = сколько врачей его заполнили. + // Холмы соседних разделов перекрываются полупрозрачными слоями; шкала — от 0 до числа врачей. + metrics: [ + ['Основные сведения', ({ d }) => !!(d.last_name && d.first_name && d.position && d.specialties)], + ['Фото', ({ d }) => !!d.photo], + ['Стаж', ({ d }) => d.practice_start_year != null], + ['Онлайн-запись', ({ d }) => d.booking_provider === 'medflex'], + ['Категория', ({ d }) => !!d.category], + ['«О враче»', ({ d }) => !!d.bio], + ], + // холодный голубой, персиковый, пудровый, светло-лавандовый + hills: ['#9cc0ec', '#f4c09a', '#eeb0bd', '#c3b8ee'], + + chart(rows) { + const box = $('#dd-chart'); box.textContent = ''; + const n = rows.length; + $('#dd-total').textContent = `${n} ${plural(n, 'врач', 'врача', 'врачей')}`; + const data = DoctorDash.metrics.map(([name, f]) => ({ name, v: rows.filter(f).length })); + const W = Math.max(box.clientWidth, 340), H = 290, L = 8, R = 40, T = 18, B = 46; + const max = Math.max(n, 1); + // поля по краям, чтобы крайние холмы плавно уходили в ноль, а не обрывались + const step = (W - L - R) / (data.length + 1.9); + const cx = (i) => L + step * (i + 1.45); + const y = (v) => T + (H - T - B) * (1 - v / max); + const sigma = step * 0.55; + const hill = (i, v) => { + const pts = []; + for (let k = -2.6; k <= 2.601; k += 0.1) { + const xx = cx(i) + k * sigma; + if (xx < L || xx > W - R) continue; + pts.push(`${xx.toFixed(1)} ${y(v * Math.exp(-(k * k) / 2)).toFixed(1)}`); + } + const x0 = Math.max(L, cx(i) - 2.6 * sigma), x1 = Math.min(W - R, cx(i) + 2.6 * sigma); + return { area: `M${x0.toFixed(1)} ${y(0)} L${pts.join(' L')} L${x1.toFixed(1)} ${y(0)} Z`, line: `M${pts.join(' L')}` }; + }; + const ticks = [0, 0.25, 0.5, 0.75, 1].map((t) => Math.round(max * t)); + let grid = ''; + for (const t of ticks) grid += `${t}`; + // высокие холмы — дальше, низкие — ближе, чтобы было видно все + const order = data.map((d, i) => i).sort((a, b) => data[b].v - data[a].v); + const defs = DoctorDash.hills.map((c, i) => ` + `).join(''); + const hills = order.map((i) => { + const h = hill(i, data[i].v), c = i % DoctorDash.hills.length; + return ``; + }).join(''); + const labels = data.map((d, i) => `${d.name} + ${d.v}`).join(''); + box.innerHTML = ` + ${defs}${grid}${hills}${labels}`; + const svg = box.querySelector('svg'), tip = box.querySelector('.dd-tip'), guide = svg.querySelector('.dd-guide'); + const show = (i) => { + svg.querySelectorAll('.dd-hill').forEach((g) => g.classList.toggle('on', Number(g.dataset.i) === i)); + guide.setAttribute('x1', cx(i)); guide.setAttribute('x2', cx(i)); guide.hidden = false; + const r = svg.getBoundingClientRect(); + tip.hidden = false; + tip.innerHTML = `${data[i].v} из ${n}${data[i].name} · ${n ? Math.round((data[i].v / n) * 100) : 0}%`; + tip.style.left = (cx(i) / W) * r.width + 'px'; + tip.style.top = (y(data[i].v) / H) * r.height + 'px'; + }; + svg.addEventListener('mousemove', (e) => { + const r = svg.getBoundingClientRect(); + const px = ((e.clientX - r.left) / r.width) * W; + show(Math.max(0, Math.min(data.length - 1, Math.round((px - L) / step - 1.45)))); + }); + svg.addEventListener('mouseleave', () => { + tip.hidden = true; guide.hidden = true; + svg.querySelectorAll('.dd-hill').forEach((g) => g.classList.remove('on')); + }); + }, + + details(rows) { + const n = rows.length, cnt = (f) => rows.filter(f).length; + const sum = (k) => rows.reduce((a, r) => a + (r.q[k] || 0), 0); + const pending = sum('reviews_pending'), live = sum('reviews_published'); + const withReviews = cnt(({ q }) => q.reviews_pending + q.reviews_published > 0); + const rs = sum('rating_sum'), rc = sum('rating_count'); + const leads = sum('leads'), Q = DoctorDash.q; + const extra = cnt(({ d, q }) => d.quote || q.kids_from || q.languages || d.show_on_home || d.absent); + const list = [ + ['«О враче»', cnt(({ d }) => !!d.bio), (v) => `заполнено у ${v}`, 'no-bio'], + ['Образование', cnt(({ q }) => q.education > 0), (v) => `заполнено у ${v}`], + ['Награды', cnt(({ q }) => q.awards > 0), (v) => `заполнено у ${v}`], + ['Конференции', cnt(({ q }) => q.conferences > 0), (v) => `заполнено у ${v}`], + ['Онлайн-запись Medflex', cnt(({ d }) => d.booking_provider === 'medflex'), (v) => `подключена у ${v}`, 'no-medflex'], + ['Услуги врача', cnt(({ d }) => d.service_ids.length > 0), (v) => v ? `указаны у ${v}` : 'не указаны', 'no-services'], + ['Филиалы приёма', cnt(({ d }) => d.branch_ids.length > 0), (v) => v ? `указаны у ${v}` : 'не заполнены', 'no-branches'], + ['Отзывы', withReviews, () => (pending + live + ? `${pending} на проверке у ${cnt(({ q }) => q.reviews_pending > 0)} ${plural(cnt(({ q }) => q.reviews_pending > 0), 'врача', 'врачей', 'врачей')}` + + `, ${live ? 'опубликовано ' + live : 'опубликованных нет'}` + (rc ? `, средняя оценка ${(rs / rc).toFixed(2).replace('.', ',')}` : '') + : 'отзывов пока нет'), 'reviews'], + ['Заявки к врачу', cnt(({ q }) => q.leads > 0), () => `пока ${leads} из ${Q.leads_total} ${plural(Q.leads_total, 'заявки', 'заявок', 'заявок')}`], + ['Дополнительно: цитата, возраст детей, языки, «на главной», «не принимает»', extra, (v) => v ? `заполнено у ${v}` : 'пока не заполнены'], + ]; + const box = $('#dd-details'); box.textContent = ''; + for (const [label, v, text, target] of list) { + const p = n ? Math.round((v / n) * 100) : 0; + const [st, cls] = !v ? ['пусто', 'empty'] : p >= 90 ? ['заполнено', 'ok'] : ['требует внимания', 'warn']; + const row = el(target ? 'button' : 'div', { className: 'dd-row ' + cls }); + if (target) { row.type = 'button'; row.onclick = () => (target === 'reviews' ? go('reviews') : DoctorDash.toList(target)); } + row.innerHTML = ` + ${p}%${st}`; + row.querySelector('.dd-row__label').textContent = label; + row.querySelector('.dd-row__text').textContent = text(v); + box.append(row); + } + }, + + toList(filter) { + go('doctors'); + Doctors.setFilter(filter || ''); + }, + + // отчёт по каждому врачу: что заполнено, что нет + exportCSV() { + const yes = (b) => (b ? 'да' : 'нет'); + const rows = [['ФИО', 'Специальность', 'Фото', 'Стаж', 'Категория', '«О враче»', 'Образование', 'Награды', 'Конференции', + 'Онлайн-запись', 'Услуг', 'Филиалов', 'Отзывов на проверке', 'Отзывов опубликовано', 'Заявок']]; + for (const { d, q } of DoctorDash.rows()) { + rows.push([fullName(d), d.specialties, yes(d.photo), d.practice_start_year ? new Date().getFullYear() - d.practice_start_year + ' лет' : 'нет', + d.category || 'нет', yes(d.bio), q.education || 0, q.awards || 0, q.conferences || 0, d.booking_provider === 'medflex' ? 'Medflex' : 'заявка', + d.service_ids.length, d.branch_ids.length, q.reviews_pending || 0, q.reviews_published || 0, q.leads || 0]); + } + const esc = (v) => { const x = String(v ?? ''); return /[";\n]/.test(x) ? '"' + x.replace(/"/g, '""') + '"' : x; }; + const a = el('a', { href: URL.createObjectURL(new Blob(['' + rows.map((r) => r.map(esc).join(';')).join('\r\n')], { type: 'text/csv;charset=utf-8' })), + download: `kartochki-vrachey-${new Date().toISOString().slice(0, 10)}.csv` }); + document.body.append(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + toast('Отчёт выгружен: ' + (rows.length - 1) + ' ' + plural(rows.length - 1, 'врач', 'врача', 'врачей')); + }, +}; + +$('#dd-spec').onchange = () => DoctorDash.render(); +window.addEventListener('resize', debounce(() => { if (DoctorDash.q && !$('#tab-doctors-dash').hidden) DoctorDash.chart(DoctorDash.rows()); }, 150)); +{ + const acts = [['users', 'Все врачи', () => DoctorDash.toList('')], ['check', 'Проверить карточки', () => DoctorDash.toList('incomplete')], + ['chat', 'Отзывы', () => go('reviews')], ['calendar', 'Онлайн-запись', () => DoctorDash.toList('no-medflex')], + ['download', 'Экспорт отчёта', () => DoctorDash.exportCSV()]]; + const bar = $('#dd-actions'); + for (const [icon, label, fn] of acts) { + const b = el('button', { type: 'button', className: 'dd-act' }); + b.innerHTML = ddIcon(icon) + `${label}`; + b.onclick = fn; + bar.append(b); + } +} diff --git a/internal/api/adminui/doctors.js b/internal/api/adminui/doctors.js new file mode 100644 index 0000000..d4575b9 --- /dev/null +++ b/internal/api/adminui/doctors.js @@ -0,0 +1,366 @@ +/* Раздел «Врачи»: таблица с сортировкой и фильтрами, быстрые действия и сводка справа. + Карточка врача и «Услуги и филиалы» — прежние диалоги editDoctor / editDoctorLinks. */ + +const DR_ICONS = { + building: '', + pencil: '', + dots: '', + plus: '', + download: '', + list: '', + people: '', + info: '', + close: '', + sort: '', + search: '', +}; +const drIcon = (n) => ``; + +let adminDoctors = []; +let specialtiesCache = []; + +const TRANSLIT = { а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', + н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'kh', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'shch', ъ: '', ы: 'y', + ь: '', э: 'e', ю: 'yu', я: 'ya' }; +const toSlug = (s) => s.toLowerCase().split('').map((c) => TRANSLIT[c] ?? c).join('') + .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100); + +const Doctors = { + sort: { key: '', dir: 1 }, + noteKey: 'dana-admin:dr-note-hidden', + + status(d) { + if (!d.published) return ['скрыт', 'off']; + if (d.absent) return ['не принимает', 'wait']; + return ['на сайте', '']; + }, + + filtered() { + const q = $('#dr-q').value.trim().toLowerCase(); + const f = $('#dr-filter').value; + let list = adminDoctors.filter((d) => + (!q || (fullName(d) + ' ' + d.position + ' ' + (d.specialties || '')).toLowerCase().includes(q)) && + (f !== 'published' || d.published) && (f !== 'hidden' || !d.published) && (f !== 'absent' || d.absent) && + (f !== 'no-services' || !d.service_ids.length) && (f !== 'no-branches' || !d.branch_ids.length) && + (f !== 'no-photo' || !d.photo) && + (f !== 'no-experience' || d.practice_start_year == null) && (f !== 'no-category' || !d.category) && + (f !== 'no-bio' || !d.bio) && (f !== 'no-medflex' || d.booking_provider !== 'medflex') && + (f !== 'incomplete' || !d.photo || d.practice_start_year == null || (!d.bio && !d.bio_count)) && + (!f.startsWith('sp:') || String(d.specialty_id) === f.slice(3))); + const { key, dir } = Doctors.sort; + if (key === 'name') list = list.slice().sort((a, b) => dir * fullName(a).localeCompare(fullName(b), 'ru')); + if (key === 'services') list = list.slice().sort((a, b) => dir * (a.service_ids.length - b.service_ids.length)); + return list; + }, + + render() { + const list = Doctors.filtered(); + $('#dr-count').textContent = list.length + ' ' + plural(list.length, 'врач', 'врача', 'врачей'); + const tb = $('#dr-body'); tb.textContent = ''; + if (!list.length) { + const tr = el('tr'); tr.append(el('td', { colSpan: 6, className: 'muted dr-empty', textContent: 'Никого не нашлось — измените поиск или фильтр.' })); + tb.append(tr); + } + for (const d of list) tb.append(Doctors.row(d)); + for (const th of document.querySelectorAll('#tab-doctors th[data-sort]')) { + const on = th.dataset.sort === Doctors.sort.key; + th.setAttribute('aria-sort', on ? (Doctors.sort.dir > 0 ? 'ascending' : 'descending') : 'none'); + } + Doctors.stats(); + }, + + row(d) { + const tr = el('tr'); + const who = el('td'); + const box = el('div', { className: 'dr-who' }); + box.append(d.photo ? el('img', { className: 'dr-photo', src: d.photo, alt: '', loading: 'lazy' }) + : el('span', { className: 'dr-photo dr-photo--empty', textContent: (d.last_name[0] || '') + (d.first_name[0] || '') })); + const txt = el('div'); + const name = el('button', { type: 'button', className: 'dr-name', textContent: fullName(d) }); + name.onclick = () => editDoctor(d); + txt.append(name, el('div', { className: 'dr-pos', textContent: d.position || 'должность не указана' })); + box.append(txt); who.append(box); + + const spec = el('td'); + spec.append(el('span', { className: 'chip', textContent: d.specialties || '—' })); + + const svc = el('td'); + svc.append(d.service_ids.length ? el('span', { className: 'dr-num', textContent: d.service_ids.length }) + : el('span', { className: 'pill warn-pill', textContent: 'нет' })); + + const br = el('td'); + const branches = d.branch_ids.map((id) => branchList.find((b) => b.id === id)).filter(Boolean).map((b) => shortAddress(b.address)); + if (branches.length) br.append(el('span', { className: 'dr-branches', textContent: branches.join('; ') })); + else br.append(el('span', { className: 'pill warn-pill', textContent: 'не указан' })); + + const st = el('td'); + const [label, cls] = Doctors.status(d); + st.append(el('span', { className: 'pill dot ' + cls, textContent: label })); + + const act = el('td', { className: 'dr-actions' }); + const links = el('button', { type: 'button', className: 'soft-btn' }); + links.innerHTML = drIcon('building') + 'Услуги и филиалы'; + links.onclick = () => editDoctorLinks(d); + const edit = el('button', { type: 'button', className: 'icon-btn', title: 'Изменить карточку' }); + edit.setAttribute('aria-label', 'Изменить: ' + fullName(d)); + edit.innerHTML = drIcon('pencil'); + edit.onclick = () => editDoctor(d); + const more = el('button', { type: 'button', className: 'icon-btn', title: 'Ещё' }); + more.setAttribute('aria-label', 'Ещё действия: ' + fullName(d)); + more.setAttribute('aria-haspopup', 'menu'); + more.innerHTML = drIcon('dots'); + more.onclick = (e) => { e.stopPropagation(); Doctors.menu(d, more); }; + act.append(links, edit, more); + + tr.append(who, spec, svc, br, st, act); + return tr; + }, + + // Меню «…»: открыть на сайте, скрыть/показать, «не принимает». + menu(d, anchor) { + Doctors.closeMenu(); + const m = el('div', { className: 'menu', id: 'dr-menu', role: 'menu' }); + const item = (text, fn, danger) => { + const b = el('button', { type: 'button', role: 'menuitem', className: danger ? 'danger' : '', textContent: text }); + b.onclick = () => { Doctors.closeMenu(); fn(); }; + m.append(b); + }; + if (d.published && d.specialty_slug) { + item('Открыть страницу на сайте', () => window.open(`/company/staff/${d.specialty_slug}/${d.slug}/`, '_blank', 'noopener')); + } + item(d.absent ? 'Снова принимает' : 'Временно не принимает', () => Doctors.patch(d, { absent: !d.absent }, + d.absent ? 'Кнопка записи снова видна' : 'Кнопка записи скрыта')); + item(d.published ? 'Скрыть с сайта' : 'Показать на сайте', () => Doctors.patch(d, { published: !d.published }, + d.published ? 'Врач скрыт с сайта' : 'Врач на сайте'), d.published); + document.body.append(m); + const r = anchor.getBoundingClientRect(); + m.style.top = (r.bottom + window.scrollY + 6) + 'px'; + m.style.left = Math.max(12, r.right + window.scrollX - m.offsetWidth) + 'px'; + m.querySelector('button').focus(); + anchor.setAttribute('aria-expanded', 'true'); + Doctors.menuAnchor = anchor; + }, + closeMenu() { + $('#dr-menu')?.remove(); + Doctors.menuAnchor?.setAttribute('aria-expanded', 'false'); + Doctors.menuAnchor = null; + }, + + async patch(d, change, message) { + const body = { + slug: d.slug, last_name: d.last_name, first_name: d.first_name, middle_name: d.middle_name, + position: d.position, category: d.category, degree: d.degree, practice_start_year: d.practice_start_year, rating: d.rating, + photo_id: d.photo_id, quote: d.quote, bio: d.bio, booking_provider: d.booking_provider, medflex_user: d.medflex_user, + sort: d.sort, published: d.published, absent: d.absent, show_on_home: d.show_on_home, + seo_title: d.seo_title, seo_description: d.seo_description, seo_h1: d.seo_h1, ...change, + }; + try { + await api('/admin/doctors/' + d.id, { method: 'PUT', body: JSON.stringify(body) }); + toast(message); + await loadAdminDoctors(); + } catch (e) { toast(e.message, true); } + }, + + stats() { + const all = adminDoctors; + const n = (f) => all.filter(f).length; + $('#dr-total').textContent = all.length; + const cells = [ + ['published', 'На сайте', n((d) => d.published), 'ok'], + ['no-services', 'Нет услуг', n((d) => !d.service_ids.length), 'warn'], + ['no-branches', 'Нет филиалов', n((d) => !d.branch_ids.length), 'rose'], + ]; + const box = $('#dr-stats'); box.textContent = ''; + for (const [key, label, value, tone] of cells) { + const b = el('button', { type: 'button', className: 'mini-stat ' + tone }); + b.setAttribute('aria-pressed', String($('#dr-filter').value === key)); + b.innerHTML = `${label}${value}`; + b.onclick = () => Doctors.setFilter($('#dr-filter').value === key ? '' : key); + box.append(b); + } + }, + + setFilter(value) { + $('#dr-filter').value = value; + Doctors.render(); + }, + + fillFilter() { + const sel = $('#dr-filter'); + const keep = sel.value; + sel.textContent = ''; + const base = [['', 'Все врачи'], ['published', 'На сайте'], ['hidden', 'Скрытые'], ['absent', 'Не принимают'], + ['no-services', 'Без услуг'], ['no-branches', 'Без филиала'], ['no-photo', 'Без фото'], + ['incomplete', 'Неполные карточки'], ['no-experience', 'Без стажа'], ['no-category', 'Без категории'], + ['no-bio', 'Без «О враче»'], ['no-medflex', 'Без онлайн-записи']]; + for (const [v, t] of base) sel.append(el('option', { value: v, textContent: t })); + const grp = el('optgroup', { label: 'Специальность' }); + for (const s of specialtiesCache.filter((s) => s.doctors)) grp.append(el('option', { value: 'sp:' + s.id, textContent: `${s.name_plural} (${s.doctors})` })); + sel.append(grp); + sel.value = keep; + if (sel.value !== keep) sel.value = ''; + }, + + add() { + if (!specialtiesCache.length) { toast('Сначала добавьте специальность', true); return; } + openDialog('Новый врач', [ + { key: 'last_name', label: 'Фамилия', value: '' }, + { key: 'first_name', label: 'Имя', value: '' }, + { key: 'middle_name', label: 'Отчество', value: '' }, + { key: 'position', label: 'Должность, как на сайте (например, «Акушер-гинеколог, врач УЗИ»)', value: '' }, + { key: 'specialty_id', label: 'Специальность — раздел в адресе страницы, потом не меняется', type: 'select', value: '', + options: [{ value: '', label: '— выберите —' }].concat(specialtiesCache.map((s) => ({ value: s.id, label: s.name_plural }))) }, + { key: 'slug', label: 'Адрес латиницей — заполнится сам из ФИО', value: '' }, + ], async (data) => { + if (!data.slug.trim()) data.slug = toSlug([data.last_name, data.first_name, data.middle_name].join(' ')); + data.specialty_id = Number(data.specialty_id) || 0; + const r = await api('/admin/doctors', { method: 'POST', body: JSON.stringify(data) }); + toast('Врач добавлен скрытым — заполните карточку и включите «Показывать на сайте»'); + await loadAdminDoctors(); + const d = adminDoctors.find((x) => x.id === r.data.id); + if (d) setTimeout(() => editDoctor(d), 50); + }); + // адрес подставляется из ФИО, пока его не правили руками + const inputs = [...$('#dlg-body').querySelectorAll('input')]; + const slug = inputs[inputs.length - 1]; + let touched = false; + slug.addEventListener('input', () => { touched = true; }); + for (const i of inputs.slice(0, 3)) i.addEventListener('input', () => { + if (!touched) slug.value = toSlug(inputs.slice(0, 3).map((x) => x.value).join(' ')); + }); + }, + + // Список врачей в CSV для Excel: открытые данные с сайта, без персональных данных пациентов. + exportCSV() { + const list = Doctors.filtered(); + const rows = [['ФИО', 'Должность', 'Специальность', 'Услуг', 'Филиалы', 'Статус', 'Адрес на сайте']]; + for (const d of list) { + const branches = d.branch_ids.map((id) => branchList.find((b) => b.id === id)).filter(Boolean).map((b) => shortAddress(b.address)); + rows.push([fullName(d), d.position, d.specialties, d.service_ids.length, branches.join('; '), Doctors.status(d)[0], + d.specialty_slug ? `${location.origin}/company/staff/${d.specialty_slug}/${d.slug}/` : '']); + } + const esc = (v) => { const s = String(v ?? ''); return /[";\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; + const csv = '' + rows.map((r) => r.map(esc).join(';')).join('\r\n'); + const a = el('a', { href: URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })), + download: `vrachi-${new Date().toISOString().slice(0, 10)}.csv` }); + document.body.append(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + toast('Выгружено: ' + list.length + ' ' + plural(list.length, 'врач', 'врача', 'врачей')); + }, +}; + +async function loadSpecialties() { + const r = await api('/admin/specialties'); + specialtiesCache = r.data || []; + Doctors.fillFilter(); +} +async function loadAdminDoctors() { + const [r] = await Promise.all([api('/admin/doctors'), loadSpecialties()]); + adminDoctors = r.data || []; + Doctors.render(); +} +// старое имя: на него ссылаются подсказки колокольчика +const renderDoctors = () => Doctors.render(); + +/* ---------- справочник специальностей ---------- */ +const Specialties = { + open() { + const dlg = $('#sp-dlg'); + Specialties.render(); + dlg.showModal(); + }, + render() { + const box = $('#sp-list'); box.textContent = ''; + for (const s of specialtiesCache) { + const row = el('div', { className: 'sp-row' }); + const txt = el('div'); + txt.append(el('b', { textContent: s.name_plural }), + el('small', { textContent: `/company/staff/${s.slug}/ · ${s.doctors} ${plural(s.doctors, 'врач', 'врача', 'врачей')}` })); + const edit = el('button', { type: 'button', className: 'icon-btn', title: 'Переименовать' }); + edit.setAttribute('aria-label', 'Переименовать: ' + s.name_plural); + edit.innerHTML = drIcon('pencil'); + edit.onclick = () => Specialties.edit(s); + const del = el('button', { type: 'button', className: 'btn danger', textContent: 'Удалить', disabled: s.doctors > 0, + title: s.doctors ? 'Сначала перенесите врачей в другую специальность' : '' }); + del.onclick = () => Specialties.remove(s); + row.append(txt, edit, del); + box.append(row); + } + }, + form(s, onSave) { + $('#sp-dlg').close(); + const fields = [ + { key: 'name_plural', label: 'Название раздела на сайте (например, «Кардиологи»)', value: s?.name_plural || '' }, + { key: 'name', label: 'В единственном числе или так же (необязательно)', value: s?.name || '' }, + { key: 'sort', label: 'Порядок в списке разделов (меньше — выше)', type: 'number', value: s?.sort ?? 500 }, + ]; + if (!s) fields.push({ key: 'slug', label: 'Адрес латиницей — заполнится сам, потом не меняется', value: '' }); + openDialog(s ? 'Специальность: ' + s.name_plural : 'Новая специальность', fields, async (d) => { + d.sort = Number(d.sort) || 500; + if (!d.name.trim()) d.name = d.name_plural; + if (!s && !d.slug.trim()) d.slug = toSlug(d.name_plural); + await onSave(d); + await loadSpecialties(); + setTimeout(Specialties.open, 50); + }); + $('#dlg').addEventListener('close', function back() { + $('#dlg').removeEventListener('close', back); + if ($('#dlg').returnValue === 'cancel') setTimeout(Specialties.open, 50); + }); + }, + add() { + Specialties.form(null, async (d) => { + await api('/admin/specialties', { method: 'POST', body: JSON.stringify(d) }); + toast('Специальность добавлена'); + }); + }, + edit(s) { + Specialties.form(s, async (d) => { + await api('/admin/specialties/' + s.id, { method: 'PUT', body: JSON.stringify(d) }); + toast('Сохранено'); + loadAdminDoctors(); + }); + }, + async remove(s) { + if (!confirm(`Удалить специальность «${s.name_plural}»?`)) return; + try { + await api('/admin/specialties/' + s.id, { method: 'DELETE' }); + toast('Специальность удалена'); + await loadSpecialties(); + Specialties.render(); + } catch (e) { toast(e.message, true); } + }, +}; + +/* ---------- обработчики ---------- */ +$('#dr-q').addEventListener('input', debounce(() => Doctors.render(), 200)); +$('#dr-filter').onchange = () => Doctors.render(); +for (const th of document.querySelectorAll('#tab-doctors th[data-sort] button')) { + th.insertAdjacentHTML('beforeend', drIcon('sort')); + th.onclick = () => { + const key = th.parentElement.dataset.sort; + Doctors.sort = Doctors.sort.key === key ? { key, dir: -Doctors.sort.dir } : { key, dir: key === 'services' ? -1 : 1 }; + Doctors.render(); + }; +} +$('#dr-add').insertAdjacentHTML('afterbegin', drIcon('plus')); +$('#dr-export').insertAdjacentHTML('afterbegin', drIcon('download')); +$('#dr-spec').insertAdjacentHTML('afterbegin', drIcon('list')); +$('#dr-total-icon').innerHTML = drIcon('people'); +$('#dr-note-icon').innerHTML = drIcon('info'); +$('#dr-note-close').innerHTML = drIcon('close'); +$('#dr-search-icon').innerHTML = drIcon('search'); +$('#dr-add').onclick = () => Doctors.add(); +$('#dr-export').onclick = () => Doctors.exportCSV(); +$('#dr-spec').onclick = () => Specialties.open(); +$('#sp-add').onclick = () => Specialties.add(); +$('#dr-note-close').onclick = () => { + $('#dr-note').hidden = true; + try { localStorage.setItem(Doctors.noteKey, '1'); } catch { /* приватный режим */ } +}; +try { if (localStorage.getItem(Doctors.noteKey)) $('#dr-note').hidden = true; } catch { /* приватный режим */ } +document.addEventListener('click', (e) => { if (!e.target.closest('#dr-menu')) Doctors.closeMenu(); }); +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && $('#dr-menu')) { const a = Doctors.menuAnchor; Doctors.closeMenu(); a?.focus(); } +}); +window.addEventListener('hashchange', () => Doctors.closeMenu()); diff --git a/internal/api/adminui/fonts/montserrat-alternates-cyrillic-300-normal.woff2 b/internal/api/adminui/fonts/montserrat-alternates-cyrillic-300-normal.woff2 new file mode 100644 index 0000000..fdc79da Binary files /dev/null and b/internal/api/adminui/fonts/montserrat-alternates-cyrillic-300-normal.woff2 differ diff --git a/internal/api/adminui/fonts/montserrat-alternates-cyrillic-400-normal.woff2 b/internal/api/adminui/fonts/montserrat-alternates-cyrillic-400-normal.woff2 new file mode 100644 index 0000000..8bf7241 Binary files /dev/null and b/internal/api/adminui/fonts/montserrat-alternates-cyrillic-400-normal.woff2 differ diff --git a/internal/api/adminui/fonts/montserrat-alternates-cyrillic-500-normal.woff2 b/internal/api/adminui/fonts/montserrat-alternates-cyrillic-500-normal.woff2 new file mode 100644 index 0000000..2b14c05 Binary files /dev/null and b/internal/api/adminui/fonts/montserrat-alternates-cyrillic-500-normal.woff2 differ diff --git a/internal/api/adminui/fonts/montserrat-alternates-cyrillic-600-normal.woff2 b/internal/api/adminui/fonts/montserrat-alternates-cyrillic-600-normal.woff2 new file mode 100644 index 0000000..f86439d Binary files /dev/null and b/internal/api/adminui/fonts/montserrat-alternates-cyrillic-600-normal.woff2 differ diff --git a/internal/api/adminui/fonts/montserrat-alternates-latin-300-normal.woff2 b/internal/api/adminui/fonts/montserrat-alternates-latin-300-normal.woff2 new file mode 100644 index 0000000..4cc8475 Binary files /dev/null and b/internal/api/adminui/fonts/montserrat-alternates-latin-300-normal.woff2 differ diff --git a/internal/api/adminui/fonts/montserrat-alternates-latin-400-normal.woff2 b/internal/api/adminui/fonts/montserrat-alternates-latin-400-normal.woff2 new file mode 100644 index 0000000..f783323 Binary files /dev/null and b/internal/api/adminui/fonts/montserrat-alternates-latin-400-normal.woff2 differ diff --git a/internal/api/adminui/fonts/montserrat-alternates-latin-500-normal.woff2 b/internal/api/adminui/fonts/montserrat-alternates-latin-500-normal.woff2 new file mode 100644 index 0000000..49d7595 Binary files /dev/null and b/internal/api/adminui/fonts/montserrat-alternates-latin-500-normal.woff2 differ diff --git a/internal/api/adminui/fonts/montserrat-alternates-latin-600-normal.woff2 b/internal/api/adminui/fonts/montserrat-alternates-latin-600-normal.woff2 new file mode 100644 index 0000000..195f9ab Binary files /dev/null and b/internal/api/adminui/fonts/montserrat-alternates-latin-600-normal.woff2 differ diff --git a/internal/api/adminui/index.html b/internal/api/adminui/index.html new file mode 100644 index 0000000..92502d5 --- /dev/null +++ b/internal/api/adminui/index.html @@ -0,0 +1,2427 @@ + + + + + +DANA — админка + + + + + + + + + + + + + + +
+
Специальности
+
+

Специальность — раздел на странице «Врачи» и часть адреса страницы врача. Поэтому адрес раздела + после создания не меняется, а удалить можно только раздел без врачей.

+
+
+
+ + +
+
+
+ + +
+
Редактирование
+
+
+ + +
+
+
+ + + +
+
+ +

Заявка

Просмотр и редактирование информации о заявке

+ +
+
+

Информация о заявке

+
+

Дополнительные настройки

+
+ + +
+
+
+ + +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + diff --git a/internal/api/adminui/leads.css b/internal/api/adminui/leads.css new file mode 100644 index 0000000..3e6837e --- /dev/null +++ b/internal/api/adminui/leads.css @@ -0,0 +1,151 @@ +/* «Заявки»: отдельная голубая тема по предоставленному макету. Остальные вкладки не затрагивает. */ +body.leads-view { color: #20324c; background: radial-gradient(1100px 680px at 78% 8%, #d6eaff 0%, transparent 75%), #f2f8ff; } +body.leads-view .shell { padding: 0 34px 22px; background: radial-gradient(950px 540px at 96% 0%, #cfe5ff, transparent 80%); } +body.leads-view .top { min-height: 97px; gap: 17px; border-bottom: 1px solid #e1edfb; } +body.leads-view .top .search { width: min(720px, 48vw); } +body.leads-view .top .search input { border: 1px solid #d6e4f7; border-right: 0; border-radius: 16px 0 0 16px; height: 46px; font-size: 13px; } +body.leads-view .top .search__neck { display: none; } +body.leads-view .top .search__btn { margin-left: 0; width: 45px; height: 46px; border: 1px solid #d6e4f7; border-left: 0; border-radius: 0 16px 16px 0; } +body.leads-view .top .search__btn span { background: none; } +body.leads-view .round, body.leads-view .who__avatar { background: #fff; } +body.leads-view .round--date { width: auto; min-width: 192px; border-radius: 18px; padding: 0 16px; } +body.leads-view .who { margin-left: 12px; } +body.leads-view .page-title, body.leads-view .page-sub, body.leads-view .subtabs { display: none !important; } + +#tab-leads { max-width: 1500px; margin: 0 auto; } +.ld-heading { padding: 18px 0 20px; } +.ld-heading h1 { margin: 0 0 3px; font: 600 clamp(30px, 3vw, 42px)/1.15 var(--font-display); letter-spacing: -.04em; color: #1f3049; } +.ld-heading p { margin: 0; color: #637891; font-size: 14px; } +.ld-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; margin-bottom: 18px; } +.ld-stat { min-height: 99px; padding: 16px 23px; display: flex; align-items: center; gap: 24px; border-radius: 18px; background: #fff; box-shadow: 0 6px 30px rgba(62, 113, 175, .045); } +.ld-stat__icon { flex: 0 0 62px; height: 62px; display: grid; place-items: center; border-radius: 16px; color: #3186f4; background: linear-gradient(145deg, #edf5ff, #dcecff); } +.ld-stat__icon svg { width: 33px; height: 33px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.ld-stat:nth-child(2) .ld-stat__icon { color: #31455e; } +.ld-stat__copy { color: #617590; line-height: 1.4; white-space: nowrap; } +.ld-stat__copy strong { display: block; font: 600 27px/1.15 var(--font-display); color: #1e304a; } +.ld-stat__badge { margin-left: auto; min-width: 68px; padding: 7px 11px; border-radius: 99px; text-align: center; font-weight: 600; color: #20324c; } +.ld-stat__badge::before { content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 12px; background: #4595fb; } +.ld-stat__badge--blue { background: #e9f3ff; } +.ld-stat__badge--orange { background: #fff1df; } +.ld-stat__badge--orange::before { background: #f5a426; } +.ld-panel { padding: 24px 24px 12px; background: #fff; border-radius: 21px; box-shadow: 0 6px 30px rgba(62, 113, 175, .04); } +.ld-toolbar { display: grid; grid-template-columns: minmax(165px, 1fr) minmax(260px, 1.85fr) minmax(230px, 1.4fr) auto 52px; gap: 11px; margin-bottom: 22px; } +.ld-toolbar select, .ld-toolbar input { height: 47px; border: 1px solid #d6e3f5; border-radius: 11px; background: #fff; color: #465c79; } +.ld-toolbar select { padding-right: 30px; } +.ld-search { position: relative; } +.ld-search span { position: absolute; top: 3px; left: 15px; color: #38577c; font-size: 29px; pointer-events: none; } +.ld-search input { padding-left: 49px; } +.ld-date { display: flex; gap: 4px; border: 1px solid #d6e3f5; border-radius: 11px; align-items: center; padding: 0 8px; } +.ld-date label { min-width: 0; display: flex; align-items: center; color: #647992; font-size: 11px; } +.ld-date input { min-width: 0; width: 100%; height: 42px; padding: 2px 3px; border: 0; font-size: 11px; } +.ld-export, .ld-refresh { height: 47px; border: 0; border-radius: 11px; cursor: pointer; } +.ld-export { min-width: 160px; padding: 0 17px; display: inline-flex; align-items: center; justify-content: center; gap: 10px; color: #fff; background: linear-gradient(110deg, #6aaefb, #3287f1); font-weight: 600; } +.ld-export svg { width: 21px; height: 21px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.ld-export:disabled { opacity: .5; cursor: not-allowed; } +.ld-refresh { color: #324f70; background: #f2f7ff; border: 1px solid #d7e4f5; font-size: 26px; } +.ld-table-wrap { overflow-x: auto; border: 1px solid #e3edfa; border-radius: 11px; } +.ld-table { min-width: 830px; } +.ld-table th { padding: 13px 19px; background: #f2f7ff; color: #526782; font-size: 12px; font-weight: 600; } +.ld-table td { padding: 15px 19px; border-bottom-color: #e9f0fb; font-size: 13px; } +.ld-table tbody tr:hover { background: #f7fbff; } +.ld-table td:nth-child(5) { max-width: 320px; overflow-wrap: anywhere; } +.ld-table .pill { background: #e9f3ff; color: #2879d9; } +.ld-table .ld-empty-cell { padding: 0; } +.ld-empty { min-height: 326px; padding: 27px 20px 14px; text-align: center; display: flex; align-items: center; flex-direction: column; justify-content: center; } +.ld-empty__art { width: 245px; height: 135px; } +.ld-empty h2 { margin: 7px 0 4px; font: 600 22px var(--font-display); color: #20324c; } +.ld-empty p { max-width: 520px; margin: 0; color: #637891; } +.ld-empty__hint { margin-top: 12px; padding: 10px 24px; border-radius: 99px; color: #3078d6; background: #edf5ff; font-size: 12px; } +.ld-pager { display: flex; justify-content: space-between; align-items: center; gap: 14px; min-height: 54px; color: #56708f; font-size: 12px; } +.ld-pager__actions { display: flex; align-items: center; gap: 12px; } +.ld-pager .btn.ghost { border-radius: 10px; color: #547092; background: #f2f7ff; } +.ld-pager .btn.ghost:disabled { opacity: .5; cursor: default; } +.ld-privacy { margin: 0; padding-top: 4px; color: #879ab1; font-size: 11px; text-align: right; } +@media (max-width: 1100px) { + body.leads-view .shell { padding: 0 20px 20px; } + .ld-summary { gap: 12px; } + .ld-stat { gap: 12px; padding: 12px; } + .ld-stat__badge { min-width: 48px; } + .ld-toolbar { grid-template-columns: 1fr 2fr; } + .ld-export, .ld-refresh { width: 100%; } +} +@media (max-width: 700px) { + body.leads-view .top { min-height: 64px; } + body.leads-view .top .search { width: 100%; } + body.leads-view .top .round--date, body.leads-view .who { display: none; } + .ld-summary { grid-template-columns: 1fr; } + .ld-stat { min-height: 72px; } + .ld-stat__icon { flex-basis: 46px; height: 46px; } + .ld-stat__icon svg { width: 26px; height: 26px; } + .ld-panel { padding: 14px; } + .ld-toolbar { grid-template-columns: 1fr 1fr; } + .ld-toolbar select, .ld-search, .ld-date { grid-column: 1 / -1; } + .ld-pager { flex-wrap: wrap; } + .ld-empty__hint { border-radius: 12px; } +} + +/* ---------- карточка заявки ---------- */ +dialog.ldd { max-width: 960px; width: calc(100% - 32px); max-height: calc(100dvh - 32px); border-radius: 30px; color: #20324c; + overflow-y: auto; overscroll-behavior: contain; + background: radial-gradient(260px 160px at 72% 10%, rgba(175, 208, 250, .45), transparent 70%), + radial-gradient(220px 150px at 92% 18%, rgba(247, 196, 214, .45), transparent 70%), #f3f7fd; + box-shadow: 0 40px 90px rgba(37, 67, 110, .22); } +dialog.ldd::backdrop { background: rgba(214, 228, 245, .55); backdrop-filter: blur(8px); } +.ldd__form { padding: 22px 26px 24px; } +.ldd__head { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 18px; } +.ldd__head h2 { margin: 2px 0 2px; font: 600 26px/1.2 var(--font-display); letter-spacing: -.02em; color: #1d2f49; } +.ldd__head p { margin: 0; font-size: 14.5px; color: #6a7d95; } +.ldd__icon { flex: 0 0 58px; height: 58px; display: grid; place-items: center; border-radius: 18px; color: #3d86f0; + background: linear-gradient(145deg, #edf5ff, #dcecff); box-shadow: 0 8px 20px rgba(61, 134, 240, .14); } +.ldd__icon svg { width: 28px; height: 28px; fill: currentColor; stroke: #fff; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; } +.ldd__close { margin-left: auto; width: 42px; height: 42px; border: 0; border-radius: 14px; background: #fff; color: #53657d; cursor: pointer; + display: grid; place-items: center; box-shadow: 0 4px 14px rgba(37, 67, 110, .08); } +.ldd__close:hover { color: #1d2f49; } +.ldd__close svg, .ldd__sec svg, .ldd__ticon svg, .ldd__save svg { fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.ldd__close svg { width: 18px; height: 18px; } +.ldd__card { padding: 20px 22px 22px; border-radius: 24px; background: #fff; box-shadow: 0 10px 30px rgba(37, 67, 110, .06); } +.ldd__sec { display: flex; align-items: center; gap: 12px; margin: 0 0 16px; font-size: 16.5px; font-weight: 600; color: #1d2f49; } +.ldd__sec svg { width: 22px; height: 22px; color: #3d86f0; } +.ldd__sec--line { margin-top: 22px; padding-top: 22px; border-top: 1px solid #e8eef6; } +.ldd__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 22px; align-items: start; } +.ldd__col { display: flex; flex-direction: column; gap: 12px; min-width: 0; } +.ldd__tile { display: flex; align-items: center; gap: 16px; min-height: 64px; padding: 12px 16px; border-radius: 16px; background: #f4f8fd; } +.ldd__tile small { display: block; font-size: 12.5px; color: #6a7d95; } +.ldd__tile b { display: block; margin-top: 3px; font-size: 15.5px; font-weight: 500; color: #1d2f49; overflow-wrap: anywhere; white-space: pre-line; } +.ldd__tile a { color: inherit; text-decoration: none; } +.ldd__tile a:hover { color: #2f76f0; text-decoration: underline; } +.ldd__tile > div { min-width: 0; } +.ldd__ticon { flex: 0 0 44px; height: 44px; display: grid; place-items: center; border-radius: 13px; background: #fff; color: #5b86c7; } +.ldd__ticon svg { width: 21px; height: 21px; } +.ldd__tile--msg .ldd__ticon { background: #fdeef2; color: #e0587a; } +.ldd__field { display: flex; flex-direction: column; gap: 8px; font-size: 13.5px; color: #33475f; font-weight: 500; } +.ldd__field textarea { min-height: 90px; padding: 12px 16px; border: 1px solid #dde6f1; border-radius: 16px; background: #fff; font: inherit; font-weight: 400; + font-size: 14.5px; resize: vertical; } +.ldd__select { position: relative; display: block; } +.ldd__select select { width: 100%; height: 50px; padding: 0 44px 0 42px; border: 1.5px solid #9ec3f5; border-radius: 16px; background: #fff; font: inherit; + font-size: 15.5px; font-weight: 500; color: #1d2f49; appearance: none; cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%231d2f49' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: right 16px center; background-size: 18px; } +.ldd__select select:focus { outline: none; box-shadow: 0 0 0 4px rgba(63, 134, 242, .14); } +.ldd__dot { position: absolute; left: 18px; top: 50%; width: 11px; height: 11px; margin-top: -5.5px; border-radius: 50%; background: #22b07d; pointer-events: none; } +.ldd__dot[data-status="in_progress"] { background: #f5a426; } +.ldd__dot[data-status="done"] { background: #3d86f0; } +.ldd__dot[data-status="spam"] { background: #a3adbb; } +.ldd__foot { display: flex; justify-content: flex-end; gap: 14px; margin-top: 22px; } +.ldd__cancel, .ldd__save { height: 52px; padding: 0 28px; border: 0; border-radius: 16px; font: inherit; font-size: 15.5px; font-weight: 600; cursor: pointer; } +.ldd__cancel { background: #fff; color: #1d2f49; box-shadow: 0 4px 14px rgba(37, 67, 110, .08); } +.ldd__save { display: inline-flex; align-items: center; gap: 10px; padding: 0 32px; color: #fff; background: linear-gradient(100deg, #3a7ff0, #5c9cf6); + box-shadow: 0 12px 24px rgba(58, 127, 240, .3); } +.ldd__save:hover { filter: brightness(1.05); } +.ldd__save:disabled { opacity: .6; cursor: default; } +.ldd__save svg { width: 20px; height: 20px; } +@media (max-width: 760px) { + .ldd__form { padding: 16px 14px 16px; } + .ldd__head h2 { font-size: 21px; } + .ldd__head p { font-size: 13px; } + .ldd__icon { flex-basis: 46px; height: 46px; border-radius: 14px; } + .ldd__card { padding: 16px 14px; } + .ldd__grid { grid-template-columns: 1fr; } + .ldd__foot { gap: 10px; } + .ldd__cancel, .ldd__save { flex: 1; justify-content: center; padding: 0 14px; } +} diff --git a/internal/api/adminui/leads.js b/internal/api/adminui/leads.js new file mode 100644 index 0000000..18d3174 --- /dev/null +++ b/internal/api/adminui/leads.js @@ -0,0 +1,154 @@ +/* Раздел «Заявки»: заявки с форм сайта, статус и комментарий администратора. + Просмотр списка — доступ к персональным данным, сервер пишет его в журнал. */ + +const Leads = { + offset: 0, limit: 30, total: 0, loaded: false, visible: [], + + async load() { + const status = $('#ld-status').value; + const params = new URLSearchParams({ status, q: $('#ld-q').value.trim(), limit: Leads.limit, offset: Leads.offset }); + if ($('#ld-from').value) params.set('from', $('#ld-from').value); + if ($('#ld-to').value) params.set('to', $('#ld-to').value); + let r; + try { + r = await api(`/admin/leads?${params}`); + } catch (e) { toast(e.message, true); return; } + Leads.total = r.meta?.total || 0; + const counts = r.meta?.counts || {}; + for (const [id, value] of [['ld-new', counts.new || 0], ['ld-new-badge', counts.new || 0], + ['ld-progress', counts.in_progress || 0], ['ld-progress-badge', counts.in_progress || 0], + ['ld-today', r.meta?.today || 0], ['ld-today-badge', r.meta?.today || 0]]) $('#' + id).textContent = value; + for (const o of $('#ld-status').options) { + const n = o.value ? counts[o.value] || 0 : Object.values(counts).reduce((a, b) => a + b, 0); + o.textContent = o.textContent.replace(/ \(\d+\)$/, '') + ` (${n})`; + } + const badge = $('#ld-badge'); + if (badge) { badge.textContent = counts.new || ''; badge.hidden = !counts.new; } + $('#ld-count').textContent = `Показано ${r.data?.length || 0} из ${Leads.total} ${plural(Leads.total, 'заявки', 'заявок', 'заявок')}`; + const tb = $('#ld-body'); tb.textContent = ''; + const list = r.data || []; + Leads.visible = list; + if (!list.length) { + const tr = el('tr'); const td = el('td', { colSpan: 7, className: 'ld-empty-cell' }); + const empty = el('div', { className: 'ld-empty' }); + empty.innerHTML = ``; + const initialEmpty = Leads.total === 0 && !$('#ld-q').value && !$('#ld-from').value && !$('#ld-to').value && status === 'new'; + empty.append(el('h2', { textContent: initialEmpty ? 'Пока нет заявок' : 'Заявки не найдены' })); + if (!initialEmpty) empty.append(el('p', { textContent: 'Попробуйте изменить статус, даты или поисковый запрос.' })); + td.append(empty); tr.append(td); tb.append(tr); + } + const df = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }); + const tf = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }); + for (const l of list) { + const tr = el('tr'); + const when = el('td'); when.append(df.format(new Date(l.created_at)), el('small', { className: 'muted', textContent: ' ' + tf.format(new Date(l.created_at)) })); + const [label, cls] = LEAD_STATUS[l.status] || [l.status, 'grey']; + const st = el('td'); st.append(el('span', { className: 'pill ' + cls, textContent: label })); + const phone = el('td'); phone.append(el('a', { href: 'tel:' + l.phone.replace(/[^\d+]/g, ''), textContent: l.phone, onclick: (e) => e.stopPropagation() })); + tr.append(when, el('td', { textContent: l.name || '—' }), phone, el('td', { className: 'muted', textContent: l.form_title || '—' }), + el('td', { className: 'muted rv-text', textContent: l.message || l.doctor_name || l.service_name || '—' }), st); + const act = el('td', { className: 'right' }); + const e = el('button', { className: 'btn ghost', textContent: 'Открыть' }); + e.onclick = (ev) => { ev.stopPropagation(); Leads.open(l); }; + act.append(e); tr.append(act); + tr.style.cursor = 'pointer'; + tr.onclick = () => Leads.open(l); + tb.append(tr); + } + const from = Leads.total ? Leads.offset + 1 : 0; + $('#ld-page').textContent = `${from}–${Math.min(Leads.offset + Leads.limit, Leads.total)} из ${Leads.total}`; + $('#ld-prev').disabled = Leads.offset === 0; + $('#ld-next').disabled = Leads.offset + Leads.limit >= Leads.total; + $('#ld-export').disabled = list.length === 0; + }, + + exportVisible() { + if (!Leads.visible.length) return; + const columns = ['Дата', 'Имя', 'Телефон', 'Форма', 'Сообщение', 'Статус']; + const safe = (value) => { + let text = String(value ?? '').replace(/\r?\n/g, ' '); + if (/^[=+\-@\t]/.test(text)) text = "'" + text; + return '"' + text.replace(/"/g, '""') + '"'; + }; + const rows = Leads.visible.map((l) => [new Date(l.created_at).toLocaleString('ru-RU'), l.name, l.phone, l.form_title, l.message, LEAD_STATUS[l.status]?.[0] || l.status]); + const blob = new Blob(['\ufeff' + [columns, ...rows].map((row) => row.map(safe).join(';')).join('\r\n')], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob), a = document.createElement('a'); + a.href = url; a.download = `dana-leads-page-${new Date().toISOString().slice(0, 10)}.csv`; a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }, + + // карточка заявки: слева — когда и кто, справа — к кому и откуда; ниже статус и комментарий + open(l) { + const df = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + const left = [['Когда', 'calendar', df.format(new Date(l.created_at)).replace(', ', ' в ')], ['Форма', 'doc', l.form_title], + ['Имя', 'user', l.name], ['Телефон', 'phone', l.phone], ['E-mail', 'mail', l.email]]; + // желаемые дата и время из формы записи (payload): «2026-10-20» и «10:00» или старое «Утро, 08:00–12:00» + const p = l.payload || {}; + const wishDate = typeof p.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(p.date) ? p.date.split('-').reverse().join('.') : ''; + const wish = [wishDate, typeof p.time === 'string' ? p.time : ''].filter(Boolean).join(', '); + const right = [['Врач', 'doctor', l.doctor_name], ['Желаемое время', 'calendar', wish], ['Услуга', 'heart', l.service_name], ['Сообщение', 'chat', l.message], + ['Страница', 'link', l.page_url]]; + for (const [box, rows] of [[$('#ldd-left'), left], [$('#ldd-right'), right]]) { + box.textContent = ''; + for (const [label, icon, value] of rows.filter((r) => r[2])) { + const tile = el('div', { className: 'ldd__tile' + (icon === 'chat' ? ' ldd__tile--msg' : '') }); + tile.innerHTML = `${lddIcon(icon)}
`; + tile.querySelector('small').textContent = label; + const v = tile.querySelector('b'); + if (icon === 'phone') v.append(el('a', { href: 'tel:+' + String(value).replace(/\D/g, ''), textContent: value })); + else if (icon === 'mail') v.append(el('a', { href: 'mailto:' + value, textContent: value })); + else if (icon === 'link') v.append(el('a', { href: value, target: '_blank', rel: 'noopener', textContent: value.replace(/^https?:\/\/[^/]+/, '') || value })); + else v.textContent = value; + box.append(tile); + } + } + const sel = $('#ldd-status'); + sel.textContent = ''; + for (const [value, [label]] of Object.entries(LEAD_STATUS)) sel.append(el('option', { value, textContent: label })); + sel.value = l.status; + const dot = () => { $('#ldd-dot').dataset.status = sel.value; }; + sel.onchange = dot; dot(); + $('#ldd-comment').value = l.comment || ''; + const dlg = $('#ld-dlg'); + $('#ldd-save').onclick = async (e) => { + e.preventDefault(); + const b = $('#ldd-save'); b.disabled = true; + try { + await api('/admin/leads/' + l.id, { method: 'PUT', body: JSON.stringify({ status: sel.value, comment: $('#ldd-comment').value }) }); + toast('Заявка сохранена'); + dlg.close(); + Leads.load(); + } catch (err) { toast(err.message, true); } + b.disabled = false; + }; + dlg.showModal(); + }, +}; + +const LDD_ICONS = { + doc: '', + close: '', + user: '', + doctor: '', + gear: '', + calendar: '', + phone: '', + mail: '', + heart: '', + chat: '', + link: '', + save: '', +}; +const lddIcon = (n) => ``; +document.querySelectorAll('[data-ldd-icon]').forEach((e) => { e.innerHTML = lddIcon(e.dataset.lddIcon); }); +$('#ldd-close').onclick = () => $('#ld-dlg').close(); +$('#ldd-cancel').onclick = () => $('#ld-dlg').close(); +$('#ld-dlg').addEventListener('click', (e) => { if (e.target === e.currentTarget) e.currentTarget.close(); }); + +$('#ld-status').onchange = () => { Leads.offset = 0; Leads.load(); }; +$('#ld-q').addEventListener('input', debounce(() => { Leads.offset = 0; Leads.load(); }, 300)); +for (const id of ['ld-from', 'ld-to']) $('#' + id).onchange = () => { Leads.offset = 0; Leads.load(); }; +$('#ld-refresh').onclick = () => Leads.load(); +$('#ld-export').onclick = () => Leads.exportVisible(); +$('#ld-prev').onclick = () => { Leads.offset = Math.max(0, Leads.offset - Leads.limit); Leads.load(); }; +$('#ld-next').onclick = () => { Leads.offset += Leads.limit; Leads.load(); }; diff --git a/internal/api/adminui/onec.css b/internal/api/adminui/onec.css new file mode 100644 index 0000000..52b79e5 --- /dev/null +++ b/internal/api/adminui/onec.css @@ -0,0 +1,27 @@ +#tab-onec .oc-head { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 4px; } +#tab-onec .oc-head h2 { margin: 0; font-size: 17px; } +#tab-onec .oc-head .spacer { flex: 1; } +#tab-onec .oc-line { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 12px 0 16px; } +#tab-onec .oc-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; } +#tab-onec .oc-stat { background: var(--field); border-radius: 14px; padding: 14px 16px; } +#tab-onec .oc-stat b { display: block; font-size: 24px; } +#tab-onec .oc-stat span { font-size: 12px; color: var(--muted); } +#tab-onec .oc-warn { margin: 12px 0 0; padding: 12px 14px; border-radius: 12px; background: #fbf3d9; color: var(--warn); font-size: 13px; } +#tab-onec .oc-small { padding: 5px 12px; font-size: 12px; margin-left: 6px; } +#tab-onec tr.oc-off { opacity: .55; } +#tab-onec td select { max-width: 280px; width: 100%; } +#tab-onec .oc-err { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; } +#tab-onec .oc-scroll { overflow-x: auto; } +#tab-onec .oc-scroll table { min-width: 720px; } +#tab-onec .oc-grid-row { display: grid; grid-template-columns: 240px 1fr; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--line); } +#tab-onec .oc-grid-row:last-child { border-bottom: 0; } +#tab-onec .oc-grid-doc { display: flex; flex-direction: column; gap: 2px; font-size: 13px; } +#tab-onec .oc-cells { display: flex; flex-wrap: wrap; gap: 6px; align-content: flex-start; } +#tab-onec .oc-cell { padding: 4px 10px; border-radius: 8px; font-size: 12px; font-variant-numeric: tabular-nums; } +#tab-onec .oc-cell.free { background: #e6f6ee; color: var(--mint-text); } +#tab-onec .oc-cell.busy { background: #eeefed; color: var(--muted); text-decoration: line-through; } +#tab-onec .oc-num { width: 64px; margin: 0 4px; } +#tab-onec label.oc-line { cursor: pointer; } +@media (max-width: 720px) { + #tab-onec .oc-grid-row { grid-template-columns: 1fr; gap: 8px; } +} diff --git a/internal/api/adminui/onec.js b/internal/api/adminui/onec.js new file mode 100644 index 0000000..6f277e4 --- /dev/null +++ b/internal/api/adminui/onec.js @@ -0,0 +1,281 @@ +/* «Запись через 1С»: статус синхронизации, привязка врачей 1С к карточкам сайта, + сетка окон на день и записи, сделанные с сайта. */ + +const ocEl = (t, p = {}, kids = []) => { const n = el(t, p); n.append(...kids); return n; }; + +const Onec = { + data: null, + siteDoctors: [], + gridDate: '', + busy: false, + + fmtTime(iso, opts = { hour: '2-digit', minute: '2-digit' }) { + return new Intl.DateTimeFormat('ru-RU', { timeZone: 'Europe/Moscow', ...opts }).format(new Date(iso)); + }, + fmtDateTime(iso) { + return iso ? Onec.fmtTime(iso, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'; + }, + ago(iso) { + if (!iso) return 'ещё не было'; + const m = Math.round((Date.now() - new Date(iso).getTime()) / 60000); + if (m < 1) return 'только что'; + if (m < 60) return `${m} мин назад`; + return Onec.fmtDateTime(iso); + }, + docName(d) { + return [d.last_name, d.first_name, d.middle_name].filter(Boolean).join(' '); + }, + + async open() { + if (!Onec.gridDate) Onec.gridDate = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Moscow' }); + $('#oc-date').value = Onec.gridDate; + try { + const [main, docs, booking] = await Promise.all([api('/admin/onec'), api('/admin/doctors'), api('/admin/booking-settings')]); + Onec.data = main.data; + Onec.booking = booking.data; + Onec.siteDoctors = (docs.data || []).sort((a, b) => Onec.docName(a).localeCompare(Onec.docName(b), 'ru')); + } catch (e) { + toast(e.message, true); + return; + } + Onec.renderStatus(); + Onec.renderConfirm(); + Onec.renderDoctors(); + Onec.loadGrid(); + Onec.loadAppointments(); + }, + + renderStatus() { + const d = Onec.data, s = d.status || {}; + const box = $('#oc-status'); + box.textContent = ''; + if (!d.configured) { + box.append(ocEl('p', { className: 'oc-warn', textContent: 'Связь с 1С не настроена: на сервере не заданы ONEC_API_URL и ONEC_API_KEY. Пока запись работает как обычная заявка.' })); + $('#oc-sync').disabled = true; + return; + } + $('#oc-sync').disabled = false; + const state = s.last_error ? ['off', 'Ошибка'] : (s.last_ok ? ['', 'Работает'] : ['wait', 'Ждём первую синхронизацию']); + const stale = s.last_ok && Date.now() - new Date(s.last_ok).getTime() > 30 * 60000; + if (stale) { state[0] = 'off'; state[1] = 'Данные устарели — окна на сайте скрыты'; } + const stat = (label, value) => ocEl('div', { className: 'oc-stat' }, [ocEl('b', { textContent: value }), ocEl('span', { textContent: label })]); + box.append( + ocEl('div', { className: 'oc-line' }, [ + ocEl('span', { className: 'pill dot ' + state[0], textContent: state[1] }), + ocEl('span', { className: 'muted', textContent: `1С: ${d.host} · последняя удачная синхронизация: ${Onec.ago(s.last_ok)}` }), + ]), + ocEl('div', { className: 'oc-stats' }, [ + stat('врачей в 1С', s.doctors ?? 0), + stat('привязано к карточкам', s.linked ?? 0), + stat('свободных окон', s.free_slots ?? 0), + stat('записей с сайта', s.appointments ?? 0), + ]), + ); + if (s.last_error) box.append(ocEl('p', { className: 'oc-warn', textContent: 'Последняя ошибка: ' + s.last_error })); + }, + + // Подтверждение записи оператором в Битрикс24 и проверочный звонок пациента. + renderConfirm() { + const b = Onec.booking || {}, s = b.settings || {}; + const box = $('#oc-confirm'); + box.textContent = ''; + box.append(b.confirm_enabled + ? ocEl('div', { className: 'oc-line' }, [ + ocEl('span', { className: 'pill dot', textContent: 'Включено' }), + ocEl('span', { className: 'muted', textContent: 'Запись сразу занимает окно в 1С и создаёт лид в Битрикс24 («Не обработан»). «Клиент записался» — подтверждена, любая провальная стадия — запись в 1С отменяется.' }), + ]) + : ocEl('p', { className: 'oc-warn', textContent: b.bitrix_configured + ? 'Не работает: нет связи с 1С.' + : 'Не работает: на сервере не задан BITRIX_WEBHOOK_URL. Записи с сайта не попадают в Битрикс24 и не требуют подтверждения.' })); + + const num = (value, max, label) => ocEl('input', { type: 'number', min: '0', max: String(max), step: '1', value: String(value ?? 0), className: 'oc-num', 'aria-label': label }); + const after = num(s.autocancel_hours, 168, 'Часов после записи'); + const before = num(s.cancel_before_hours, 48, 'Часов до приёма'); + const phone = ocEl('input', { type: 'checkbox', checked: !!s.phone_check, disabled: !b.smsc_configured }); + const perDoc = num(s.max_per_doctor_day, 50, 'Неподтверждённых к врачу в день'); + const perHour = num(s.max_per_hour, 1000, 'Онлайн-записей за час'); + const perPhone = num(s.max_active_per_phone, 20, 'Будущих записей на один номер'); + const save = ocEl('button', { type: 'button', className: 'btn', textContent: 'Сохранить' }); + save.onclick = () => Onec.saveConfirm({ + phone_check: phone.checked, + autocancel_hours: Number(after.value) || 0, + cancel_before_hours: Number(before.value) || 0, + max_per_doctor_day: Number(perDoc.value) || 0, + max_per_hour: Number(perHour.value) || 0, + max_active_per_phone: Number(perPhone.value) || 0, + }, save); + box.append( + ocEl('p', {}, [ + 'Отменять неподтверждённую запись через ', after, ' ч после записи или за ', before, + ' ч до приёма — что наступит раньше (но не раньше чем через 30 минут после записи). 0 — правило выключено.', + ]), + ocEl('label', { className: 'oc-line' }, [phone, ocEl('span', { + textContent: 'Требовать проверочный звонок от пациента перед записью' + (b.smsc_configured ? '' : ' (недоступно: не настроен SMSC)'), + })]), + ocEl('p', { className: 'muted', textContent: 'Защита расписания от ботов (0 — правило выключено). Обычно пациент записывается без звонка. ' + + 'Если сработало любое правило ниже, сайт просит проверочный звонок: настоящий пациент записывается дальше, бот останавливается.' + + (b.smsc_configured ? '' : ' Сейчас SMSC не настроен — вместо звонка такая запись придёт оператору заявкой, без окна в 1С.') }), + ocEl('p', {}, ['Просить звонок, если за последний час уже ', perHour, ' онлайн-записей,']), + ocEl('p', {}, ['или у врача на этот день уже ', perDoc, ' неподтверждённых записей,']), + ocEl('p', {}, ['или на этот номер уже ', perPhone, ' будущих записей.']), + ocEl('div', {}, [save]), + ); + }, + + async saveConfirm(body, btn) { + btn.disabled = true; + try { + Onec.booking = (await api('/admin/booking-settings', { method: 'PUT', body: JSON.stringify(body) })).data; + toast('Настройки записи сохранены'); + Onec.renderConfirm(); + } catch (e) { + toast(e.message, true); + btn.disabled = false; + } + }, + + renderDoctors() { + const tb = $('#oc-doctors'); tb.textContent = ''; + const rows = Onec.data.doctors || []; + $('#oc-doctors-count').textContent = `${rows.length} ${plural(rows.length, 'врач', 'врача', 'врачей')}`; + if (!rows.length) { + const tr = ocEl('tr'); tr.append(ocEl('td', { colSpan: 5, className: 'muted dr-empty', textContent: 'Врачей из 1С пока нет — дождитесь синхронизации.' })); tb.append(tr); + return; + } + const takenBy = new Map(rows.filter((r) => r.doctor_id).map((r) => [r.doctor_id, r.onec_id])); + for (const r of rows) { + const tr = ocEl('tr'); + if (!r.active) tr.className = 'oc-off'; + const name = ocEl('td'); + name.append(ocEl('b', { textContent: r.full_name }), ocEl('div', { className: 'muted', textContent: (r.specialties || []).join(', ') + (r.active ? '' : ' · в 1С не работает') })); + + const sel = ocEl('select', { 'aria-label': 'Карточка врача на сайте' }); + sel.append(ocEl('option', { value: '', textContent: '— не привязан —' })); + for (const sd of Onec.siteDoctors) { + const other = takenBy.get(sd.id); + const o = ocEl('option', { value: sd.id, textContent: Onec.docName(sd) + (other && other !== r.onec_id ? ' (занят другим врачом 1С)' : '') }); + if (sd.id === r.doctor_id) o.selected = true; + sel.append(o); + } + sel.onchange = () => Onec.link(r, sel.value ? Number(sel.value) : null); + const link = ocEl('td'); link.append(sel); + + const way = ocEl('td'); + const bookingNative = r.booking === 'native'; + if (!r.doctor_id) { + way.append(ocEl('span', { className: 'muted', textContent: 'сначала привяжите карточку' })); + } else { + const label = { native: '1С', medflex: 'Medflex', none: 'заявка', '': 'заявка' }[r.booking] || r.booking; + way.append(ocEl('span', { className: 'pill ' + (bookingNative ? '' : 'grey'), textContent: label })); + const b = ocEl('button', { type: 'button', className: 'btn ghost oc-small', textContent: bookingNative ? 'Отключить' : 'Включить запись через 1С' }); + b.onclick = () => Onec.setBooking(r, !bookingNative); + way.append(' ', b); + } + tr.append(name, link, way, ocEl('td', { textContent: String(r.free_slots ?? 0) })); + tb.append(tr); + } + }, + + async link(r, doctorID) { + try { + await api(`/admin/onec/doctors/${encodeURIComponent(r.onec_id)}/link`, { method: 'PUT', body: JSON.stringify({ doctor_id: doctorID }) }); + toast(doctorID ? 'Врач привязан к карточке. Включите запись через 1С.' : 'Привязка снята'); + } catch (e) { toast(e.message, true); } + Onec.open(); + }, + + async setBooking(r, enabled) { + try { + await api(`/admin/onec/doctors/${encodeURIComponent(r.onec_id)}/booking`, { method: 'PUT', body: JSON.stringify({ enabled }) }); + toast(enabled ? 'Запись через 1С включена: на карточке врача появятся окна' : 'Запись через 1С отключена: остаётся обычная заявка'); + // кэш врачей на других вкладках админки устарел + if (typeof loadAdminDoctors === 'function') loadAdminDoctors().catch(() => {}); + } catch (e) { toast(e.message, true); } + Onec.open(); + }, + + async sync() { + if (Onec.busy) return; + Onec.busy = true; + const b = $('#oc-sync'); b.disabled = true; b.textContent = 'Синхронизируем…'; + try { + await api('/admin/onec/sync', { method: 'POST' }); + toast('Расписание обновлено'); + } catch (e) { toast(e.message, true); } + Onec.busy = false; b.disabled = false; b.textContent = 'Синхронизировать сейчас'; + Onec.open(); + }, + + async loadGrid() { + const box = $('#oc-grid'); box.textContent = ''; + let rows; + try { rows = (await api('/admin/onec/grid?date=' + encodeURIComponent(Onec.gridDate))).data || []; } catch (e) { toast(e.message, true); return; } + if (!rows.length) { box.append(ocEl('p', { className: 'muted', textContent: 'На эту дату окон в расписании нет.' })); return; } + const byDoc = new Map(); + for (const r of rows) { + const key = r.doctor_onec_id + '|' + r.branch; + if (!byDoc.has(key)) byDoc.set(key, { name: r.doctor_name, branch: r.branch, cabinet: r.cabinet, slots: [] }); + byDoc.get(key).slots.push(r); + } + for (const g of byDoc.values()) { + const row = ocEl('div', { className: 'oc-grid-row' }); + const head = ocEl('div', { className: 'oc-grid-doc' }, [ocEl('b', { textContent: g.name }), ocEl('span', { className: 'muted', textContent: g.branch })]); + const cells = ocEl('div', { className: 'oc-cells' }); + for (const s of g.slots) { + cells.append(ocEl('span', { className: 'oc-cell ' + (s.status === 'free' ? 'free' : 'busy'), textContent: Onec.fmtTime(s.start), title: s.status === 'free' ? 'Свободно' : 'Занято' })); + } + row.append(head, cells); + box.append(row); + } + }, + + async loadAppointments() { + const tb = $('#oc-appts'); tb.textContent = ''; + let rows; + try { rows = (await api('/admin/onec/appointments?limit=100')).data || []; } catch (e) { toast(e.message, true); return; } + if (!rows.length) { + const tr = ocEl('tr'); tr.append(ocEl('td', { colSpan: 7, className: 'muted dr-empty', textContent: 'Записей с сайта через 1С пока нет.' })); tb.append(tr); + return; + } + const STATUS = { booked: ['', 'Записан'], pending: ['wait', 'Отправляется'], failed: ['off', 'Не записан'], cancelled: ['grey', 'Отменён'], arrived: ['', 'Пришёл'] }; + const CONFIRM = { + waiting: ['wait', 'Ждёт оператора'], confirmed: ['', 'Подтверждена'], cancelled: ['grey', 'Отменена оператором'], + expired: ['off', 'Не подтвердили — отменена'], missed: ['grey', 'Приём без подтверждения'], + lead_only: ['wait', 'Отправляется в Битрикс'], lead_sent: ['', 'Заявка в Битриксе'], + }; + for (const a of rows) { + const st = STATUS[a.status] || ['grey', a.status]; + const tr = ocEl('tr'); + const patient = ocEl('td'); + patient.append(ocEl('b', { textContent: a.patient_name }), ocEl('div', { className: 'muted', textContent: a.patient_phone })); + const doc = ocEl('td'); + doc.append(ocEl('span', { textContent: a.doctor_name || a.doctor_onec_id }), ocEl('div', { className: 'muted', textContent: a.branch || '' })); + const state = ocEl('td'); + state.append(ocEl('span', { className: 'pill ' + st[0], textContent: st[1] })); + if (a.error) state.append(ocEl('div', { className: 'muted oc-err', textContent: a.error, title: a.error })); + const confirm = ocEl('td'); + const cs = CONFIRM[a.confirm_state]; + if (cs) { + confirm.append(ocEl('span', { className: 'pill ' + cs[0], textContent: cs[1] })); + if (a.bitrix_lead_id) confirm.append(ocEl('div', { className: 'muted', textContent: 'лид № ' + a.bitrix_lead_id })); + if (a.confirm_error) confirm.append(ocEl('div', { className: 'muted oc-err', textContent: a.confirm_error, title: a.confirm_error })); + } else { + confirm.append(ocEl('span', { className: 'muted', textContent: '—' })); + } + tr.append( + ocEl('td', { textContent: Onec.fmtDateTime(a.created_at) }), + patient, doc, + ocEl('td', { textContent: Onec.fmtDateTime(a.start) }), + state, confirm, + ocEl('td', { className: 'muted', textContent: a.onec_appointment_id || '—' }), + ); + tb.append(tr); + } + }, + + init() { + $('#oc-sync').onclick = Onec.sync; + $('#oc-date').onchange = (e) => { Onec.gridDate = e.target.value; Onec.loadGrid(); }; + }, +}; +Onec.init(); diff --git a/internal/api/adminui/pbx.js b/internal/api/adminui/pbx.js new file mode 100644 index 0000000..893268e --- /dev/null +++ b/internal/api/adminui/pbx.js @@ -0,0 +1,453 @@ +/* «Отчёты» → «Звонки АТС»: подключение Виртуальной АТС МегаФон, загрузка звонков, + отчёт по звонкам и привязка номеров клиники к каналам. Ключ API — только на сервере (.env). */ + +const pxISO = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +const pxDate = (iso) => { const [y, m, d] = iso.split('-').map(Number); return new Date(y, m - 1, d); }; +const pxRu = (iso) => (iso ? iso.split('-').reverse().join('.') : 'дд.мм.гггг'); +const pxDur = (s) => (s >= 3600 ? `${Math.floor(s / 3600)} ч ${pad2(Math.round((s % 3600) / 60))} мин` : s >= 60 ? `${Math.round(s / 60)} мин` : `${s} с`); +const pxDT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); +const pxNum = (n) => Number(n).toLocaleString('ru-RU'); + +const PX_ICONS = { + phone: '', + phoneIn: '', + phoneOut: '', + phoneMiss: '', + people: '', + callback: '', + calendar: '', + copy: '', + info: '', + left: '', + right: '', + dots: '', + up: '', + down: '', +}; +const pxIcon = (name) => ``; +document.querySelectorAll('[data-px-icon]').forEach((e) => { e.innerHTML = pxIcon(e.dataset.pxIcon); }); + +/* ---------- выбор периода: два поля и календарь с выделением диапазона ---------- */ +const PX_MONTHS = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; + +class PxRange { + constructor(box) { + this.box = box; + this.from = $('#' + box.dataset.from); + this.to = $('#' + box.dataset.to); + box.innerHTML = '—' + + ''; + this.pop = box.querySelector('.dp'); + this.active = 'from'; + box.querySelectorAll('.px-date').forEach((b) => { b.onclick = () => this.toggle(b.dataset.end); }); + document.addEventListener('click', (e) => { if (!this.pop.hidden && !box.contains(e.target)) this.close(); }); + box.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !this.pop.hidden) { this.close(); box.querySelector('.px-date').focus(); } }); + } + + // значения полей → подписи кнопок + sync() { + for (const [b, input] of [[this.box.querySelector('[data-end=from]'), this.from], [this.box.querySelector('[data-end=to]'), this.to]]) { + b.innerHTML = pxIcon('calendar') + `${pxRu(input.value)}`; + b.classList.toggle('empty', !input.value); + b.classList.toggle('active', !this.pop.hidden && b.dataset.end === this.active); + b.setAttribute('aria-expanded', String(!this.pop.hidden && b.dataset.end === this.active)); + b.setAttribute('aria-label', (b.dataset.end === 'from' ? 'Период с ' : 'Период по ') + (input.value ? pxRu(input.value) : 'не выбрано')); + } + } + + // Левая и правая кнопки явно выбирают соответствующую границу периода. + // Повторный клик по полю не закрывает календарь: он закрывается выбором конца, + // кликом снаружи или Escape. Так правое поле нельзя случайно принять за начало. + toggle(end) { + if (this.pop.hidden) this.open(end); + else { + this.active = end; + const value = end === 'to' ? (this.to.value || this.from.value) : this.from.value; + if (value) { + const d = pxDate(value); + this.month = new Date(d.getFullYear(), d.getMonth(), 1); + } + this.sync(); this.render(); + } + } + + open(end = 'from') { + this.active = end; + const value = end === 'to' ? (this.to.value || this.from.value) : this.from.value; + const base = value ? pxDate(value) : new Date(); + this.month = new Date(base.getFullYear(), base.getMonth(), 1); + this.pop.hidden = false; + this.box.classList.add('open'); + this.sync(); + this.render(); + // на телефоне календарь может открыться ниже края экрана + requestAnimationFrame(() => this.pop.scrollIntoView({ block: 'nearest', behavior: 'smooth' })); + } + + close() { this.pop.hidden = true; this.box.classList.remove('open'); this.sync(); } + + pick(iso) { + if (this.active === 'to' && this.from.value) { + if (iso < this.from.value) { + // Если конец оказался раньше начала, сохраняем обе выбранные даты, + // просто располагая их в хронологическом порядке. + this.to.value = this.from.value; + this.from.value = iso; + } else { + this.to.value = iso; + } + this.active = 'from'; + this.sync(); this.render(); + setTimeout(() => this.close(), 180); + } else { + this.from.value = iso; + if (this.to.value && this.to.value < iso) this.to.value = ''; + this.active = 'to'; + this.sync(); this.render(); + } + } + + render() { + const y = this.month.getFullYear(), m = this.month.getMonth(); + const first = new Date(y, m, 1), shift = (first.getDay() + 6) % 7; + const start = new Date(y, m, 1 - shift), today = pxISO(new Date()); + const a = this.from.value, b = this.to.value || this.from.value; + let html = `
` + + `${PX_MONTHS[m]} ${y}
`; + ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].forEach((w, i) => { html += `${w}`; }); + for (let i = 0; i < 42; i++) { + const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i); + if (i === 35 && d.getMonth() !== m) break; // шестая неделя не нужна + const iso = pxISO(d), cls = ['dp__day']; + if (d.getMonth() !== m) cls.push('dp__out'); + if (i % 7 > 4) cls.push('dp__we'); + if (a && iso >= a && iso <= b) cls.push('dp__in'); + if (iso === a) cls.push('dp__start'); + if (iso === b && this.to.value) cls.push('dp__end'); + if (iso === today) cls.push('dp__today'); + html += ``; + } + html += '
' + + '
'; + this.pop.innerHTML = html; + this.pop.querySelectorAll('[data-go]').forEach((btn) => { + btn.onclick = () => { this.month = new Date(y, m + Number(btn.dataset.go), 1); this.render(); }; + }); + this.pop.querySelectorAll('[data-d]').forEach((btn) => { btn.onclick = () => this.pick(btn.dataset.d); }); + this.pop.querySelector('[data-act=clear]').onclick = () => { this.from.value = ''; this.to.value = ''; this.active = 'from'; this.sync(); this.render(); }; + this.pop.querySelector('[data-act=today]').onclick = () => { + this.from.value = this.to.value = today; this.active = 'from'; + this.month = new Date(); this.month.setDate(1); + this.sync(); this.render(); setTimeout(() => this.close(), 180); + }; + } +} + +/* ---------- мини-график в карточке ---------- */ +function pxSpark(values, color) { + const w = 220, h = 44, pad = 3; + if (!values || values.length < 2) return ''; + const max = Math.max(...values), min = Math.min(...values); + const span = max - min || 1; + const pts = values.map((v, i) => [(i / (values.length - 1)) * w, h - pad - ((v - min) / span) * (h - pad * 2)]); + const line = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' '); + const id = 'pxg' + Math.random().toString(36).slice(2, 8); + return ``; +} + +const Pbx = { + status: null, + channels: [], + report: null, + lostFilter: '', + + // период по умолчанию — текущая неделя, как в ВАТС + defaults() { + const now = new Date(), mon = new Date(now); + mon.setDate(now.getDate() - ((now.getDay() + 6) % 7)); + for (const [a, b] of [['#px-from', '#px-to'], ['#px-sync-from', '#px-sync-to'], ['#rp-pbx-from', '#rp-pbx-to']]) { + if (!$(a).value) $(a).value = pxISO(mon); + if (!$(b).value) $(b).value = pxISO(now); + } + Pbx.ranges.forEach((r) => r.sync()); + }, + + async open() { + Pbx.defaults(); + await Pbx.loadStatus(); + await Promise.all([Pbx.loadReport(), Pbx.loadLines()]); + }, + + async loadStatus() { + try { + const r = await api(ReportClinic.q('/admin/pbx/status')); + Pbx.status = r.data; + } catch (e) { toast(e.message, true); return; } + const { configured, domain, status: s } = Pbx.status; + $('#px-conn').classList.toggle('off', !configured); + $('#px-conn-text').textContent = configured ? `Подключена: ${domain}` : 'Не подключена — ключ API ещё не задан на сервере'; + $('#px-copy').hidden = !configured; + const parts = []; + parts.push(s.calls ? `В базе ${pxNum(s.calls)} звонков, с ${ruDate(localDate(s.oldest))} по ${ruDate(localDate(s.newest))}.` : 'Звонков в базе пока нет.'); + if (s.last_sync_at) parts.push(`Последняя загрузка: ${pxDT.format(new Date(s.last_sync_at))}${s.last_error ? ' — ошибка: ' + s.last_error : ', звонков ' + s.last_calls}.`); + if (s.lines_unknown) parts.push(`Линий без канала: ${s.lines_unknown} — их звонки в отчёты не входят (канал задаётся внизу страницы).`); + $('#px-state').textContent = parts.join(' '); + $('#px-state').classList.toggle('px-bad', !!s.last_error); + $('#px-setup').hidden = configured; + // строки .env своей клиники: у DANA MEGAFON_API_*, у «Очевидно» OCHEVIDNO_MEGAFON_API_* + const [urlVar, keyVar] = (Pbx.status.env || 'MEGAFON_API_URL и MEGAFON_API_KEY').split(' и '); + $('#px-setup-env').textContent = `${urlVar}=${ReportClinic.key === 'ochevidno' ? 'vats363024' : 'vats970345'}.megapbx.ru\n${keyVar}=ваш ключ`; + $('#px-check').disabled = !configured; + $('#px-sync').disabled = !configured; + $('#rp-pbx-opt').classList.toggle('px-off', !configured && !s.calls); + }, + + async check() { + const b = $('#px-check'); b.disabled = true; + try { + const r = await api(ReportClinic.q('/admin/pbx/check'), { method: 'POST' }); + toast(`Ключ принят: сотрудников в АТС — ${r.data.users}`); + } catch (e) { toast(e.message, true); } + b.disabled = false; + }, + + async sync() { + if (!$('#px-sync-from').value || !$('#px-sync-to').value) { toast('Выберите период загрузки', true); return; } + const b = $('#px-sync'); b.disabled = true; + const old = b.textContent; b.textContent = 'Загружаем…'; + try { + const r = await api(ReportClinic.q('/admin/pbx/sync'), { method: 'POST', body: JSON.stringify({ from: $('#px-sync-from').value, to: $('#px-sync-to').value }) }); + toast(`Загружено звонков: ${r.data.calls}`); + } catch (e) { toast(e.message, true); } + b.textContent = old; b.disabled = false; + await Pbx.loadStatus(); + await Promise.all([Pbx.loadReport(), Pbx.loadLines()]); + }, + + // Период для сравнения. Месяц (с 1-го числа) — с прошлым месяцем: целый — с целым, начало + // месяца — с теми же числами. Остальное (неделя, несколько дней) — с теми же днями недели + // на целое число недель раньше. Если период включает сегодня — прошлый берётся до того же часа. + // missing: звонков прошлого периода в базе нет целиком — сравнивать не с чем. + previousRange(from, to) { + const oldest = Pbx.status?.status?.oldest; + if (!from || !to) return null; + const now = new Date(), today = pxISO(now); + if (from > today) return null; + const f = pxDate(from), t = pxDate(to); + // будущие дни звонков ещё не имеют: сравниваем то, что уже прошло, — по сегодня + const cut = to > today ? pxDate(today) : t; + const lastDay = (y, m) => new Date(y, m + 1, 0).getDate(); + let pf = null, pt = null, kind = 'week'; + if (f.getDate() === 1) { + const months = (t.getFullYear() - f.getFullYear()) * 12 + t.getMonth() - f.getMonth() + 1; + const full = t.getDate() === lastDay(t.getFullYear(), t.getMonth()); + if (full || months === 1) { + kind = 'month'; + pf = new Date(f.getFullYear(), f.getMonth() - months, 1); + if (full && cut === t) pt = new Date(pf.getFullYear(), pf.getMonth() + months, 0); + else { + // начало месяца (или месяц, который ещё идёт) — те же числа прошлого месяца + const m = pf.getMonth() + (cut.getMonth() - f.getMonth()) + (cut.getFullYear() - f.getFullYear()) * 12; + pt = new Date(pf.getFullYear(), m, Math.min(cut.getDate(), lastDay(pf.getFullYear(), m))); + } + } + } + if (!pf) { + const shift = Math.ceil((Math.round((t - f) / 864e5) + 1) / 7) * 7; + pf = new Date(f); pf.setDate(pf.getDate() - shift); + pt = new Date(cut); pt.setDate(pt.getDate() - shift); + } + const mins = now.getHours() * 60 + now.getMinutes(); + const until = pxISO(cut) === today && mins > 0 ? `${pxISO(pt)}T${pad2(now.getHours())}:${pad2(now.getMinutes())}` : ''; + return { from: pxISO(pf), to: pxISO(pt), until, kind, missing: !oldest || pxISO(pf) < localDate(oldest) }; + }, + + async loadReport() { + const from = $('#px-from').value, to = $('#px-to').value; + if (from && !to) { toast('Выберите конец периода', true); return; } + const q = from ? `?from=${from}&to=${to}` : ''; + let r, prev = null; + const pr = Pbx.previousRange(from, to); + try { + [r, prev] = await Promise.all([api(ReportClinic.q('/admin/pbx/report' + q)), + pr && !pr.missing ? api(ReportClinic.q(`/admin/pbx/report?from=${pr.from}&to=${pr.to}${pr.until ? '&until=' + pr.until : ''}`)).catch(() => null) : null]); + } catch (e) { toast(e.message, true); return; } + const d = r.data, t = d.total, p = prev?.data; + Pbx.report = d; + const pt = p && (p.total.incoming + p.outgoing) > 0 ? p.total : null; + // с чем сравниваются проценты в карточках + let cmp = ''; + if (pr) { + const span = `${pxRu(pr.from)}–${pxRu(pr.to)}${pr.until ? ', до ' + pr.until.slice(11) : ''}`; + const what = pr.kind === 'month' ? 'прошлый месяц' : 'те же дни недели раньше'; + cmp = pr.missing ? `Сравнения нет: звонки за ${span} не загружены.` + : pt ? `Проценты — к периоду ${span} (${what}).` : `Сравнения нет: за ${span} звонков нет.`; + } + $('#px-cmp').textContent = cmp; + $('#px-cmp').hidden = !cmp; + // [подпись, значение, подсказка, иконка, цвет, ряд, сравнение: [сейчас, раньше, рост — хорошо?, в п.п.?]] + const kpis = [ + ['Все звонки', pxNum(t.incoming + d.outgoing), 'входящие и исходящие', 'phone', '#3f86f2', d.series.all, + pt && [t.incoming + d.outgoing, pt.incoming + p.outgoing, true]], + ['Входящих звонков', pxNum(t.incoming), `разных клиентов: ${pxNum(t.clients)}`, 'phoneIn', '#22b07d', d.series.incoming, + pt && [t.incoming, pt.incoming, true]], + ['Принято', `${Math.round(t.answered_rate)}%`, `${pxNum(t.answered)} звонков, ожидание ~${Math.round(t.avg_wait)} с`, 'people', '#8b6cf0', + d.series.answered_rate, pt && [t.answered_rate, pt.answered_rate, true, true]], + ['Пропущено', pxNum(t.missed), 'входящих без ответа', 'phoneMiss', '#ec5a72', d.series.missed, pt && [t.missed, pt.missed, false]], + ['Не перезвонили', pxNum(t.missed_lost), 'клиентов после пропущенного', 'callback', '#f29a3c', d.series.lost, + pt && [t.missed_lost, pt.missed_lost, false]], + ['Исходящих', pxNum(d.outgoing), `разговоры: ${pxDur(t.out_talk)}`, 'phoneOut', '#1fa6c4', d.series.outgoing, + pt && [d.outgoing, p.outgoing, true]], + ]; + const k = $('#px-kpis'); k.textContent = ''; + for (const [label, value, hint, icon, color, series, cmp] of kpis) { + const c = el('div', { className: 'px-kpi' }); + c.style.setProperty('--c', color); + let trend = ''; + if (cmp) { + const [now, before, upGood, points] = cmp; + const diff = points ? now - before : before ? ((now - before) / before) * 100 : null; + if (diff !== null && Number.isFinite(diff)) { + const v = Math.round(diff); + const good = v === 0 ? null : (v > 0) === upGood; + trend = `` + + `${v ? pxIcon(v > 0 ? 'up' : 'down') : ''}${v > 0 ? '+' : v < 0 ? '−' : ''}${Math.abs(v)}${points ? ' п.п.' : '%'}`; + } + } + c.innerHTML = `
${pxIcon(icon)}
` + + `
${trend}
` + + `${pxSpark(series, color)}`; + c.querySelector('.px-kpi__value').textContent = value; + c.querySelector('.px-kpi__label').textContent = label; + c.querySelector('.px-kpi__hint').textContent = hint; + k.append(c); + } + const tb = $('#px-channels'); tb.textContent = ''; + if (!d.channels.length) tb.append(Pbx.empty(9, 'За период нет звонков на линии с каналом. Загрузите звонки из АТС, выберите другие даты или задайте каналы линиям внизу страницы.')); + // строка «Итого» — сверка с фильтром по тем же номерам в «Истории» ВАТС + const rows = d.channels.length > 1 ? [...d.channels, { ...t, outgoing: d.outgoing, channel: 'Итого', total: true }] : d.channels; + for (const c of rows) { + const tr = el('tr', { className: c.total ? 'px-total' : '' }); + const name = el('td'); + name.append(c.total ? el('b', { textContent: c.channel }) : Pbx.chip(c.channel)); + const menu = el('td', { className: 'px-menu-cell' }); + if (!c.total) { + const btn = el('button', { type: 'button', className: 'px-more' }); + btn.innerHTML = pxIcon('dots'); + btn.setAttribute('aria-label', 'Действия: ' + c.channel); + btn.onclick = (e) => { e.stopPropagation(); Pbx.menu(btn, c); }; + menu.append(btn); + } + tr.append(name, el('td', { textContent: pxNum(c.incoming) }), el('td', { textContent: pxNum(c.clients) }), + el('td', { textContent: c.incoming ? `${pxNum(c.answered)} · ${Math.round(c.answered_rate)}%` : '—' }), + el('td', { className: c.total && c.missed ? 'px-lost' : '', textContent: pxNum(c.missed) }), + el('td', { textContent: pxNum(c.outgoing) }), el('td', { textContent: pxDur(c.talk_seconds + c.out_talk) }), + el('td', { textContent: c.incoming ? `${Math.round(c.avg_wait)} с` : '—' }), menu); + tb.append(tr); + } + Pbx.renderLost(); + const ub = $('#px-users'); ub.textContent = ''; + if (!d.users.length) ub.append(Pbx.empty(4, 'Нет звонков за период.')); + for (const u of d.users) { + const tr = el('tr'); + tr.append(el('td', { textContent: u.name }), el('td', { textContent: u.answered }), el('td', { textContent: u.outgoing }), el('td', { textContent: pxDur(u.talk_seconds) })); + ub.append(tr); + } + }, + + renderLost() { + const d = Pbx.report; + if (!d) return; + const list = Pbx.lostFilter ? d.lost.filter((l) => l.channel === Pbx.lostFilter) : d.lost; + $('#px-lost-title').textContent = Pbx.lostFilter ? `Кому перезвонить · ${Pbx.lostFilter}` : 'Кому перезвонить'; + $('#px-lost-all').hidden = !Pbx.lostFilter; + const lb = $('#px-lost'); lb.textContent = ''; + if (!list.length) lb.append(Pbx.empty(4, 'Всем, кого пропустили, перезвонили или они дозвонились сами.')); + for (const l of list.slice(0, 100)) { + const tr = el('tr'); + const ph = el('td'); + ph.append(el('a', { href: 'tel:+' + String(l.client).replace(/\D/g, ''), textContent: l.client })); + const ch = el('td'); ch.append(Pbx.chip(l.channel)); + tr.append(ph, ch, el('td', { textContent: pxDT.format(new Date(l.last_call)) }), el('td', { textContent: l.attempts })); + lb.append(tr); + } + }, + + // меню строки канала: кому перезвонить по каналу, номера и каналы + menu(btn, c) { + document.querySelector('.px-pop')?.remove(); + const pop = el('div', { className: 'px-pop' }); + pop.setAttribute('role', 'menu'); + const item = (text, fn) => { + const b = el('button', { type: 'button', textContent: text }); + b.setAttribute('role', 'menuitem'); + b.onclick = () => { pop.remove(); fn(); }; + pop.append(b); + }; + item(`Кому перезвонить${c.missed_lost ? ` (${c.missed_lost})` : ''}`, () => { + Pbx.lostFilter = c.channel; Pbx.renderLost(); + $('#px-lost-card').scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + item('Номера клиники и каналы', () => $('#px-lines-card').scrollIntoView({ behavior: 'smooth', block: 'start' })); + btn.parentElement.append(pop); + pop.querySelector('button').focus(); + const close = (e) => { if (!pop.contains(e.target)) { pop.remove(); document.removeEventListener('click', close); } }; + setTimeout(() => document.addEventListener('click', close)); + pop.addEventListener('keydown', (e) => { if (e.key === 'Escape') { pop.remove(); btn.focus(); } }); + }, + + async loadLines() { + let r; + try { r = await api(ReportClinic.q('/admin/pbx/lines')); } catch (e) { toast(e.message, true); return; } + Pbx.channels = r.meta?.channels || []; + const tb = $('#px-lines'); tb.textContent = ''; + if (!r.data.length) tb.append(Pbx.empty(4, 'Номера появятся после первой загрузки звонков.')); + for (const l of r.data) { + const tr = el('tr'); + const sel = el('select'); + sel.setAttribute('aria-label', 'Канал номера ' + (l.via || l.via_key)); + sel.append(el('option', { value: '', textContent: '— не учитывать —' })); + for (const c of Pbx.channels) sel.append(el('option', { value: c.key, textContent: c.title })); + if (l.channel && !Pbx.channels.some((c) => c.key === l.channel)) sel.append(el('option', { value: l.channel, textContent: l.channel })); + sel.value = l.channel; + sel.onchange = async () => { + try { + await api(ReportClinic.q('/admin/pbx/lines'), { method: 'PUT', body: JSON.stringify({ via_key: l.via_key, channel: sel.value }) }); + toast(sel.value ? 'Канал номера сохранён' : 'Номер больше не учитывается'); + Pbx.loadReport(); Pbx.loadStatus(); + } catch (e) { toast(e.message, true); } + }; + const ch = el('td'); ch.append(sel); + tr.append(el('td', { textContent: l.via || l.via_key }), el('td', { className: 'muted', textContent: (l.user_names || []).join(', ') || '—' }), + el('td', { textContent: l.calls }), ch); + tb.append(tr); + } + }, + + // канал цветом — те же цвета, что в «Истории отчётов»; основной номер — как колл-центр + chip(name) { + const [color, , soft] = name === 'Основной номер' ? RH_CHANNEL_COLORS['колл-центр'] : rhChannelColor(name); + const s = el('span', { className: 'px-ch', textContent: name }); + s.style.setProperty('--ch', color); + s.style.setProperty('--ch-soft', soft); + return s; + }, + + empty(cols, text) { + const tr = el('tr'); + tr.append(el('td', { colSpan: cols, className: 'muted dr-empty', textContent: text })); + return tr; + }, +}; +Pbx.ranges = [new PxRange($('#px-range')), new PxRange($('#px-sync-range'))]; + +$('#px-check').onclick = () => Pbx.check(); +$('#px-sync').onclick = () => Pbx.sync(); +$('#px-show').onclick = () => Pbx.loadReport(); +$('#px-lost-all').onclick = () => { Pbx.lostFilter = ''; Pbx.renderLost(); }; +$('#px-copy').onclick = async () => { + try { await navigator.clipboard.writeText(Pbx.status.domain); toast('Адрес АТС скопирован'); } catch { toast('Не удалось скопировать', true); } +}; diff --git a/internal/api/adminui/reports-history.css b/internal/api/adminui/reports-history.css new file mode 100644 index 0000000..877f3b5 --- /dev/null +++ b/internal/api/adminui/reports-history.css @@ -0,0 +1,134 @@ +/* Оформление только вкладки истории: светлое стекло и цветные акценты. */ +body.reports-history-view { + background: radial-gradient(ellipse at 90% 14%, #f9e6f2 0, transparent 32%), radial-gradient(ellipse at 40% 0, #e1f5ff 0, transparent 50%), #f0f7ff; +} +body.reports-history-view .top .search input, +body.reports-history-view .round { background: #fff; } +#tab-reports.rp-history-view { --rp-ink: #142447; --rp-soft: #62799e; --rp-line: #e1edf9; color: var(--rp-ink); } +.rp-history-view .rp-waves { display: none; } +.rp-history-view .rp-head { margin: 22px 0 18px; } +.rp-history-view .rp-head h1 { font-family: var(--font); font-size: 34px; font-weight: 700; letter-spacing: -.035em; } +.rp-history-view .rp-tabs { border: 1px solid #fff; border-radius: 16px; padding: 4px; box-shadow: 0 5px 14px #638cb016; margin-bottom: 22px; } +.rp-history-view .rp-tabs button { border-radius: 12px; padding: 12px 24px; } +.rp-history-view .rp-tabs button[aria-selected="true"] { background: linear-gradient(135deg, #d7eeff, #bfe2ff); color: #087ff2; box-shadow: 0 4px 9px #419bdf20; } +[data-rp-view="history"] { --rh-line: #e4edf8; --rh-muted: #61789a; } +[data-rp-view="history"] svg { width: 21px; height: 21px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.rh-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 24px; flex-wrap: wrap; } +.rh-toolbar input, .rh-toolbar select, .rh-selection select { background: #ffffffed; border: 1px solid #e0eaf7; border-radius: 12px; height: 44px; color: #29446c; font-size: 13px; } +.rh-search { display: flex; align-items: center; flex: 1 1 230px; background: #ffffffed; border: 1px solid #e0eaf7; border-radius: 12px; padding-left: 14px; color: #6586ab; } +.rh-search > span { display: flex; } +.rh-search input { border: 0; background: none; min-width: 0; padding-left: 10px; } +.rh-search:focus-within, .rh-dates:focus-within { outline: 2px solid #6caaf0; outline-offset: 2px; } +.rh-search input:focus-visible, .rh-dates input:focus-visible { outline: none; box-shadow: none; } +.rh-toolbar select { width: auto; min-width: 155px; } +.rh-dates { display: flex; align-items: center; gap: 5px; padding: 0 12px; background: #fff; border: 1px solid #e0eaf7; border-radius: 12px; color: #637d9c; } +.rh-dates > span:first-child { display: flex; margin-right: 4px; } +.rh-dates input { width: 128px; border: 0; padding: 6px 0; font-size: 12px; } +.rh-icon-button { display: grid; place-items: center; width: 44px; height: 44px; border: 1px solid #e0eaf7; background: #fff; border-radius: 12px; color: #3d6497; cursor: pointer; } +.rh-icon-button span { display: flex; } +.rh-icon-button:disabled { opacity: .5; cursor: wait; } +.rh-reset { padding: 8px; color: #367cb8; font-size: 12px; background: none; border: 0; cursor: pointer; } +.rh-feedback { padding: 12px 16px; border-radius: 12px; color: #9b4e36; background: #fff0e6; } +.rh-selection { display: flex; align-items: center; gap: 20px; margin: 0 0 18px; } +.rh-selection label { font-weight: 600; font-size: 16px; white-space: nowrap; } +.rh-selection select { flex: 1; min-width: 0; max-width: 620px; text-overflow: ellipsis; } +.rh-download { margin-left: auto; display: inline-flex; align-items: center; justify-content: center; gap: 9px; min-height: 44px; padding: 11px 20px; border: 0; border-radius: 14px; background: linear-gradient(115deg, #7268ff 0%, #289cff 47%, #25d6d4 100%); box-shadow: 0 7px 16px #5488fa27; color: #fff; text-decoration: none; cursor: pointer; font-size: 13px; font-weight: 500; white-space: nowrap; } +.rh-download:hover { filter: brightness(1.04); color: #fff; } +.rh-download > span { display: flex; } +.rh-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 16px; } +.rh-kpi { display: flex; align-items: center; gap: 14px; min-width: 0; padding: 20px 18px; border: 1px solid #fff; border-radius: 20px; background: #ffffffed; box-shadow: 0 7px 25px #7d9abc08; } +.rh-kpi-icon { display: grid; place-items: center; flex: 0 0 50px; height: 50px; border-radius: 15px; color: #108cf3; background: #e7f5ff; } +.rh-kpi-icon svg { width: 26px; height: 26px; } +.rh-kpi--violet .rh-kpi-icon { background: #f0eaff; color: #9861f5; } +.rh-kpi--mint .rh-kpi-icon { background: #dffafa; color: #00b4a4; } +.rh-kpi--peach .rh-kpi-icon { background: #fff4e4; color: #ffad30; } +.rh-kpi > div { min-width: 0; } +.rh-kpi strong { display: block; font-size: clamp(18px, 1.6vw, 25px); line-height: 1.2; font-weight: 700; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } +.rh-kpi div > span { display: block; color: #4b6891; font-size: 12px; margin-top: 6px; } +.rh-kpi small { display: block; color: #8b9db5; font-size: 10px; margin-top: 5px; line-height: 1.4; } +.rh-card { min-width: 0; padding: 23px; background: #fffffff2; border: 1px solid #fff; border-radius: 23px; box-shadow: 0 10px 28px #81a1c309; } +.rh-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 20px; } +.rh-card h2 { margin: 0; font-size: 18px; font-weight: 650; color: #192b4b; } +.rh-card h3 { margin: 0 0 14px; font-size: 14px; font-weight: 600; } +.rh-tag { color: #6a8fb7; font-size: 11px; } +.rh-result { margin-bottom: 16px; } +.rh-result-grid { display: grid; grid-template-columns: 1.1fr 1fr; gap: 36px; align-items: center; } +.rh-conversion { display: flex; align-items: center; gap: 25px; min-width: 0; } +.rh-donut { position: relative; isolation: isolate; width: 174px; height: 174px; flex: 0 0 174px; display: flex; align-items: center; justify-content: center; flex-direction: column; border-radius: 50%; background: conic-gradient(from -90deg, #21c7ef 0%, #5387ff 22%, #c07aff 46%, #ff8cc2 70%, #ffba6d 88%, #80e9e5 100%); } +.rh-donut::before { content: ''; position: absolute; inset: 0; z-index: -1; border-radius: 50%; background: conic-gradient(from -90deg, transparent 0 var(--rh-progress), #edf4fb var(--rh-progress) 100%); } +.rh-donut::after { content: ''; position: absolute; inset: 16px; z-index: -1; border-radius: 50%; background: #fff; box-shadow: 0 2px 8px #acc5dc14; } +.rh-donut strong { font-size: 29px; font-weight: 700; letter-spacing: -.03em; } +.rh-donut span { color: #6780a2; font-size: 12px; } +.rh-facts { flex: 1; min-width: 0; } +.rh-facts > div, .rh-check-row { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; padding: 10px 12px; margin-bottom: 5px; border-radius: 12px; background: linear-gradient(90deg, #f3f8fe, #ecf6ff); font-size: 13px; } +.rh-facts span, .rh-check-row span { color: #5d79a0; } +.rh-facts strong, .rh-check-row strong { font-weight: 600; text-align: right; font-variant-numeric: tabular-nums; } +.rh-control { padding-left: 30px; border-left: 1px solid #e4effa; } +.rh-check-row { padding: 8px 0; background: none; border-bottom: 1px solid #eef3fa; border-radius: 0; } +.rh-check-note { display: flex; align-items: center; gap: 8px; margin: 12px 0 0; font-size: 11px; padding: 10px 12px; border-radius: 12px; background: #f0f5fb; color: #69809e; } +.rh-check-note svg { width: 17px; height: 17px; flex-shrink: 0; } +.rh-check-note--ok { color: #139e89; background: #e7fbf6; } +.rh-check-note--warning { color: #b16a20; background: #fff4e4; } +.rh-rankings { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.rh-ranking { list-style: none; padding: 0; margin: 0; } +.rh-rank { display: flex; align-items: flex-start; gap: 14px; margin-top: 16px; --rank-start: #198fff; --rank-end: #3cd7e7; --rank-soft: #e1f3ff; } +.rh-rank-number { flex: 0 0 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; background: var(--rank-soft); color: var(--rank-start); font-size: 16px; font-weight: 600; } +.rh-rank-data { flex: 1; min-width: 0; } +.rh-rank-title { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; font-size: 12px; } +.rh-rank-title span { overflow-wrap: anywhere; } +.rh-rank-title strong { white-space: nowrap; font-weight: 600; } +.rh-track { background: #eaf2fa; height: 7px; border-radius: 9px; margin: 7px 0 4px; overflow: hidden; } +.rh-track i { display: block; height: 100%; background: linear-gradient(90deg, var(--rank-start), var(--rank-end)); border-radius: inherit; } +.rh-rank small { font-size: 10px; color: #879bb6; } +.rh-ranking-empty { color: #7188a7; padding: 30px 0; font-size: 13px; } +.rh-note { margin: 12px 0 18px; font-size: 11px; line-height: 1.55; color: #7f93af; } +.rh-history-card { margin: 0; border: 1px solid #fff; box-shadow: 0 10px 28px #81a1c309; background: #fffffff2; } +.rh-history-card .rp-recent__head { align-items: center; margin-bottom: 15px; } +.rh-history-card .rh-note { margin: 4px 0 0; } +.rh-history-card .rp-table th { background: #eff6fd; color: #6580a3; font-weight: 500; } +.rh-history-card .rp-table td { border-color: #edf3fa; color: #42618a; } +.rh-history-card .rp-table .dr-name { color: #284976; } +.rh-history-card .rp-table .bn-edit { color: #198afa; background: #eef7ff; border-color: #dfedff; } +.rh-retention { font-size: 11px; color: #8ca1ba; } +.rh-empty { text-align: center; padding: 40px 24px; margin-bottom: 20px; } +.rh-empty > span { display: inline-grid; place-items: center; width: 64px; height: 64px; background: #e9f4ff; color: #4697ed; border-radius: 20px; margin-bottom: 16px; } +.rh-empty > span svg { width: 32px; height: 32px; } +.rh-empty p { font-size: 13px; color: #6a81a0; } +.rh-empty .rh-download { margin: 10px auto 0; } +@media (max-width: 1200px) { + .rh-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .rh-result-grid { gap: 22px; } + .rh-conversion { gap: 16px; } + .rh-donut { width: 144px; height: 144px; flex-basis: 144px; } + .rh-control { padding-left: 22px; } + .rh-selection { flex-wrap: wrap; gap: 12px; } + .rh-selection select { max-width: none; } +} +@media (max-width: 960px) { + .rh-result-grid { grid-template-columns: 1fr; } + .rh-control { padding: 20px 0 0; border-left: 0; border-top: 1px solid #e4effa; } + .rh-selection > div { flex-basis: 100%; } +} +@media (max-width: 600px) { + .rp-history-view .rp-head h1 { font-size: 28px; } + .rh-toolbar { gap: 8px; } + .rh-search { flex-basis: 100%; } + .rh-toolbar select { flex: 1; } + .rh-dates { order: 2; width: 100%; justify-content: space-between; } + .rh-dates input { width: 42%; } + .rh-selection select { width: 100%; flex-basis: 100%; } + .rh-download { margin-left: 0; } + .rh-kpis { gap: 10px; } + .rh-kpi { flex-direction: column; align-items: flex-start; gap: 12px; padding: 16px; } + .rh-kpi-icon { flex-basis: auto; width: 40px; height: 40px; border-radius: 12px; } + .rh-kpi strong { font-size: 21px; } + .rh-kpi small { font-size: 9px; } + .rh-card { padding: 18px; } + .rh-card-head { align-items: flex-start; } + .rh-card h2 { font-size: 16px; } + .rh-tag { text-align: right; } + .rh-conversion { flex-direction: column; } + .rh-facts { width: 100%; } + .rh-rankings { grid-template-columns: 1fr; } + .rh-history-card .rp-recent__head { flex-wrap: wrap; } +} diff --git a/internal/api/adminui/reports-history.js b/internal/api/adminui/reports-history.js new file mode 100644 index 0000000..a2c7234 --- /dev/null +++ b/internal/api/adminui/reports-history.js @@ -0,0 +1,158 @@ +/* История: показатели одной обработанной книги, без суммирования разных выгрузок. */ +const RH_ICONS = { + search: '', + calendar: '', + channels: '', + wallet: '', + receipt: '', +}; +const rhIcon = (name) => ``; +const rhNumber = (value, digits = 0) => typeof value === 'number' && Number.isFinite(value) + ? value.toLocaleString('ru-RU', { maximumFractionDigits: digits }) : '—'; +const rhMoney = (value) => rhNumber(value) + (typeof value === 'number' && Number.isFinite(value) ? ' ₽' : ''); +const rhPercent = (value) => rhNumber(value, 1) + (typeof value === 'number' && Number.isFinite(value) ? '%' : ''); + +// Цвет закреплён за каналом, а не за местом в рейтинге: в обоих списках канал одного цвета. +const RH_CHANNEL_COLORS = { + 'колл-центр': ['#198fff', '#3cd7e7', '#e1f3ff'], + 'инстаграм': ['#ff8a1f', '#ffc36b', '#fff0dc'], + 'яндекс': ['#ee2d2d', '#ff8a80', '#ffe4e2'], + '2гис': ['#1fae4b', '#7bdc8f', '#e2f7e7'], + 'сайт': ['#7261ff', '#c892ff', '#ede9ff'], + 'продокторов': ['#04b9a8', '#60e5d7', '#dcfbf4'], + 'zoon': ['#f838ad', '#ffa0d9', '#ffe5f4'], + 'напоправку': ['#3f51d8', '#8a9bff', '#e6e9ff'], + 'сберздоровье': ['#0aa6d6', '#6fd8f5', '#dff5fc'], + 'баннер': ['#d4a300', '#f5d75e', '#fdf6d6'], + 'докту': ['#5b6b82', '#a3b1c4', '#eef1f5'], + 'тв': ['#b0359f', '#e58ad6', '#fbe6f7'], + 'маршрутки': ['#8d6e63', '#c7a89c', '#f3ebe8'], +}; +const RH_EXTRA_COLORS = [['#6d4cff', '#b3a2ff', '#eeeaff'], ['#00897b', '#6cc9bd', '#e0f4f1'], ['#c2185b', '#f07aa6', '#fce4ec']]; +function rhChannelColor(name) { + const n = String(name || '').toLowerCase(); + for (const [key, color] of Object.entries(RH_CHANNEL_COLORS)) if (n.startsWith(key)) return color; + let h = 0; + for (const ch of n) h = (h * 31 + ch.charCodeAt(0)) >>> 0; + return RH_EXTRA_COLORS[h % RH_EXTRA_COLORS.length]; +} + +const ReportsHistory = { + selected: null, + + render() { + const query = $('#rh-search').value.trim().toLocaleLowerCase('ru-RU'); + const status = $('#rh-status').value; + const from = $('#rh-from').value, to = $('#rh-to').value; + const invalidRange = from && to && from > to; + const message = invalidRange ? 'Дата начала должна быть не позже даты окончания.' + : Reports.loadError ? 'Не удалось обновить историю. ' + Reports.loadError : ''; + $('#rh-feedback').textContent = message; + $('#rh-feedback').hidden = !message; + const list = Reports.list.filter((r) => { + const date = new Date(r.created_at); + const day = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + const text = `${RP_KINDS[r.kind]?.title || r.title} ${r.source_name || ''} ${r.result_name || ''}`.toLocaleLowerCase('ru-RU'); + return !invalidRange && (!query || text.includes(query)) && (!status || r.status === status) + && (!from || day >= from) && (!to || day <= to); + }); + Reports.renderTable('#rp-history', list); + $('#rh-count').textContent = `Показано ${list.length} из ${Reports.list.length}`; + if (!list.length) $('#rp-history td').textContent = Reports.loadError ? 'История недоступна. Попробуйте обновить страницу.' + : Reports.list.length ? 'По выбранным условиям отчёты не найдены.' : 'Отчётов пока нет. Загрузите книгу, чтобы сформировать первый.'; + + const ready = list.filter((r) => r.kind === 'phone_match' && r.status === 'done' && Array.isArray(r.summary?.channels) && r.summary?.internet); + if (!ready.some((r) => String(r.id) === String(ReportsHistory.selected))) ReportsHistory.selected = ready[0]?.id ?? null; + const select = $('#rh-report'); select.textContent = ''; + const df = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + for (const r of ready) select.append(el('option', { value: String(r.id), textContent: `${r.source_name || r.title} · ${df.format(new Date(r.created_at))} · №${r.id}` })); + select.disabled = ready.length === 0; + if (!ready.length) select.append(el('option', { textContent: 'Нет готовых отчётов по выбранным условиям' })); + else select.value = String(ReportsHistory.selected); + const report = ready.find((r) => String(r.id) === String(ReportsHistory.selected)); + $('#rh-dashboard').hidden = !report; + $('#rh-empty').hidden = !!report; + $('#rh-download').hidden = !report; + if (!report) { + $('#rh-download').removeAttribute('href'); + $('#rh-empty h2').textContent = Reports.loadError ? 'Не удалось загрузить показатели' + : query || status || from || to ? 'Нет готового отчёта по выбранным условиям' : 'Пока нет готового отчёта по каналам'; + $('#rh-empty p').textContent = Reports.loadError ? 'Нажмите «Обновить историю», чтобы повторить загрузку.' + : query || status || from || to ? 'Измените фильтры или создайте новый отчёт по каналам.' : 'После обработки книги здесь появятся показатели и сравнение каналов.'; + return; + } + $('#rh-download').href = `/api/v1/admin/reports/${report.id}/download`; + ReportsHistory.dashboard(report.summary); + }, + + dashboard(s) { + const internet = s.internet, totals = s.totals || {}, control = s.control || {}; + const cards = [ + ['channels', 'Лиды из интернета', rhNumber(internet.leads), 'Без повторов между интернет-каналами', 'blue'], + ['calendar', 'Услуги по базе 1С', rhNumber(totals.services, 1), 'Из выбранной выгрузки', 'violet'], + ['wallet', 'Выручка по базе 1С', rhMoney(totals.revenue), 'Из выбранной выгрузки', 'mint'], + ['receipt', 'Средний чек', rhMoney(totals.avg_check), 'По базе 1С', 'peach'], + ]; + const kpis = $('#rh-kpis'); kpis.textContent = ''; + for (const [icon, label, value, note, color] of cards) { + const card = el('article', { className: `rh-kpi rh-kpi--${color}` }); + card.innerHTML = `${rhIcon(icon)}
`; + card.querySelector('strong').textContent = value; + card.querySelector('div > span').textContent = label; + card.querySelector('small').textContent = note; + kpis.append(card); + } + const donut = $('#rh-donut'); donut.textContent = ''; + const hasConversion = Number.isFinite(internet.conv); + donut.style.setProperty('--rh-progress', hasConversion ? Math.max(0, Math.min(100, internet.conv)) + '%' : '0%'); + donut.append(el('strong', { textContent: rhPercent(internet.conv) }), el('span', { textContent: 'Конверсия' })); + donut.setAttribute('aria-label', `Конверсия интернет-каналов: ${rhPercent(internet.conv)}`); + const facts = $('#rh-internet'); facts.textContent = ''; + facts.append(el('h3', { textContent: 'Интернет-каналы' })); + for (const [label, value] of [['Лиды', rhNumber(internet.leads)], ['Услуги', rhNumber(internet.services, 1)], ['Выручка', rhMoney(internet.revenue)]]) { + const row = el('div'); row.append(el('span', { textContent: label }), el('strong', { textContent: value })); facts.append(row); + } + const check = $('#rh-control'); check.textContent = ''; + const hasTotal = control.has_total && Number.isFinite(control.revenue) && Number.isFinite(control.revenue_total); + const diff = hasTotal ? Math.round((control.revenue - control.revenue_total) * 100) / 100 : null; + for (const [label, value] of [['По строкам 1С', rhMoney(control.revenue)], ['Строка «Итого»', hasTotal ? rhMoney(control.revenue_total) : 'Нет в файле'], ['Расхождение', diff === null ? '—' : rhNumber(diff, 2) + ' ₽']]) { + const row = el('div', { className: 'rh-check-row' }); row.append(el('span', { textContent: label }), el('strong', { textContent: value })); check.append(row); + } + const state = diff === null ? 'unknown' : diff === 0 ? 'ok' : 'warning'; + const note = el('p', { className: 'rh-check-note rh-check-note--' + state }); + note.innerHTML = rhIcon(state === 'ok' ? 'check' : 'info'); + note.append(el('span', { textContent: diff === null ? 'В книге нет строки «Итого» для сверки.' : diff === 0 ? 'Выручка совпадает с итогом 1С' : 'Есть расхождение — проверьте выгрузку 1С' })); + check.append(note); + ReportsHistory.ranking('#rh-leads', s.channels, 'leads'); + ReportsHistory.ranking('#rh-revenue', s.channels, 'revenue'); + }, + + ranking(selector, channels, metric) { + const box = $(selector); box.textContent = ''; + const sorted = channels.filter((c) => Number.isFinite(c[metric])).slice().sort((a, b) => b[metric] - a[metric]); + const max = Math.max(0, ...sorted.map((c) => c[metric])); + if (!sorted.length) { box.append(el('li', { className: 'rh-ranking-empty', textContent: 'В этом отчёте нет данных по каналам.' })); return; } + sorted.forEach((c, index) => { + const row = el('li', { className: 'rh-rank' }); + const [start, end, soft] = rhChannelColor(c.name); + row.style.setProperty('--rank-start', start); + row.style.setProperty('--rank-end', end); + row.style.setProperty('--rank-soft', soft); + row.innerHTML = '
'; + row.querySelector('.rh-rank-number').textContent = index + 1; + row.querySelector('.rh-rank-title span').textContent = c.name; + row.querySelector('strong').textContent = metric === 'revenue' ? rhMoney(c.revenue) : rhNumber(c.leads); + row.querySelector('i').style.width = (max > 0 ? Math.max(0, c[metric]) / max * 100 : 0) + '%'; + row.querySelector('small').textContent = metric === 'revenue' ? 'Средний чек: ' + rhMoney(c.avg_check) : 'Конверсия: ' + rhPercent(c.conv); + box.append(row); + }); + }, +}; + +for (const icon of document.querySelectorAll('[data-rh-icon]')) icon.innerHTML = rhIcon(icon.dataset.rhIcon); +$('#rh-search').addEventListener('input', debounce(() => ReportsHistory.render(), 200)); +for (const id of ['rh-status', 'rh-from', 'rh-to']) $('#' + id).onchange = () => ReportsHistory.render(); +$('#rh-report').onchange = () => { ReportsHistory.selected = $('#rh-report').value; ReportsHistory.render(); }; +$('#rh-reset').onclick = () => { for (const id of ['rh-search', 'rh-status', 'rh-from', 'rh-to']) $('#' + id).value = ''; ReportsHistory.render(); }; +$('#rh-refresh').onclick = async () => { $('#rh-refresh').disabled = true; try { await Reports.load(); } finally { $('#rh-refresh').disabled = false; } }; +$('#rh-new').onclick = () => Reports.view('new'); diff --git a/internal/api/adminui/reports.js b/internal/api/adminui/reports.js new file mode 100644 index 0000000..ad52733 --- /dev/null +++ b/internal/api/adminui/reports.js @@ -0,0 +1,515 @@ +/* «Отчёты»: загрузка книги Excel, ход расчёта на сервере, история и шаблоны. + Проценты и время этапов приходят с сервера — это настоящий расчёт, а не анимация. */ + +const RP_KINDS = { + phone_match: { title: 'Отчёт по каналам', file: true, + text: 'Книга «отчёт по каналам»: выгрузка 1С и листы каналов с номерами (все номера, сайт, инстаграм, яндекс, 2гис…). Лиды, пациенты, конверсия, услуги, выручка и средний чек по каждому каналу — как на листе «ОТЧЁТ».' }, +}; +const RP_STAGES = [ + ['Загрузка файла', 'Файл принят сервером'], + ['Проверка данных', 'Читаем листы книги и находим выгрузку 1С'], + ['Обработка данных', 'Сопоставляем номера 1С с каналами'], + ['Формирование отчёта', 'Собираем таблицу для Excel'], + ['Завершение', 'Сохраняем результат'], +]; +const RP_STATUS = { running: ['В обработке', 'sky'], done: ['Готов', ''], failed: ['Ошибка', 'off'], cancelled: ['Отменён', 'grey'] }; +const RP_ICONS = { + doc: '', + file: '', + refresh: '', + play: '', + clock: '', + info: '', + x: '', + down: '', + dots: '', + check: '', +}; +const rpIcon = (n) => ``; +const rpSize = (b) => (b >= 1 << 20 ? (b / (1 << 20)).toFixed(1).replace('.', ',') + ' МБ' : Math.max(1, Math.round(b / 1024)) + ' КБ'); +const rpDur = (ms) => (ms < 1000 ? '0,' + String(Math.max(1, Math.round(ms / 10))).padStart(2, '0') + ' с' + : ms < 60000 ? (ms / 1000).toFixed(1).replace('.', ',') + ' с' : new Date(ms).toISOString().slice(11, 19)); +const rpClock = (ms) => new Date(Math.max(0, ms)).toISOString().slice(11, 19); + +const Reports = { + file: null, + filePeriod: null, + periodLoading: false, + periodRequest: 0, + autoOpenReport: null, + current: null, // задание, которое показывает правая карточка + list: [], + timer: null, + poll: null, + + view(name) { + // Temporarily hidden tabs stay unreachable through secondary UI actions. + const tab = [...document.querySelectorAll('#rp-tabs button')].find((b) => b.dataset.view === name); + if (!tab || tab.hidden) name = 'new'; + $('#tab-reports').classList.toggle('rp-history-view', name === 'history'); + document.body.classList.toggle('reports-history-view', name === 'history' && location.hash === '#reports'); + const heads = { new: 'Отчёты', history: 'История отчётов', pbx: 'Звонки АТС', weekly: WeeklyDashboard.mode === 'months' ? 'Дашборд по месяцам' : 'Дашборд по неделям' }; + $('#tab-reports .rp-head h1').textContent = heads[name]; + for (const b of document.querySelectorAll('#rp-tabs button')) b.setAttribute('aria-selected', String(b.dataset.view === name && (!b.dataset.mode || b.dataset.mode === WeeklyDashboard.mode))); + for (const v of document.querySelectorAll('[data-rp-view]')) v.hidden = v.dataset.rpView !== name; + if (name === 'history') ReportsHistory.render(); + if (name === 'pbx') Pbx.open(); + if (name === 'weekly') WeeklyFullscreen.open(); + }, + + async open() { + await Reports.load(); + const running = Reports.list.find((r) => r.status === 'running'); + if (running) Reports.watch(running.id); + else if (!Reports.current) Reports.renderProgress(null); + }, + + async load() { + try { + const r = await api(ReportClinic.q('/admin/reports')); + Reports.list = r.data || []; + Reports.loadError = ''; + } catch (e) { Reports.loadError = e.message; toast(e.message, true); } + Reports.renderTable('#rp-recent', Reports.list.slice(0, 5)); + if (!$('[data-rp-view="history"]').hidden) ReportsHistory.render(); + }, + + /* ---------- файл ---------- */ + setFile(f) { + if (!f) return; + const ext = (f.name.split('.').pop() || '').toLowerCase(); + if (!['xlsx', 'csv'].includes(ext)) { toast('Нужен файл .xlsx или .csv', true); return; } + if (f.size > 10 * 1024 * 1024) { toast('Файл больше 10 МБ', true); return; } + Reports.file = f; + Reports.renderDrop(); + Reports.detectFilePeriod(f); + }, + async detectFilePeriod(file) { + const request = ++Reports.periodRequest; + Reports.filePeriod = null; + Reports.periodLoading = true; + const hint = $('#rp-file-period'); + hint.hidden = false; + hint.dataset.state = 'loading'; + hint.textContent = 'Читаем период из строки 2 файла 1С…'; + Reports.syncStart(); + try { + const data = new FormData(); data.append('file', file); + const response = await fetch('/api/v1/admin/reports/period', {method: 'POST', body: data, credentials: 'same-origin'}); + const result = await response.json(); + if (!response.ok) throw new Error(result.error?.message || 'Не удалось прочитать период'); + if (request !== Reports.periodRequest) return; + Reports.filePeriod = result.data; + hint.dataset.state = 'ready'; + hint.textContent = `${result.data.mode === 'months' ? 'Месячный' : 'Недельный'} отчёт · ${ReportClinic.title} · ${result.data.label}`; + Reports.renderPartners(); + } catch (error) { + if (request !== Reports.periodRequest) return; + hint.dataset.state = 'warning'; + hint.textContent = `${error.message}. В строке 2 файла должна быть запись «Период ДД.ММ.ГГГГ - ДД.ММ.ГГГГ».`; + } finally { + if (request === Reports.periodRequest) { + Reports.periodLoading = false; + Reports.syncStart(); + } + } + }, + openDashboard(period) { + WeeklyDashboard.mode = period.mode; + $('#wk-filter-from').value = ''; $('#wk-filter-to').value = ''; + Reports.view('weekly'); + }, + renderDrop() { + const f = Reports.file, box = $('#rp-drop'); + box.classList.toggle('has-file', !!f); + if (f) { + box.innerHTML = `${rpIcon('doc')}Файл загружен + ${rpIcon('check')} + ${rpSize(f.size)} · ${f.name.split('.').pop().toUpperCase()}`; + box.querySelector('.rp-drop__name span').textContent = f.name; + } else { + box.innerHTML = `${rpIcon('doc')}Перетащите книгу Excel сюда + или нажмите, чтобы выбрать файл · .xlsx или .csv до 10 МБ`; + } + $('#rp-replace').hidden = !f; + Reports.syncStart(); + }, + // Необязательные файлы кабинетов: сервер сам их скачать не может (кабинеты отвечают 403 с VPS). + sber: null, + prodoc: [], + setSber(f) { + if (!f) return; + if (!/\.xls$/i.test(f.name)) { toast('СберЗдоровье: нужен файл .xls из кабинета', true); return; } + Reports.sber = f; + Reports.renderPartners(); + }, + addProdoc(list) { + for (const f of list) { + if (!/\.xlsx$/i.test(f.name)) { toast('ПроДокторов: нужен файл .xlsx из кабинета', true); continue; } + if (Reports.prodoc.some((x) => x.name === f.name && x.size === f.size)) continue; + if (Reports.prodoc.length >= 6) { toast('ПроДокторов: не больше 6 файлов (на месяц — онлайн-запись и телефонная запись)', true); break; } + Reports.prodoc.push(f); + } + Reports.renderPartners(); + }, + renderPartners() { + const draw = (box, files, remove) => { + box.replaceChildren(...files.map((f, i) => { + const row = el('div', { className: 'rp-partner__file' }); + row.append(el('span', { textContent: `${f.name} · ${rpSize(f.size)}` })); + const x = el('button', { type: 'button', title: 'Убрать файл', textContent: '×' }); + x.setAttribute('aria-label', 'Убрать файл ' + f.name); + x.onclick = () => remove(i); + row.append(x); + return row; + })); + }; + draw($('#rp-sber-files'), Reports.sber ? [Reports.sber] : [], () => { Reports.sber = null; Reports.renderPartners(); }); + draw($('#rp-prodoc-files'), Reports.prodoc, (i) => { Reports.prodoc.splice(i, 1); Reports.renderPartners(); }); + $('#rp-sber-add').textContent = Reports.sber ? 'Заменить файл' : 'Добавить файл'; + // какие месяцы нужны ПроДокторову для найденного в файле 1С периода + const need = $('#rp-prodoc-need'), p = Reports.filePeriod; + const months = []; + if (p && p.from && p.to) { + const names = ['январь', 'февраль', 'март', 'апрель', 'май', 'июнь', 'июль', 'август', 'сентябрь', 'октябрь', 'ноябрь', 'декабрь']; + const [fy, fm] = p.from.split('-').map(Number), [ty, tm] = p.to.split('-').map(Number); + for (let y = fy, m = fm; y < ty || (y === ty && m <= tm); m++) { + if (m > 12) { m = 1; y++; } + months.push(`${names[m - 1]} ${y}`); + if (months.length > 13) break; + } + } + need.textContent = months.length ? 'Для этого периода нужны файлы за: ' + months.join(', ') : ''; + }, + syncStart() { + const running = Reports.current && Reports.current.status === 'running'; + // АТС и Битрикс24 берут даты из файла, поэтому без найденного периода запуск недоступен. + $('#rp-start').disabled = !Reports.file || running || Reports.periodLoading || !Reports.filePeriod; + }, + + /* ---------- запуск и ход ---------- */ + async start(kind = 'phone_match') { + const fd = new FormData(); + fd.append('kind', kind); + fd.append('clinic', ReportClinic.key); + if (RP_KINDS[kind].file) { + if (!Reports.file) { toast('Сначала выберите файл', true); return; } + fd.append('file', Reports.file); + // Всегда весь отчёт: номера АТС МегаФон и Битрикс24 за период из файла. + fd.append('source', 'all'); + fd.append('bitrix', '1'); + if (Reports.sber) fd.append('sber_file', Reports.sber); + for (const f of Reports.prodoc) fd.append('prodoctorov_file', f); + } + $('#rp-start').disabled = true; + try { + const res = await fetch('/api/v1/admin/reports', { method: 'POST', body: fd, credentials: 'same-origin' }); + const j = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(j.error?.message || 'Не удалось запустить отчёт'); + Reports.view('new'); + Reports.autoOpenReport = j.data.id; + Reports.watch(j.data.id); + Reports.load(); + } catch (e) { toast(e.message, true); Reports.syncStart(); } + }, + + // Ссылка «только просмотр» на дашборд выбранной клиники — для маркетолога без входа в админку. + async shareDialog() { + const clinic = ReportClinic.title; + const dlg = el('dialog', { className: 'rp-share' }); + const close = () => { dlg.close(); dlg.remove(); }; + const head = el('h2', { textContent: `Ссылка на дашборд «${clinic}»` }); + const note = el('p', { className: 'muted', textContent: 'По ссылке открывается только дашборд по неделям и месяцам этой клиники, без входа и без права что-либо менять. Данных пациентов там нет. Кто получил ссылку — тот и видит, поэтому отправляйте её только маркетологу.' }); + const state = el('p', { className: 'rp-share__state', textContent: 'Проверяем…' }); + const out = el('div', { className: 'rp-share__out', hidden: true }); + const urlBox = el('input', { type: 'text', readOnly: true, className: 'rp-share__url', ariaLabel: 'Ссылка для маркетолога' }); + const copy = el('button', { type: 'button', className: 'px-btn', textContent: 'Скопировать' }); + copy.onclick = async () => { try { await navigator.clipboard.writeText(urlBox.value); toast('Ссылка скопирована'); } catch { urlBox.select(); } }; + out.append(urlBox, copy, el('p', { className: 'muted', textContent: 'Ссылка показывается один раз — сохраните её сейчас. Потом можно только выпустить новую.' })); + const issue = el('button', { type: 'button', className: 'px-btn' }); + const revoke = el('button', { type: 'button', className: 'bn-edit', textContent: 'Отключить ссылку', hidden: true }); + const done = el('button', { type: 'button', className: 'bn-edit', textContent: 'Закрыть' }); + done.onclick = close; + const actions = el('div', { className: 'rp-share__actions' }); + actions.append(issue, revoke, done); + dlg.append(head, note, state, out, actions); + dlg.addEventListener('cancel', (e) => { e.preventDefault(); close(); }); + document.body.append(dlg); + dlg.showModal(); + const df = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + const show = (info) => { + state.textContent = info.exists + ? `Ссылка выпущена ${df.format(new Date(info.created_at))}` + (info.last_used_at ? `, последний раз открывали ${df.format(new Date(info.last_used_at))}` : ', ещё не открывали') + : 'Ссылки пока нет.'; + issue.textContent = info.exists ? 'Выпустить новую (старая перестанет работать)' : 'Создать ссылку'; + revoke.hidden = !info.exists; + }; + try { show((await api(ReportClinic.q('/admin/share-link'))).data); } catch (e) { state.textContent = e.message; } + issue.onclick = async () => { + issue.disabled = true; + try { + const r = await api(ReportClinic.q('/admin/share-link'), { method: 'POST' }); + urlBox.value = location.origin + r.data.path; + out.hidden = false; + show((await api(ReportClinic.q('/admin/share-link'))).data); + urlBox.select(); + } catch (e) { toast(e.message, true); } finally { issue.disabled = false; } + }; + revoke.onclick = async () => { + if (!confirm('Отключить ссылку? Маркетолог больше не сможет открыть дашборд, пока вы не выпустите новую.')) return; + try { + await api(ReportClinic.q('/admin/share-link'), { method: 'DELETE' }); + out.hidden = true; urlBox.value = ''; + show({ exists: false }); + toast('Ссылка отключена'); + } catch (e) { toast(e.message, true); } + }; + }, + + // Другая клиника: свои отчёты, дашборд и звонки. Выбранные файлы сбрасываются, чтобы книга 1С + // одной клиники не ушла в отчёт другой. + onClinicChange() { + clearTimeout(Reports.poll); + Reports.current = null; + Reports.autoOpenReport = null; + Reports.periodRequest++; + Reports.periodLoading = false; + Reports.file = null; Reports.filePeriod = null; Reports.sber = null; Reports.prodoc = []; + $('#rp-file-period').hidden = true; + Reports.renderDrop(); + Reports.renderPartners(); + Reports.renderProgress(null); + Reports.open(); + if (!$('[data-rp-view="weekly"]').hidden) WeeklyDashboard.load(); + if (!$('[data-rp-view="pbx"]').hidden) Pbx.open(); + }, + + watch(id) { + clearTimeout(Reports.poll); + const tick = async () => { + try { + const r = await api('/admin/reports/' + id); + Reports.current = r.data; + Reports.renderProgress(r.data); + if (r.data.status === 'running') { Reports.poll = setTimeout(tick, 400); return; } + Reports.load(); + if (Reports.autoOpenReport === r.data.id) { + Reports.autoOpenReport = null; + const dashboard = r.data.summary?.dashboard; + if (r.data.status === 'done' && dashboard?.id) Reports.openDashboard(dashboard); + } + } catch (e) { toast(e.message, true); } + }; + tick(); + }, + + async cancel(id) { + try { + await api('/admin/reports/' + id + '/cancel', { method: 'POST' }); + toast('Отчёт отменён'); + } catch (e) { toast(e.message, true); } + if (Reports.current?.id === id) Reports.watch(id); + Reports.load(); + }, + + renderProgress(r) { + clearInterval(Reports.timer); + const card = $('#rp-progress'); + const st = r ? r.status : 'idle'; + card.dataset.state = st; + const pct = r ? (st === 'done' ? 100 : r.progress) : 0; + const titles = { idle: 'Отчёт ещё не запущен', running: 'Выполняется отчёт', done: 'Отчёт готов', failed: 'Отчёт не сформирован', cancelled: 'Отчёт отменён' }; + const subs = { + idle: 'Загрузите книгу слева и нажмите «Запустить отчёт».', + running: 'Идёт обработка данных на сервере — процент показывает, сколько строк уже обработано.', + done: r ? `${RP_KINDS[r.kind]?.title || r.title}${r.source_name ? ' · ' + r.source_name : ''}` : '', + failed: r?.error || 'Расчёт остановился с ошибкой.', + cancelled: 'Расчёт остановлен, результат не сохранён.', + }; + $('#rp-title').textContent = titles[st]; + $('#rp-sub').textContent = subs[st]; + // кольцо + const C = 2 * Math.PI * 70; + $('#rp-ring-bar').setAttribute('stroke-dasharray', `${(pct / 100) * C} ${C}`); + $('#rp-ring-bar').style.opacity = pct ? 1 : 0; // скруглённый кончик полосы виден и при 0% + $('#rp-pct').textContent = pct + '%'; + $('#rp-pct-label').textContent = st === 'done' ? 'Готово' : st === 'running' ? 'Выполнено' : st === 'idle' ? 'Ожидание' : 'Остановлено'; + // время + const clock = $('#rp-clock'); + clock.hidden = !r; + if (r) { + const t0 = new Date(r.created_at).getTime(); + const upd = () => { + const end = r.finished_at ? new Date(r.finished_at).getTime() : Date.now(); + clock.querySelector('span').textContent = st === 'running' ? 'Прошло ' + rpClock(end - t0) + : 'Заняло ' + (end - t0 < 60000 ? rpDur(end - t0) : rpClock(end - t0)); + }; + upd(); + if (st === 'running') Reports.timer = setInterval(upd, 1000); + } + // этапы + const times = r ? (Array.isArray(r.stages) ? r.stages : []) : []; + const ol = $('#rp-stages'); ol.textContent = ''; + RP_STAGES.forEach(([name, hint], i) => { + let s = 'wait'; + if (r) { + if (i < times.length || st === 'done') s = 'done'; + else if (i === r.stage && st === 'running') s = 'now'; + else if (i === r.stage && st !== 'running') s = 'stop'; + } + const li = el('li', { className: 'rp-stage ' + s }); + li.innerHTML = `${s === 'done' ? rpIcon('check') : ''} +
${name}${s === 'now' ? hint + '…' : s === 'wait' ? 'Ожидание…' : s === 'stop' ? (st === 'failed' ? 'Ошибка на этом этапе' : 'Остановлено') : ''}
+ `; + ol.append(li); + }); + // итоги и действия + const out = $('#rp-result'); out.textContent = ''; + out.hidden = st !== 'done' && st !== 'running'; + if (st === 'running') { + const b = el('button', { type: 'button', className: 'rp-btn-ghost' }); + b.innerHTML = rpIcon('x') + 'Отменить'; + b.onclick = () => Reports.cancel(r.id); + out.append(b); + } + if (st === 'done') { + const a = el('a', { className: 'rp-download', href: `/api/v1/admin/reports/${r.id}/download` }); + a.innerHTML = rpIcon('down') + `Скачать отчёт · CSV для Excel · ${rpSize(r.result_size)}`; + out.append(a); + const s = r.summary || {}; + if (s.dashboard?.id) { + const open = el('button', {type: 'button', className: 'rp-download'}); + open.textContent = `Открыть ${s.dashboard.mode === 'months' ? 'месячный' : 'недельный'} отчёт · ${s.dashboard.label}`; + open.onclick = () => Reports.openDashboard(s.dashboard); + out.append(open); + } else if (s.dashboard?.warning) { + out.append(el('p', {className: 'rp-control bad', textContent: s.dashboard.warning})); + } + if (r.kind === 'phone_match' && s.channels) { + const rub = (v) => Math.round(v).toLocaleString('ru-RU') + ' ₽'; + const c = s.control || {}; + const diff = c.has_total ? Math.round((c.revenue - c.revenue_total) * 100) / 100 : null; + out.append(el('p', { className: 'rp-control ' + (diff === 0 ? 'ok' : diff === null ? '' : 'bad'), + textContent: diff === null ? 'Строки «Итого» в выгрузке 1С нет — сверить выручку не с чем.' + : diff === 0 ? `Контроль базы: выручка по строкам 1С совпала с «Итого» — ${rub(c.revenue)}.` + : `Контроль базы: расхождение с «Итого» 1С — ${rub(diff)}. Проверьте выгрузку.` })); + const t = el('table', { className: 'rp-sum' }); + t.innerHTML = 'КаналЛидыПациентовКонверсияВыручкаСредний чек'; + const tb = el('tbody'); + const pctf = (v) => v.toFixed(1).replace('.', ',') + '%'; + for (const x of s.channels.concat([s.internet])) { + const tr = el('tr', { className: x === s.internet ? 'rp-sum__total' : '' }); + tr.append(el('td', { textContent: x.name }), el('td', { textContent: x.leads }), el('td', { textContent: x.patients }), + el('td', { textContent: pctf(x.conv) }), el('td', { textContent: rub(x.revenue) }), el('td', { textContent: rub(x.avg_check) })); + tb.append(tr); + } + t.append(tb); out.append(t); + if (s.bitrix) out.append(el('p', { className: 'rp-control', textContent: + `Номера из Битрикс24: записей ${s.bitrix.records}, с номером ${s.bitrix.phones}` + + (s.bitrix.skipped ? `, не из DANA пропущено ${s.bitrix.skipped}.` : '.') })); + if (s.prodoctorov) out.append(el('p', { className: 'rp-control', textContent: + `ПроДокторов: записей (онлайн и по телефону) по дате создания ${s.prodoctorov.records}, уникальных номеров до исключения заглушек ${s.prodoctorov.phones}.` })); + if (s.sberhealth) out.append(el('p', { className: 'rp-control', textContent: + `СберЗдоровье: записей по дате создания ${s.sberhealth.records}, уникальных номеров до исключения заглушек ${s.sberhealth.phones}; строк выгрузки проверено ${s.sberhealth.export_records}.` })); + } + } + Reports.syncStart(); + }, + + /* ---------- таблицы ---------- */ + renderTable(sel, list) { + const tb = $(sel); tb.textContent = ''; + if (!list.length) { + const tr = el('tr'); tr.append(el('td', { colSpan: 5, className: 'muted dr-empty', textContent: 'Отчётов пока нет — запустите первый.' })); tb.append(tr); + return; + } + const df = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + for (const r of list) { + const tr = el('tr'); + const name = el('td'); + const nb = el('button', { type: 'button', className: 'dr-name', textContent: RP_KINDS[r.kind]?.title || r.title }); + nb.onclick = () => { Reports.view('new'); Reports.watch(r.id); window.scrollTo({ top: 0, behavior: 'smooth' }); }; + name.append(nb); + const file = el('td', { className: 'rp-file' }); + file.innerHTML = rpIcon('file'); + file.append(r.source_name || r.result_name || '—'); + const [label, cls] = RP_STATUS[r.status] || [r.status, 'grey']; + const st = el('td'); st.append(el('span', { className: 'pill dot ' + cls, textContent: label })); + if (r.status === 'failed' && r.error) st.title = r.error; + const act = el('td', { className: 'dr-actions' }); + if (r.status === 'running') { + const b = el('button', { type: 'button', className: 'bn-edit' }); + b.innerHTML = rpIcon('x') + 'Отменить'; + b.onclick = () => Reports.cancel(r.id); + act.append(b); + } else if (r.status === 'done') { + const a = el('a', { className: 'bn-edit', href: `/api/v1/admin/reports/${r.id}/download` }); + a.innerHTML = rpIcon('down') + 'Скачать'; + act.append(a); + } + const more = el('button', { type: 'button', className: 'icon-btn', title: 'Ещё' }); + more.setAttribute('aria-label', 'Ещё действия с отчётом'); + more.setAttribute('aria-haspopup', 'menu'); + more.innerHTML = rpIcon('dots'); + more.onclick = (e) => { e.stopPropagation(); Reports.menu(r, more); }; + act.append(more); + tr.append(name, file, el('td', { className: 'muted', textContent: df.format(new Date(r.created_at)).replace(',', '') }), st, act); + tb.append(tr); + } + }, + + menu(r, anchor) { + Doctors.closeMenu(); + const m = el('div', { className: 'menu', id: 'dr-menu', role: 'menu' }); + const item = (text, fn, danger) => { + const x = el('button', { type: 'button', role: 'menuitem', className: danger ? 'danger' : '', textContent: text }); + x.onclick = () => { Doctors.closeMenu(); fn(); }; + m.append(x); + }; + item('Показать ход и итоги', () => { Reports.view('new'); Reports.watch(r.id); window.scrollTo({ top: 0, behavior: 'smooth' }); }); + if (r.status !== 'running') item('Удалить отчёт', () => Reports.remove(r), true); + document.body.append(m); + const b = anchor.getBoundingClientRect(); + m.style.top = (b.bottom + window.scrollY + 6) + 'px'; + m.style.left = Math.max(12, b.right + window.scrollX - m.offsetWidth) + 'px'; + m.querySelector('button').focus(); + anchor.setAttribute('aria-expanded', 'true'); + Doctors.menuAnchor = anchor; + }, + + async remove(r) { + if (!confirm('Удалить отчёт «' + (RP_KINDS[r.kind]?.title || r.title) + '»? Файл результата тоже удалится.')) return; + try { + await api('/admin/reports/' + r.id, { method: 'DELETE' }); + toast('Отчёт удалён'); + if (Reports.current?.id === r.id) { Reports.current = null; Reports.renderProgress(null); } + Reports.load(); + } catch (e) { toast(e.message, true); } + }, + +}; + +/* ---------- обработчики ---------- */ +$('#rp-start').insertAdjacentHTML('afterbegin', rpIcon('play')); +$('#rp-replace').insertAdjacentHTML('afterbegin', rpIcon('refresh')); +$('#rp-clock').insertAdjacentHTML('afterbegin', rpIcon('clock')); +$('#rp-start').onclick = () => Reports.start('phone_match'); +$('#rp-sber-add').onclick = () => $('#rp-sber-input').click(); +$('#rp-sber-input').onchange = () => { Reports.setSber($('#rp-sber-input').files[0]); $('#rp-sber-input').value = ''; }; +$('#rp-prodoc-add').onclick = () => $('#rp-prodoc-input').click(); +$('#rp-prodoc-input').onchange = () => { Reports.addProdoc([...$('#rp-prodoc-input').files]); $('#rp-prodoc-input').value = ''; }; +Reports.renderPartners(); +$('#rp-replace').onclick = () => $('#rp-input').click(); +$('#rp-input').onchange = () => { Reports.setFile($('#rp-input').files[0]); $('#rp-input').value = ''; }; +{ + const drop = $('#rp-drop'); + drop.onclick = () => $('#rp-input').click(); + drop.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('#rp-input').click(); } }; + drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); }); + drop.addEventListener('dragleave', () => drop.classList.remove('over')); + drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('over'); Reports.setFile(e.dataTransfer.files[0]); }); +} +for (const b of document.querySelectorAll('#rp-tabs button')) b.onclick = () => b.dataset.mode + ? Reports.openDashboard({mode: b.dataset.mode}) : Reports.view(b.dataset.view); +$('#rp-all').onclick = () => Reports.view('history'); +Reports.renderDrop(); diff --git a/internal/api/adminui/services.js b/internal/api/adminui/services.js new file mode 100644 index 0000000..a75e790 --- /dev/null +++ b/internal/api/adminui/services.js @@ -0,0 +1,278 @@ +/* «Услуги и цены» → «Услуги»: направления раскрываются, внутри — услуги направления. + Поиск и фильтры работают в браузере: услуг меньше сотни. Форма — прежняя editService. */ + +const SV_ICONS = { + 'detskaya-klinika': '', + ginekologiya: '', + oko: '', + estetic: '', + endokrinologiya: '', + urologiya: '', + khirurgiya: '', + mammologiya: '', + osteo: '', + proktologiya: '', + onkologiya: '', + 'meditsinskiy-turizm': '', + _default: '', +}; +const SV_TINTS = ['#fdeaec', '#e7f6ee', '#e5f1fb', '#fdf1e3', '#efeafb', '#e8f5f4']; +const SV_INK = ['#d9546d', '#2f9a6a', '#3a7bd5', '#d8822c', '#7a5cc9', '#2c8f88']; +const svIcon = (d) => ``; + +const Services = { + open: new Set(), + openKey: 'dana-admin:sv-open', + + dirs() { return servicesCache.filter((s) => !s.parent_id); }, + children(id) { return servicesCache.filter((s) => s.parent_id === id); }, + + match(s) { + const q = $('#s-q').value.trim().toLowerCase(); + const st = $('#s-status').value; + return (!q || (s.name + ' /' + s.path).toLowerCase().includes(q)) && + (st !== 'published' || s.published) && (st !== 'hidden' || !s.published) && + (st !== 'no-price' || !s.price_count); + }, + + render() { + const dirFilter = $('#s-dir').value; + const box = $('#s-groups'); box.textContent = ''; + const filtering = $('#s-q').value.trim() || $('#s-status').value; + let found = 0; + Services.dirs().forEach((dir, i) => { + if (dirFilter && String(dir.id) !== dirFilter) return; + const kids = Services.children(dir.id).sort((a, b) => a.sort - b.sort || a.id - b.id); + const shown = kids.filter(Services.match); + if (filtering && !shown.length) return; + found += shown.length; + box.append(Services.group(dir, kids, shown, i, filtering || dirFilter)); + }); + $('#s-count').innerHTML = `Найдено ${found} ${plural(found, 'услуга', 'услуги', 'услуг')}`; + if (!box.children.length) box.append(el('p', { className: 'muted dr-empty', textContent: 'Ничего не нашлось — измените поиск или фильтры.' })); + }, + + group(dir, kids, shown, i, forceOpen) { + const open = forceOpen || Services.open.has(dir.id); + const g = el('section', { className: 'sv-group' + (open ? ' open' : '') }); + const head = el('button', { type: 'button', className: 'sv-head' }); + head.setAttribute('aria-expanded', String(!!open)); + const ic = el('span', { className: 'sv-icon' }); + ic.style.background = SV_TINTS[i % SV_TINTS.length]; + ic.style.color = SV_INK[i % SV_INK.length]; + ic.innerHTML = svIcon(SV_ICONS[dir.slug] || SV_ICONS._default); + const txt = el('span', { className: 'sv-head__text' }); + txt.append(el('b', { textContent: dir.name }), + el('small', { textContent: `${kids.length} ${plural(kids.length, 'услуга', 'услуги', 'услуг')}` + + (dir.published ? '' : ' · направление скрыто') })); + const chev = el('span', { className: 'sv-chev' }); + chev.innerHTML = svIcon(''); + head.append(ic, txt, chev); + head.onclick = () => { + if (Services.open.has(dir.id)) Services.open.delete(dir.id); else Services.open.add(dir.id); + try { localStorage.setItem(Services.openKey, JSON.stringify([...Services.open])); } catch { /* приватный режим */ } + Services.render(); + }; + const editDir = el('button', { type: 'button', className: 'icon-btn sv-dir-edit', title: 'Изменить направление' }); + editDir.setAttribute('aria-label', 'Изменить направление «' + dir.name + '»'); + editDir.innerHTML = svIcon(BN_ICONS.pencil); + editDir.onclick = () => editService(dir); + const top = el('div', { className: 'sv-top' }); + top.append(head, editDir); + g.append(top); + if (open) { + const wrap = el('div', { className: 'dr-scroll' }); + const t = el('table', { className: 'sv-table' }); + t.innerHTML = 'НазваниеАдресЦена отПозицииПорядокСтатусДействия'; + const tb = el('tbody'); + if (!shown.length) { + const tr = el('tr'); tr.append(el('td', { colSpan: 8, className: 'muted dr-empty', textContent: 'В направлении пока нет услуг.' })); tb.append(tr); + } + const draggable = !$('#s-q').value.trim() && !$('#s-status').value; + for (const s of shown) tb.append(Services.row(s, dir, draggable)); + t.append(tb); wrap.append(t); g.append(wrap); + } + return g; + }, + + row(s, dir, draggable) { + const tr = el('tr'); + const grip = el('td'); + if (draggable) { + const h = el('button', { type: 'button', className: 'grip', title: 'Перетащите, чтобы поменять порядок' }); + h.setAttribute('aria-label', `Переместить «${s.name}». Стрелки вверх и вниз меняют порядок`); + h.innerHTML = svIcon(BN_ICONS.grip); + h.onkeydown = (e) => { + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; + e.preventDefault(); + Services.move(dir.id, s.id, e.key === 'ArrowUp' ? -1 : 1); + }; + grip.append(h); + tr.draggable = true; + tr.addEventListener('dragstart', (e) => { Services.drag = { dir: dir.id, id: s.id }; tr.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; }); + tr.addEventListener('dragend', () => { tr.classList.remove('dragging'); Services.drag = null; }); + tr.addEventListener('dragover', (e) => { if (Services.drag?.dir === dir.id && Services.drag.id !== s.id) { e.preventDefault(); tr.classList.add('drop'); } }); + tr.addEventListener('dragleave', () => tr.classList.remove('drop')); + tr.addEventListener('drop', (e) => { + e.preventDefault(); tr.classList.remove('drop'); + const ids = Services.children(dir.id).sort((a, b) => a.sort - b.sort || a.id - b.id).map((x) => x.id); + const from = ids.indexOf(Services.drag.id), to = ids.indexOf(s.id); + ids.splice(to, 0, ids.splice(from, 1)[0]); + Services.reorder(ids); + }); + } + const st = el('td'); + st.append(el('span', { className: 'pill dot ' + (s.published ? '' : 'off'), textContent: s.published ? 'Опубликовано' : 'Скрыто' })); + const act = el('td', { className: 'dr-actions' }); + const edit = el('button', { type: 'button', className: 'bn-edit' }); + edit.innerHTML = svIcon(BN_ICONS.pencil) + 'Изменить'; + edit.onclick = () => editService(s); + const more = el('button', { type: 'button', className: 'icon-btn', title: 'Ещё' }); + more.setAttribute('aria-label', 'Ещё действия: ' + s.name); + more.setAttribute('aria-haspopup', 'menu'); + more.innerHTML = svIcon(BN_ICONS.dots); + more.onclick = (e) => { e.stopPropagation(); Services.menu(s, more); }; + act.append(edit, more); + tr.append(grip, + el('td', { className: 'sv-name', textContent: s.name }), + el('td', { className: 'muted sv-path', textContent: '/' + s.path, title: '/services/' + s.path + '/' }), + el('td', { textContent: s.price_from ? 'от ' + Number(s.price_from).toLocaleString('ru-RU') + ' ₽' : '—' }), + el('td', { textContent: s.price_count || '—' }), + el('td', { className: 'muted', textContent: s.sort }), + st, act); + return tr; + }, + + menu(s, anchor) { + Doctors.closeMenu(); + const m = el('div', { className: 'menu', id: 'dr-menu', role: 'menu' }); + const item = (text, fn) => { + const x = el('button', { type: 'button', role: 'menuitem', textContent: text }); + x.onclick = () => { Doctors.closeMenu(); fn(); }; + m.append(x); + }; + if (s.published) item('Открыть страницу на сайте', () => window.open(`/services/${s.path}/`, '_blank', 'noopener')); + item(s.published ? 'Скрыть с сайта' : 'Показать на сайте', () => Services.save(s, { published: !s.published }, + s.published ? 'Услуга скрыта' : 'Услуга на сайте')); + item('Цены этой услуги', () => { go('prices'); $('#p-q').value = s.name; $('#p-q').dispatchEvent(new Event('input')); }); + document.body.append(m); + const r = anchor.getBoundingClientRect(); + m.style.top = (r.bottom + window.scrollY + 6) + 'px'; + m.style.left = Math.max(12, r.right + window.scrollX - m.offsetWidth) + 'px'; + m.querySelector('button').focus(); + anchor.setAttribute('aria-expanded', 'true'); + Doctors.menuAnchor = anchor; + }, + + body(s, change = {}) { + return { name: s.name, short_name: s.short_name, lead: s.lead, sort: s.sort, published: s.published, + seo_title: s.seo_title, seo_description: s.seo_description, seo_h1: s.seo_h1, ...change }; + }, + async save(s, change, message) { + try { + await api('/admin/services/' + s.id, { method: 'PUT', body: JSON.stringify(Services.body(s, change)) }); + toast(message); + await loadServices(); + } catch (e) { toast(e.message, true); } + }, + async reorder(ids) { + const byId = new Map(servicesCache.map((s) => [s.id, s])); + const changed = ids.map((id, i) => [byId.get(id), (i + 1) * 10]).filter(([s, sort]) => s.sort !== sort); + try { + for (const [s, sort] of changed) await api('/admin/services/' + s.id, { method: 'PUT', body: JSON.stringify(Services.body(s, { sort })) }); + if (changed.length) toast('Порядок услуг сохранён'); + } catch (e) { toast(e.message, true); } + await loadServices(); + }, + async move(dirId, id, dir) { + const ids = Services.children(dirId).sort((a, b) => a.sort - b.sort || a.id - b.id).map((x) => x.id); + const i = ids.indexOf(id), j = i + dir; + if (j < 0 || j >= ids.length) return; + [ids[i], ids[j]] = [ids[j], ids[i]]; + await Services.reorder(ids); + [...document.querySelectorAll('#s-groups .grip')].find((g) => g.getAttribute('aria-label').includes(servicesCache.find((s) => s.id === id).name))?.focus(); + }, + + fillDirs() { + const sel = $('#s-dir'); const keep = sel.value; + sel.textContent = ''; + sel.append(el('option', { value: '', textContent: 'Все направления' })); + for (const d of Services.dirs()) sel.append(el('option', { value: d.id, textContent: d.name })); + sel.value = keep; if (sel.value !== keep) sel.value = ''; + }, + + add() { + const dirs = Services.dirs(); + const pre = $('#s-dir').value; + openDialog('Новая услуга', [ + { key: 'parent_id', label: 'Направление — раздел в адресе страницы', type: 'select', value: pre, + options: [{ value: '', label: '— выберите —' }].concat(dirs.map((d) => ({ value: d.id, label: d.name }))) }, + { key: 'name', label: 'Название (например, «Приём кардиолога»)', value: '' }, + { key: 'lead', label: 'Краткое описание — под заголовком на странице услуги', value: '', type: 'textarea' }, + { key: 'slug', label: 'Адрес латиницей — заполнится сам из названия', value: '' }, + ], async (d) => { + if (!d.slug.trim()) d.slug = toSlug(d.name); + d.parent_id = Number(d.parent_id) || 0; + const r = await api('/admin/services', { method: 'POST', body: JSON.stringify(d) }); + toast('Услуга добавлена скрытой — проверьте её и включите «Опубликована»'); + Services.open.add(d.parent_id); + await loadServices(); + const s = servicesCache.find((x) => x.id === r.data.id); + if (s) setTimeout(() => editService(s), 50); + }); + const inputs = [...$('#dlg-body').querySelectorAll('input')]; + const [name, slug] = [inputs[0], inputs[inputs.length - 1]]; + let touched = false; + slug.addEventListener('input', () => { touched = true; }); + name.addEventListener('input', () => { if (!touched) slug.value = toSlug(name.value); }); + }, + + exportCSV() { + const rows = [['Направление', 'Услуга', 'Адрес на сайте', 'Цена от, ₽', 'Позиций в прайсе', 'Статус']]; + for (const dir of Services.dirs()) { + for (const s of Services.children(dir.id).filter(Services.match)) { + rows.push([dir.name, s.name, `${location.origin}/services/${s.path}/`, s.price_from ?? '', s.price_count, s.published ? 'опубликована' : 'скрыта']); + } + } + const esc = (v) => { const x = String(v ?? ''); return /[";\n]/.test(x) ? '"' + x.replace(/"/g, '""') + '"' : x; }; + const a = el('a', { href: URL.createObjectURL(new Blob(['' + rows.map((r) => r.map(esc).join(';')).join('\r\n')], { type: 'text/csv;charset=utf-8' })), + download: `uslugi-${new Date().toISOString().slice(0, 10)}.csv` }); + document.body.append(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + toast('Выгружено: ' + (rows.length - 1) + ' ' + plural(rows.length - 1, 'услуга', 'услуги', 'услуг')); + }, +}; + +async function loadServices() { + const r = await api('/admin/services'); + servicesCache = r.data || []; + if (!Services.initDone) { + Services.initDone = true; + try { for (const id of JSON.parse(localStorage.getItem(Services.openKey) || '[]')) Services.open.add(id); } catch { /* приватный режим */ } + if (!Services.open.size && Services.dirs()[0]) Services.open.add(Services.dirs()[0].id); + } + Services.fillDirs(); + Services.render(); +} + +$('#s-q').addEventListener('input', debounce(() => Services.render(), 200)); +$('#s-dir').onchange = () => Services.render(); +$('#s-status').onchange = () => Services.render(); +$('#s-export').insertAdjacentHTML('afterbegin', svIcon(DR_ICONS.download)); +$('#s-search-icon').innerHTML = svIcon(DR_ICONS.search); +$('#s-export').onclick = () => Services.exportCSV(); + +// общая шапка «Услуги и цены»: кнопка «Добавить» зависит от вкладки +const CATALOG_HERO = { + services: ['Добавить услугу', () => Services.add()], + prices: ['Добавить позицию', () => $('#p-add').click()], + checkups: ['Добавить чек-ап', () => $('#ck-add').click()], + promos: ['Добавить акцию', () => $('#pr-add').click()], +}; +function catalogHero(tab) { + const c = CATALOG_HERO[tab]; + if (!c) return; + $('#cat-add').innerHTML = svIcon(BN_ICONS.plus) + c[0]; + $('#cat-add').onclick = c[1]; +} +$('#ck-search-icon').innerHTML = svIcon(DR_ICONS.search); diff --git a/internal/api/adminui/share.html b/internal/api/adminui/share.html new file mode 100644 index 0000000..0600a97 --- /dev/null +++ b/internal/api/adminui/share.html @@ -0,0 +1,56 @@ + + + + + + + +Маркетинговая аналитика + + + + +
+
+

Маркетинговая аналитика

+
+ + +
+
+
+
+

Загрузка…

+
+
+ — + +
+
+ +
+ +
+ + + + + diff --git a/internal/api/adminui/share.js b/internal/api/adminui/share.js new file mode 100644 index 0000000..1d36f26 --- /dev/null +++ b/internal/api/adminui/share.js @@ -0,0 +1,60 @@ +/* Дашборд по ссылке для маркетолога: та же таблица, что «По неделям» в админке (weekly.js), + только просмотр. Токен — в адресе после «#»: он не уходит на сервер в адресе, в журналы и в Referer; + запрос несёт его в заголовке Authorization. */ +const $ = (s) => document.querySelector(s); +const el = (t, p = {}) => Object.assign(document.createElement(t), p); + +function toast(msg, bad) { + if (!$('#sh-error').hidden) return; // страница ошибки уже всё сказала + const t = $('#toast'); + t.textContent = msg; t.className = 'toast show' + (bad ? ' bad' : ''); + setTimeout(() => { t.className = 'toast'; }, 2600); +} + +// Клиника приходит с сервера вместе с данными: ссылка привязана к одной клинике. +const ReportClinic = { + key: '', + brand: '', + brands: { dana: 'DANA Medical', ochevidno: 'Очевидно' }, + q: (path) => path, +}; + +const Share = { + token: decodeURIComponent(location.hash.slice(1)), + + fail(text) { + $('#sh-main').hidden = true; + $('#sh-error').hidden = false; + if (text) $('#sh-error-text').textContent = text; + }, + + start() { + WeeklyDashboard.readOnly = true; + if (Share.token.length < 40) { Share.fail(); return; } + $('#sh-main').hidden = false; + for (const b of document.querySelectorAll('.sh-head [data-mode]')) { + b.onclick = () => { + WeeklyDashboard.setMode(b.dataset.mode); + for (const x of document.querySelectorAll('.sh-head [data-mode]')) x.setAttribute('aria-selected', String(x === b)); + }; + } + WeeklyDashboard.load(); + }, +}; + +async function api(path) { + if (path !== '/share/weekly-reports') throw new Error('Недоступно по ссылке'); + const r = await fetch('/api/v1/share/weekly-reports', { + headers: { Authorization: 'Bearer ' + Share.token }, + credentials: 'omit', + cache: 'no-store', + }); + const body = await r.json().catch(() => ({})); + if (r.status === 401) { Share.fail(body.error?.message); throw new Error(body.error?.message || 'Ссылка недействительна'); } + if (!r.ok) throw new Error(body.error?.message || 'Ошибка ' + r.status); + ReportClinic.key = body.meta?.clinic || ''; + ReportClinic.brand = ReportClinic.brands[ReportClinic.key] || body.meta?.title || ''; + document.title = `${ReportClinic.brand} — маркетинговая аналитика`; + $('#sh-title').textContent = `${ReportClinic.brand} — маркетинговая аналитика`; + return body; +} diff --git a/internal/api/adminui/shell.js b/internal/api/adminui/shell.js new file mode 100644 index 0000000..a1f18f5 --- /dev/null +++ b/internal/api/adminui/shell.js @@ -0,0 +1,316 @@ +/* Оболочка админки: Sidebar (органическая лента навигации), Topbar (поиск, календарь, + уведомления, профиль) и маршрутизация разделов через #адрес. */ + +const ICONS = { + home: '', + leads: '', + doctors: '', + catalog: '', + content: '', + reviews: '', + settings: '', + stats: '', + reports: '', + logout: '', + search: '', + calendar: '', + bell: '', +}; +const svg = (name) => ``; + +// Разделы ленты. Подразделы — прежние вкладки: их разметка и код не менялись. +const NAV = [ + { key: 'home', label: 'Главная', icon: 'home', tabs: [['home', 'Главная']] }, + { key: 'leads', label: 'Заявки', icon: 'leads', tabs: [['leads', 'Заявки']] }, + { key: 'doctors', label: 'Врачи', icon: 'doctors', tabs: [['doctors-dash', 'Дашборд'], ['doctors', 'Список врачей'], ['onec', 'Запись через 1С']] }, + { key: 'catalog', label: 'Услуги и цены', icon: 'catalog', + tabs: [['services', 'Услуги'], ['prices', 'Прайс'], ['checkups', 'Чек-апы'], ['promos', 'Акции']] }, + { key: 'content', label: 'Контент сайта', icon: 'content', + tabs: [['posts', 'Новости и статьи'], ['banners', 'Слайды на главной'], ['pages', 'Страницы'], ['faq', 'Вопросы'], + ['documents', 'Документы'], ['vacancies', 'Вакансии'], ['partners', 'Партнёры']] }, + { key: 'reviews', label: 'Отзывы', icon: 'reviews', tabs: [['reviews', 'Отзывы']] }, + { key: 'stats', label: 'Статистика', icon: 'stats', tabs: [['stats', 'Статистика']] }, + { key: 'reports', label: 'Отчёты', icon: 'reports', tabs: [['reports', 'Отчёты']] }, + { key: 'settings', label: 'Настройки', icon: 'settings', tabs: [['settings', 'Реквизиты и филиалы'], ['redirects', 'Редиректы']] }, +]; +const TAB_GROUP = Object.fromEntries(NAV.flatMap((g) => g.tabs.map(([t]) => [t, g]))); +// Подзаголовок под названием раздела +const TAB_SUB = { + doctors: 'Управление врачами, их услугами и филиалами', + leads: 'Заявки с форм сайта: запись, обратный звонок, отклик на вакансию', +}; +const TAB_TITLE = Object.fromEntries(NAV.flatMap((g) => g.tabs.map(([t, title]) => [t, g.tabs.length > 1 ? g.label : title]))); + +/* ---------- Sidebar ---------- */ +const Sidebar = { + render() { + const nav = $('#side-nav'); + nav.textContent = ''; + for (const g of NAV) { + const b = el('button', { type: 'button', className: 'side__btn' }); + b.dataset.group = g.key; + b.setAttribute('aria-label', g.label); + b.innerHTML = svg(g.icon) + ``; + if (g.key === 'reviews') b.append($('#rv-badge')); + if (g.key === 'leads') b.append(el('span', { id: 'ld-badge', className: 'side__badge', hidden: true })); + b.onclick = () => go(g.tabs[0][0]); + nav.append(b); + } + $('#logout').setAttribute('aria-label', 'Выйти'); + $('#logout').innerHTML = svg('logout') + ''; + Sidebar.shape(); + window.addEventListener('resize', debounce(Sidebar.shape, 100)); + }, + + // Контур ленты: три «капсулы» (логотип, меню, низ), соединённые плавными перетяжками. + shape() { + const side = $('.side'); + const h = side.clientHeight, w = side.clientWidth; + if (!h || getComputedStyle($('.side__shape')).display === 'none') return; + const logo = $('.side__logo'), nav = $('#side-nav'), foot = $('.side__foot'); + const gap = (a, b) => ({ a, b, m: (a + b) / 2 }); + const n1 = gap(logo.offsetTop + logo.offsetHeight + 14, nav.offsetTop - 14); + const n2 = gap(nav.offsetTop + nav.offsetHeight + 14, foot.offsetTop + 46); + const neck = 17; // насколько сужается лента в перетяжке + const r = w / 2, k = 0.55; + const down = (x0, x1, n) => `L ${x0} ${n.a} C ${x0} ${n.a + (n.m - n.a) * k}, ${x1} ${n.m - (n.m - n.a) * k}, ${x1} ${n.m} ` + + `C ${x1} ${n.m + (n.b - n.m) * k}, ${x0} ${n.b - (n.b - n.m) * k}, ${x0} ${n.b} `; + const up = (x0, x1, n) => `L ${x0} ${n.b} C ${x0} ${n.b - (n.b - n.m) * k}, ${x1} ${n.m + (n.b - n.m) * k}, ${x1} ${n.m} ` + + `C ${x1} ${n.m - (n.m - n.a) * k}, ${x0} ${n.a + (n.m - n.a) * k}, ${x0} ${n.a} `; + const d = `M ${r} 0 A ${r} ${r} 0 0 0 0 ${r} ` + down(0, neck, n1) + down(0, neck, n2) + + `L 0 ${h - r} A ${r} ${r} 0 0 0 ${w} ${h - r} ` + up(w, w - neck, n2) + up(w, w - neck, n1) + + `L ${w} ${r} A ${r} ${r} 0 0 0 ${r} 0 Z`; + $('.side__shape').setAttribute('viewBox', `0 0 ${w} ${h}`); + $('.side__shape path').setAttribute('d', d); + }, + + highlight(tab) { + const g = TAB_GROUP[tab]; + document.querySelectorAll('.side__btn[data-group]').forEach((b) => { + if (b.dataset.group === g.key) b.setAttribute('aria-current', 'page'); else b.removeAttribute('aria-current'); + }); + }, +}; + +// Шапки с фото у разделов с вкладками: кнопка «Добавить» зависит от вкладки и нажимает +// скрытую кнопку самой вкладки. «Услуги и цены» — catalogHero в services.js. +const GROUP_HERO = { + posts: ['Добавить публикацию', '#po-add'], + banners: ['Добавить слайд', '#bn-add'], + faq: ['Добавить вопрос', '#fq-add'], + documents: ['Добавить документ', '#dc-add'], + vacancies: ['Добавить вакансию', '#va-add'], + partners: ['Добавить партнёра', '#pt-add'], + reviews: ['Добавить отзыв', '#rv-add'], +}; +function groupHero(tab) { + const h = document.querySelector('[data-hero-for]:not([hidden])'); + const add = h?.querySelector('[data-hero-add]'); + if (!add) return; + const c = GROUP_HERO[tab]; + add.hidden = !c; + if (!c) return; + add.innerHTML = svIcon(BN_ICONS.plus) + c[0]; + add.onclick = () => $(c[1]).click(); +} + +/* ---------- маршруты ---------- */ +function go(tab) { + if (location.hash.slice(1) !== tab) location.hash = tab; else route(); +} +function route() { + let tab = location.hash.slice(1) || 'home'; + if (!TAB_GROUP[tab]) tab = 'home'; + document.body.classList.toggle('leads-view', tab === 'leads'); + document.body.classList.toggle('reports-history-view', tab === 'reports' && $('#tab-reports').classList.contains('rp-history-view')); + const g = TAB_GROUP[tab]; + document.querySelectorAll('main > section').forEach((s) => { s.hidden = s.id !== 'tab-' + tab; }); + Sidebar.highlight(tab); + const sub = $('#subtabs'); + sub.textContent = ''; + if (g.tabs.length > 1) { + for (const [t, title] of g.tabs) { + const b = el('button', { type: 'button', textContent: title }); + if (t === tab) b.setAttribute('aria-current', 'page'); + b.onclick = () => go(t); + sub.append(b); + } + } + $('#page-title').textContent = tab === 'home' ? '' : TAB_TITLE[tab]; + $('#page-title').hidden = tab === 'home'; + $('#page-sub').textContent = TAB_SUB[tab] || ''; + $('#page-sub').hidden = !TAB_SUB[tab]; + // у раздела со своей шапкой (data-hero) общий заголовок не нужен + if ($('#tab-' + tab)?.hasAttribute('data-hero')) { $('#page-title').hidden = true; $('#page-sub').hidden = true; } + // подразделы — внутри шапки раздела, если в ней есть место для них + // общие шапки групп разделов (data-hero-for="services prices …") + document.querySelectorAll('[data-hero-for]').forEach((h) => { h.hidden = !h.dataset.heroFor.split(' ').includes(tab); }); + if (typeof catalogHero === 'function') catalogHero(tab); + groupHero(tab); + const slot = document.querySelector('[data-hero-for]:not([hidden]) [data-subtabs-slot]') || $('#tab-' + tab)?.querySelector('[data-subtabs-slot]'); + if (!Sidebar.subtabsHome) Sidebar.subtabsHome = sub.previousElementSibling; + if (slot) slot.append(sub); else if (sub.parentElement !== Sidebar.subtabsHome.parentElement) Sidebar.subtabsHome.after(sub); + document.title = (tab === 'home' ? 'Панель клиники' : TAB_TITLE[tab]) + ' — DANA'; + if (tab === 'home') Dashboard.load(); + if (tab === 'leads') Leads.load(); + if (tab === 'stats') Stats.open(); + if (tab === 'reports') Reports.open(); + if (tab === 'doctors-dash') DoctorDash.open(); + if (tab === 'onec') Onec.open(); + window.scrollTo({ top: 0 }); +} +window.addEventListener('hashchange', route); + +/* ---------- Topbar ---------- */ +const Topbar = { + searchIndex: null, + + init(user) { + const name = user.name || 'Администратор'; + $('#who-name').textContent = name; + $('#who-mail').textContent = user.email; + $('#who-avatar').textContent = (name.match(/\S+/g) || ['A']).slice(0, 2).map((w) => w[0]).join('').toUpperCase(); + $('#search-btn').innerHTML = '' + svg('search') + ''; + $('#cal-btn').insertAdjacentHTML('afterbegin', svg('calendar')); + // сегодняшняя дата рядом с календарём, как в шапке референса + const now = new Date(); + $('#cal-date').innerHTML = `${new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }).format(now).replace(' г.', '')}` + + `${new Intl.DateTimeFormat('ru-RU', { weekday: 'long' }).format(now).replace(/^./, (c) => c.toUpperCase())}`; + $('#bell-btn').insertAdjacentHTML('afterbegin', svg('bell')); + + const input = $('#search-q'); + input.addEventListener('input', debounce(() => Topbar.search(input.value), 150)); + input.addEventListener('focus', () => { if (input.value.trim()) Topbar.search(input.value); }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { input.value = ''; Topbar.closeAll(); } + if (e.key === 'ArrowDown') { $('#search-results button')?.focus(); e.preventDefault(); } + }); + $('#search-btn').onclick = () => { input.focus(); Topbar.search(input.value); }; + $('#cal-btn').onclick = (e) => { e.stopPropagation(); Topbar.toggle('#cal-pop'); }; + $('#bell-btn').onclick = (e) => { e.stopPropagation(); Topbar.toggle('#bell-pop'); }; + document.addEventListener('click', (e) => { if (!e.target.closest('.search, .popover, .round')) Topbar.closeAll(); }); + document.addEventListener('keydown', (e) => { if (e.key === 'Escape') Topbar.closeAll(); }); + }, + + closeAll() { + $('#search-results').hidden = true; + ['#cal-pop', '#bell-pop'].forEach((id) => { $(id).hidden = true; }); + ['#cal-btn', '#bell-btn'].forEach((id) => $(id).setAttribute('aria-expanded', 'false')); + }, + toggle(id) { + const open = $(id).hidden; + Topbar.closeAll(); + $(id).hidden = !open; + $(id === '#cal-pop' ? '#cal-btn' : '#bell-btn').setAttribute('aria-expanded', String(open)); + }, + + // Поиск по уже загруженным спискам админки: врачи, услуги, чек-апы, публикации, страницы. + async buildIndex() { + const [news, articles, pages] = await Promise.all(['/admin/news', '/admin/articles', '/admin/pages'] + .map((p) => api(p).then((r) => r.data || []).catch(() => []))); + const idx = []; + for (const d of adminDoctors) idx.push({ text: fullName(d) + ' ' + (d.position || ''), title: fullName(d), kind: 'Врач', open: () => { go('doctors'); editDoctor(d); } }); + for (const s of servicesCache) idx.push({ text: s.name, title: s.name, kind: s.parent_id ? 'Услуга' : 'Направление', open: () => { go('services'); editService(s); } }); + for (const c of checkupsCache) idx.push({ text: c.name, title: c.name, kind: 'Чек-ап', open: () => { go('checkups'); editCheckup(c); } }); + for (const [list, kind, key] of [[news, 'Новость', 'news'], [articles, 'Статья', 'articles']]) { + for (const p of list) idx.push({ text: p.title, title: p.title, kind, open: () => { go('posts'); $('#po-kind').value = key; loadPosts().then(() => editPost(p)); } }); + } + for (const p of pages) idx.push({ text: p.title, title: p.title, kind: 'Страница', open: () => { go('pages'); editPage(p); } }); + Topbar.searchIndex = idx; + }, + + async search(raw) { + const q = raw.trim().toLowerCase(); + const box = $('#search-results'); + if (!q) { box.hidden = true; return; } + if (!Topbar.searchIndex) await Topbar.buildIndex(); + const hits = Topbar.searchIndex.filter((x) => x.text.toLowerCase().includes(q)).slice(0, 12); + box.textContent = ''; + if (!hits.length) box.append(el('div', { className: 'search__empty', textContent: 'Ничего не нашлось. Ищем по врачам, услугам, чек-апам, публикациям и страницам.' })); + for (const h of hits) { + const b = el('button', { type: 'button' }); + b.append(el('span', { textContent: h.title }), el('small', { textContent: h.kind })); + b.onclick = () => { Topbar.closeAll(); $('#search-q').value = ''; h.open(); }; + b.onkeydown = (e) => { + if (e.key === 'ArrowDown') { b.nextElementSibling?.focus(); e.preventDefault(); } + if (e.key === 'ArrowUp') { (b.previousElementSibling || $('#search-q')).focus(); e.preventDefault(); } + }; + box.append(b); + } + box.hidden = false; + }, + + // Календарь: реальные сроки акций и слайдов на главной на ближайшие 60 дней. + calendar(items) { + const box = $('#cal-list'); + box.textContent = ''; + if (!items.length) { box.append(el('div', { className: 'popover__empty', textContent: 'В ближайшие 60 дней акций и слайдов со сроками нет.' })); return; } + const fmt = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }); + for (const it of items) { + const b = el('button', { type: 'button', className: 'popover__item' }); + const kind = it.kind === 'promo' ? 'Акция' : 'Слайд'; + b.append(el('b', { textContent: fmt.format(new Date(it.at)).replace('.', '') })); + const t = el('span', { textContent: it.title }); + t.append(el('small', { textContent: `${kind} · ${it.event === 'ends' ? 'последний день показа' : 'начало показа'}` })); + b.append(t); + b.onclick = () => { Topbar.closeAll(); go(it.kind === 'promo' ? 'promos' : 'banners'); }; + box.append(b); + } + }, + + // Уведомления: незакрытые дела по данным базы, без выдуманных событий. + todo(items) { + const TEXT = { + new_leads: ['новых заявок', 'Ответьте пациентам', 'leads'], + pending_reviews: ['отзывов ждут проверки', 'Опубликуйте или отклоните', 'reviews'], + doctors_without_services: ['врачей без услуг', 'Отметьте, какие услуги ведут', 'doctors'], + doctors_without_branches: ['врачей без филиала', 'Укажите, где принимают', 'doctors'], + doctors_without_photo: ['врачей без фото', 'Загрузите фото', 'doctors'], + patients_page_hidden: ['страница «Пациенту» скрыта', 'Проверьте телефон в тексте и включите', 'pages'], + }; + const box = $('#bell-list'); + box.textContent = ''; + $('#bell-dot').hidden = !items.length; + if (!items.length) { box.append(el('div', { className: 'popover__empty', textContent: 'Всё заполнено — дел нет.' })); return; } + for (const it of items) { + const [label, hint, tab] = TEXT[it.key] || [it.key, '', 'home']; + const b = el('button', { type: 'button', className: 'popover__item' }); + b.append(el('b', { textContent: it.key === 'patients_page_hidden' ? '!' : it.count })); + const t = el('span', { textContent: label }); + t.append(el('small', { textContent: hint })); + b.append(t); + b.onclick = () => { + Topbar.closeAll(); go(tab); + if (tab === 'doctors') Doctors.setFilter({ doctors_without_branches: 'no-branches', doctors_without_services: 'no-services', doctors_without_photo: 'no-photo' }[it.key] || ''); + }; + box.append(b); + } + }, +}; + +/* ---------- анимации: переключатель внизу ленты ---------- */ +function initMotionSwitch() { + const sw = $('#motion'); + let off = false; + try { off = localStorage.getItem('dana-admin-motion') === 'off'; } catch { /* приватный режим */ } + const apply = () => { + document.documentElement.dataset.motion = off ? 'off' : 'on'; + sw.setAttribute('aria-checked', String(!off)); + sw.querySelector('.tip').textContent = off ? 'Анимации выключены' : 'Анимации включены'; + }; + sw.onclick = () => { off = !off; try { localStorage.setItem('dana-admin-motion', off ? 'off' : 'on'); } catch { /* без сохранения */ } apply(); }; + apply(); +} + +/* ---------- запуск ---------- */ +const baseShowApp = showApp; +showApp = function (user) { + baseShowApp(user); + Sidebar.render(); + Topbar.init(user); + initMotionSwitch(); + route(); + // на главной сводку загрузит сам экран; на остальных страницах — только для шапки + if (location.hash && location.hash !== '#home') Dashboard.summary().catch(() => {}); +}; +boot(); diff --git a/internal/api/adminui/stats.js b/internal/api/adminui/stats.js new file mode 100644 index 0000000..3a15394 --- /dev/null +++ b/internal/api/adminui/stats.js @@ -0,0 +1,225 @@ +/* «Статистика»: только то, что сайт действительно сохраняет о заявках. + Звонков, посещений и выручки сайт не знает — таких карточек здесь нет. */ + +const ST_SOURCES = { + '': 'Прямой заход / без метки', yandex: 'Яндекс', 'ya.ru': 'Яндекс', 'yandex.ru': 'Яндекс', google: 'Google', 'google.com': 'Google', + vk: 'ВКонтакте', 'vk.com': 'ВКонтакте', 'm.vk.com': 'ВКонтакте', telegram: 'Telegram', 't.me': 'Telegram', + instagram: 'Instagram', '2gis': '2ГИС', '2gis.ru': '2ГИС', whatsapp: 'WhatsApp', 'dzen.ru': 'Дзен', prodoctorov: 'ПроДокторов', + 'prodoctorov.ru': 'ПроДокторов', +}; +const ST_SECTIONS = { + home: 'Главная', services: 'Страницы услуг', doctors: 'Страницы врачей', checkups: 'Чек-апы', promos: 'Акции', + prices: 'Цены', contacts: 'Контакты', vacancy: 'Вакансии', other: 'Другие страницы', +}; +const ST_COLORS = ['#4f8ff0', '#e8708a', '#3fb27f', '#a77ef0', '#f0a04b', '#46b8c8', '#b5b8b6']; +const ST_STATUS_COLORS = { new: '#3fb27f', in_progress: '#4f8ff0', done: '#f0c24b', spam: '#e8708a' }; +const ST_PRESETS = [['today', 'Сегодня', 0], ['7', '7 дней', 6], ['30', '30 дней', 29], ['90', '3 месяца', 89], ['365', 'Год', 364]]; +const ST_ICONS = { + leads: '', + people: '', + check: '', + repeat: '', + shield: '', + calendar: '', +}; +const stIcon = (n) => ``; +const pct = (a, b) => (b ? Math.round((a / b) * 100) : 0); +const isoDay = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +const shortDay = (s) => s.slice(8, 10) + '.' + s.slice(5, 7); + +const Stats = { + preset: '30', + group: 'day', + data: null, + + range() { + return { from: $('#st-from').value, to: $('#st-to').value }; + }, + setPreset(key) { + Stats.preset = key; + const p = ST_PRESETS.find((x) => x[0] === key); + if (p) { + const to = new Date(); const from = new Date(); from.setDate(from.getDate() - p[2]); + $('#st-from').value = isoDay(from); $('#st-to').value = isoDay(to); + } + for (const b of document.querySelectorAll('#st-presets button')) b.setAttribute('aria-pressed', String(b.dataset.key === key)); + Stats.load(); + }, + + async load() { + const { from, to } = Stats.range(); + const br = $('#st-branch').value; + try { + const r = await api(`/admin/stats?from=${from}&to=${to}${br ? '&branch_id=' + br : ''}`); + Stats.data = r.data; Stats.meta = r.meta; + for (const k of ['by_day', 'by_form', 'by_source', 'by_section', 'by_branch', 'top_services', 'top_doctors']) Stats.data[k] ||= []; + Stats.render(); + } catch (e) { toast(e.message, true); } + }, + + delta(cur, prev) { + if (!prev && !cur) return ''; + if (!prev) return 'новое'; + const d = Math.round(((cur - prev) / prev) * 100); + if (!d) return 'без изменений'; + return `${d > 0 ? '↑' : '↓'} ${Math.abs(d)}%`; + }, + + render() { + const s = Stats.data, m = Stats.meta; + const period = m.days === 1 ? 'за день' : `за ${m.days} ${plural(m.days, 'день', 'дня', 'дней')}`; + const kpis = [ + ['leads', '#efeafb', '#7a5cc9', 'Всего заявок', s.total, Stats.delta(s.total, s.prev_total), period + ' · без спама'], + ['people', '#e5f1fb', '#3a7bd5', 'Разные телефоны', s.unique_phones, Stats.delta(s.unique_phones, s.prev_unique), 'уникальные обращения'], + ['check', '#e7f6ee', '#2f9a6a', 'Обработано', pct(s.done, s.total) + '%', '', `${s.done} из ${s.total} — статус «обработана»`], + ['repeat', '#fdf1e3', '#d8822c', 'Повторные', s.repeat, '', 'номера с 2+ заявками'], + ['shield', '#fdeaec', '#d9546d', 'Спам отсеян', s.spam, '', 'помечены как спам'], + ]; + const k = $('#st-kpis'); k.textContent = ''; + for (const [icon, bg, ink, label, value, delta, hint] of kpis) { + const c = el('div', { className: 'card kpi' }); + c.innerHTML = `${stIcon(icon)} +
${label}${value}${delta}${hint}
`; + k.append(c); + } + Stats.chart(); + Stats.donut('#st-sources', s.by_source.map((x) => [ST_SOURCES[x.key] ?? x.label, x.count]), s.total, + s.total && !s.with_utm ? 'Ни одна заявка за период не пришла с UTM-меткой или с другого сайта. Источник записывается только для заявок, оставленных после обновления сайта.' : ''); + Stats.donut('#st-status', ['new', 'in_progress', 'done', 'spam'].map((key) => [LEAD_STATUS[key]?.[0] || key, + s[key === 'in_progress' ? 'in_progress' : key], ST_STATUS_COLORS[key]]), s.total + s.spam, '', 'заявок'); + Stats.tops('#st-services', s.top_services, 'Услуга', 'Заявки с выбранной услугой или со страницы услуги.'); + Stats.tops('#st-doctors', s.top_doctors, 'Врач', 'Заявки к врачу или со страницы врача.'); + Stats.bars('#st-branches', s.by_branch.map((x) => [x.label ? shortAddress(x.label) : 'Филиал не выбран', x.count]), s.total); + Stats.bars('#st-forms', s.by_form.map((x) => [x.label, x.count]), s.total); + Stats.bars('#st-pages', s.by_section.map((x) => [ST_SECTIONS[x.key] || x.key, x.count]), s.total); + }, + + // Динамика: по дням или по неделям, с подсказкой при наведении + chart() { + const box = $('#st-chart'); box.textContent = ''; + let pts = Stats.data.by_day.map((d) => ({ label: shortDay(d.date), full: ruDate(d.date), v: d.count })); + if (Stats.group === 'week' && pts.length > 13) { + const w = []; + for (let i = 0; i < pts.length; i += 7) { + const part = pts.slice(i, i + 7); + w.push({ label: part[0].label, full: `${part[0].full} — ${part[part.length - 1].full}`, v: part.reduce((a, b) => a + b.v, 0) }); + } + pts = w; + } + const W = Math.max(box.clientWidth, 320), H = 230, L = 34, R = 10, T = 12, B = 26; + const max = Math.max(4, ...pts.map((p) => p.v)); + const step = Math.ceil(max / 4); + const top = step * 4; + const x = (i) => L + (pts.length === 1 ? (W - L - R) / 2 : (i * (W - L - R)) / (pts.length - 1)); + const y = (v) => T + (H - T - B) * (1 - v / top); + const line = pts.map((p, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)} ${y(p.v).toFixed(1)}`).join(' '); + const area = `${line} L${x(pts.length - 1).toFixed(1)} ${y(0)} L${x(0).toFixed(1)} ${y(0)} Z`; + let grid = ''; + for (let g = 0; g <= 4; g++) { + const v = step * g; + grid += `${v}`; + } + const every = Math.ceil(pts.length / 10); + const labels = pts.map((p, i) => (i % every === 0 || i === pts.length - 1) ? `${p.label}` : '').join(''); + const svg = ` + + ${grid}${labels} + `; + box.innerHTML = svg + ''; + const s = box.querySelector('svg'), tip = box.querySelector('.st-tip'), guide = s.querySelector('.st-guide'), dot = s.querySelector('.st-dot'); + s.addEventListener('mousemove', (e) => { + const r = s.getBoundingClientRect(); + const px = ((e.clientX - r.left) / r.width) * W; + let i = Math.round(((px - L) / (W - L - R)) * (pts.length - 1)); + i = Math.max(0, Math.min(pts.length - 1, i)); + const p = pts[i]; + guide.setAttribute('x1', x(i)); guide.setAttribute('x2', x(i)); guide.hidden = false; + dot.setAttribute('cx', x(i)); dot.setAttribute('cy', y(p.v)); dot.hidden = false; + tip.hidden = false; + tip.innerHTML = `${p.v} ${plural(p.v, 'заявка', 'заявки', 'заявок')}${p.full}`; + tip.style.left = (x(i) / W) * r.width + 'px'; tip.style.top = (y(p.v) / H) * r.height + 'px'; + }); + s.addEventListener('mouseleave', () => { tip.hidden = true; guide.hidden = true; dot.hidden = true; }); + }, + + donut(sel, items, total, note, unit = 'заявок') { + const box = $(sel); box.textContent = ''; + items = items.filter((x) => x[1] > 0); + const sum = items.reduce((a, b) => a + b[1], 0); + if (!sum) { box.append(el('p', { className: 'st-empty', textContent: 'За этот период заявок нет.' })); return; } + const R = 60, C = 2 * Math.PI * R; + let off = 0, arcs = ''; + items.forEach((it, i) => { + const len = (it[1] / sum) * C; + arcs += ``; + off += len; + }); + const wrap = el('div', { className: 'st-donut' }); + wrap.innerHTML = `${arcs} + ${sum}${unit}`; + const legend = el('ul', { className: 'st-legend' }); + items.forEach((it, i) => { + const li = el('li'); + li.innerHTML = `${pct(it[1], sum)}%${it[1]}`; + li.querySelector('span').textContent = it[0]; + legend.append(li); + }); + wrap.append(legend); box.append(wrap); + if (note) box.append(el('p', { className: 'st-note', textContent: note })); + }, + + bars(sel, items, total) { + const box = $(sel); box.textContent = ''; + if (!items.length) { box.append(el('p', { className: 'st-empty', textContent: 'За этот период заявок нет.' })); return; } + const max = Math.max(...items.map((x) => x[1])); + for (const [label, n] of items) { + const row = el('div', { className: 'st-bar' }); + row.innerHTML = `
${pct(n, total)}%${n}`; + row.querySelector('span').textContent = label; + box.append(row); + } + }, + + tops(sel, list, what, note) { + const box = $(sel); box.textContent = ''; + if (!list.length) { box.append(el('p', { className: 'st-empty', textContent: 'За этот период нет заявок, привязанных к ' + (what === 'Врач' ? 'врачу.' : 'услуге.') })); return; } + const t = el('table', { className: 'st-top' }); + t.innerHTML = `#${what}ЗаявкиОбработано`; + const tb = el('tbody'); + list.slice(0, 5).forEach((x, i) => { + const tr = el('tr'); + tr.append(el('td', { className: 'muted', textContent: i + 1 }), el('td', { textContent: x.name }), + el('td', { textContent: x.count }), el('td', { className: 'muted', textContent: pct(x.done, x.count) + '%' })); + tb.append(tr); + }); + t.append(tb); box.append(t); + box.append(el('p', { className: 'st-note', textContent: note })); + }, + + init() { + const pre = $('#st-presets'); + for (const [key, label] of ST_PRESETS) { + const b = el('button', { type: 'button', textContent: label }); + b.dataset.key = key; + b.onclick = () => Stats.setPreset(key); + pre.append(b); + } + $('#st-cal').innerHTML = stIcon('calendar'); + for (const id of ['#st-from', '#st-to']) $(id).onchange = () => { + Stats.preset = ''; + for (const b of document.querySelectorAll('#st-presets button')) b.setAttribute('aria-pressed', 'false'); + if ($('#st-from').value && $('#st-to').value) Stats.load(); + }; + $('#st-branch').onchange = () => Stats.load(); + $('#st-group').onchange = () => { Stats.group = $('#st-group').value; Stats.chart(); }; + window.addEventListener('resize', debounce(() => { if (Stats.data && !$('#tab-stats').hidden) Stats.chart(); }, 150)); + }, + + open() { + const sel = $('#st-branch'); + if (sel.options.length <= 1) for (const b of branchList) sel.append(el('option', { value: b.id, textContent: shortAddress(b.address) })); + if (!Stats.data) Stats.setPreset(Stats.preset || '30'); else Stats.load(); + }, +}; +Stats.init(); diff --git a/internal/api/adminui/weekly-experiment.css b/internal/api/adminui/weekly-experiment.css new file mode 100644 index 0000000..daf5a0e --- /dev/null +++ b/internal/api/adminui/weekly-experiment.css @@ -0,0 +1,71 @@ +/* Approved reference styling, applied to the real table only while open. */ +.weekly-experiment { + position: fixed; inset: 0; width: 100vw; height: 100dvh; max-width: none; max-height: none; + margin: 0; padding: 8px; border: 0; border-radius: 0; background: #efefed; color: #25364a; + font-family: 'Segoe UI', Arial, sans-serif; font-size: 14px; box-sizing: border-box; + --wk-channel-width: 226px; --wk-metric-width: 205px; +} +.weekly-experiment::backdrop { background: #efefed; } +.wke-layout { display: flex; flex-direction: column; height: 100%; min-height: 0; border-radius: 20px; background: #fdfdfc; overflow: hidden; } +.wke-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 6px 14px; min-height: 36px; } +.wke-modes { display: inline-flex; flex-shrink: 0; gap: 3px; padding: 3px; border-radius: 11px; background: #f1f2f3; } +.wke-mode { padding: 6px 12px; border: 0; border-radius: 8px; background: transparent; color: #7c8a98; font-size: 13px; cursor: pointer; white-space: nowrap; } +.wke-mode[aria-pressed="true"] { background: #e2efe8; color: #355d4b; box-shadow: 0 1px 3px #2636490a; } +.wke-mode:hover { color: #355d4b; } +.wke-mode:focus-visible { outline: 2px solid #8fbba5; outline-offset: 1px; } +.wke-status { margin-right: auto; font-size: 12px; color: #8b959e; } +.wke-status--error { color: #b85349; } +.wke-refresh { padding: 5px 10px; border: 0; border-radius: 8px; background: #f1f2f3; color: #55656e; cursor: pointer; font-size: 12px; } +.wke-refresh:disabled { opacity: .5; cursor: wait; } +.weekly-experiment #wk-filter { gap: 6px; flex-wrap: wrap; } +.weekly-experiment #wk-filter input { width: 132px; height: 28px; border: 1px solid #e8eded; border-radius: 7px; background: #f6f6f6; padding: 3px 6px; font: 12px 'Segoe UI', sans-serif; } +.weekly-experiment #wk-filter-reset { border: 0; background: none; color: #627a87; font-size: 12px; cursor: pointer; } +.weekly-experiment .wke-scroll { flex: 1; min-height: 0; overflow: auto; padding: 0 0 8px; box-sizing: border-box; scrollbar-width: thin; scrollbar-color: #d7ddde #f6f6f5; } +.weekly-experiment .wk-table { margin: 4px 0 0 8px; width: calc(var(--wk-channel-width) + var(--wk-metric-width) + var(--wk-period-count, 1) * var(--wk-period-width, 112px) + 6px); min-width: 0; table-layout: fixed; border-collapse: separate; border-spacing: 2px; color: #25364a; } +.weekly-experiment .wk-col--channel { width: var(--wk-channel-width); } +.weekly-experiment .wk-col--metric { width: var(--wk-metric-width); } +.weekly-experiment .wk-col--period { width: var(--wk-period-width, 112px); } +.weekly-experiment .wk-table th, .weekly-experiment .wk-table td { box-sizing: border-box; border: 0; height: 30px; padding: 3px 8px; border-radius: 5px; font-size: 14px; line-height: 18px; font-weight: 400; vertical-align: middle; } +.weekly-experiment .wk-table thead th { position: sticky; top: 0; z-index: 8; height: 42px; padding: 4px 7px; white-space: nowrap; text-align: center; border-radius: 10px; background: #f1f2f3; font-weight: 600; color: #152337; } +.weekly-experiment .wk-table .wk-brand { left: 0; z-index: 10; min-width: 0; padding: 0 20px; text-align: left; border-radius: 0; background: #fdfdfc; } +.weekly-experiment .wk-brand span { display: block; font-size: 24px; line-height: 26px; font-weight: 400; letter-spacing: -.025em; } +.weekly-experiment .wk-brand small { display: block; margin-top: 0; font-size: 13px; line-height: 16px; font-weight: 400; color: #8b959e; } +.weekly-experiment .wk-table .wk-metric { position: sticky; left: calc(var(--wk-channel-width) + 2px); z-index: 3; white-space: normal; text-align: left; color: #25364a; background: linear-gradient(135deg, #f6f6f6, #f2f3f3); border-top-left-radius: 0; border-bottom-left-radius: 0; box-shadow: -2px 0 0 #fdfdfc; } +.weekly-experiment .wk-table .wk-value { text-align: right; white-space: nowrap; background: linear-gradient(135deg, #f6f6f6, #f2f3f3); font-variant-numeric: tabular-nums; } +.weekly-experiment .wk-table .wk-heat { background: var(--wk-heat); } +.weekly-experiment .wk-table .wk-channel { position: sticky; left: 0; z-index: 5; padding: 18px 20px; border-right: 6px solid #fdfdfc; border-radius: 13px 0 0 13px; text-align: left; color: #17283b; background: linear-gradient(125deg, var(--wk-tint), #fafaf8); } +/* При прокрутке вправо колонка прижата к краю окна: скруглённые левые углы открывали цветные ячейки под ней. */ +.weekly-experiment.wke-scrolled .wk-table .wk-channel { border-top-left-radius: 0; border-bottom-left-radius: 0; } +/* При прокрутке вниз шапка закреплена у верха: скруглённые углы и зазоры между ячейками открывали цветные строки под ней. */ +.weekly-experiment.wke-scrolled-y .wk-table thead th { border-radius: 0; box-shadow: 2px 0 0 #fdfdfc, 0 2px 0 #fdfdfc, 2px 2px 0 #fdfdfc; } +.weekly-experiment .wk-channel__icon { width: 48px; height: 48px; margin-bottom: 8px; background: transparent; } +.weekly-experiment .wk-channel__icon img, .weekly-experiment .wk-channel__icon svg { width: 32px; height: 32px; } +.weekly-experiment .wk-channel__title { margin: 0 0 12px; color: #14243a; font-size: 18px; line-height: 22px; font-weight: 600; } +.weekly-experiment .wk-channel__hint { margin-top: 0; font-size: 13px; line-height: 18px; white-space: pre-line; } +.weekly-experiment .wk-table .wk-gap td { height: 30px; padding: 0; background: #fdfdfc; } +.weekly-experiment .wk-table .wk-budget { width: 100%; height: 22px; font-size: 14px; } +.wke-close { display: block; flex-shrink: 0; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 8px; color: #7c8a98; background: transparent; font: 24px/28px 'Segoe UI', sans-serif; cursor: pointer; opacity: .65; } +.wke-close:hover, .wke-close:focus-visible { background: #e8edee; color: #213448; opacity: 1; } +.wke-close:focus-visible { outline: 2px solid #8fbba5; outline-offset: 2px; } +.wke-empty { flex-shrink: 0; padding: 10px 20px; text-align: left; color: #6d7d89; font-size: 13px; line-height: 1.6; background: #f6f7f6; } +@media (max-width: 900px) { + .weekly-experiment { --wk-channel-width: 190px; --wk-metric-width: 190px; } + .weekly-experiment .wk-table .wk-channel { padding: 14px 12px; } + .weekly-experiment .wk-table .wk-brand { padding-left: 12px; } +} +@media (max-width: 600px) { + .weekly-experiment { padding: 4px; --wk-channel-width: 126px; --wk-metric-width: 145px; } + .wke-layout { border-radius: 14px; } + .wke-toolbar { gap: 6px; padding: 6px 8px; } + .wke-status { flex: 1; } + .weekly-experiment #wk-filter { order: 1; width: 100%; justify-content: flex-start; } + .weekly-experiment #wk-filter input { flex: 1; min-width: 0; } + .weekly-experiment .wke-scroll { padding: 0 0 5px; } + .weekly-experiment .wk-table { margin: 4px 0 0 5px; } + .weekly-experiment .wk-table .wk-brand { padding-left: 8px; } + .weekly-experiment .wk-brand span { font-size: 20px; } + .weekly-experiment .wk-brand small { font-size: 11px; } + .weekly-experiment .wk-table .wk-channel { padding: 12px 8px; } + .weekly-experiment .wk-channel__title { font-size: 14px; } + .weekly-experiment .wk-channel__hint { font-size: 11px; } +} diff --git a/internal/api/adminui/weekly-experiment.js b/internal/api/adminui/weekly-experiment.js new file mode 100644 index 0000000..cf789e2 --- /dev/null +++ b/internal/api/adminui/weekly-experiment.js @@ -0,0 +1,102 @@ +/* Full-viewport presentation of the real weekly-report table. + No demo periods, metrics or contact numbers: WeeklyDashboard owns all data. */ +const WeeklyFullscreen = { + ensure() { + if (this.dialog) return; + const dialog = el('dialog', { id: 'weekly-fullscreen', className: 'weekly-experiment', ariaLabel: ReportClinic.brand + ' — маркетинговая аналитика по неделям' }); + const layout = el('div', { className: 'wke-layout' }); + const toolbar = el('div', { className: 'wke-toolbar' }); + const modes = el('div', { className: 'wke-modes', role: 'group', ariaLabel: 'Периодичность отчёта' }); + for (const [mode, label] of [['weeks', 'По неделям'], ['months', 'По месяцам']]) { + const button = el('button', { type: 'button', className: 'wke-mode', textContent: label }); + button.dataset.mode = mode; + button.onclick = () => WeeklyDashboard.setMode(mode); + modes.append(button); + } + const status = el('span', { className: 'wke-status', role: 'status', ariaLive: 'polite' }); + const refresh = el('button', { type: 'button', className: 'wke-refresh', textContent: 'Обновить', title: 'Загрузить актуальные данные отчётов' }); + refresh.onclick = () => WeeklyDashboard.load(); + const close = el('button', { type: 'button', className: 'wke-close', ariaLabel: 'Вернуться в админку', title: 'Вернуться в админку · Esc', textContent: '×' }); + close.onclick = () => dialog.close(); + toolbar.append(modes, status, refresh, close); + const scroll = el('div', { className: 'wke-scroll' }); + const empty = el('div', { className: 'wke-empty', role: 'status', hidden: true }); + layout.append(toolbar, empty, scroll); + dialog.append(layout); + scroll.addEventListener('scroll', () => { + dialog.classList.toggle('wke-scrolled', scroll.scrollLeft > 0); + dialog.classList.toggle('wke-scrolled-y', scroll.scrollTop > 0); + }, { passive: true }); + dialog.addEventListener('close', () => { + // Return the actual nodes (including budget/filter handlers), not copies. + this.tableHome.append(this.table); + this.filterHome.append(this.filter); + document.body.style.overflow = this.savedOverflow; + document.title = this.savedTitle; + }); + document.body.append(dialog); + Object.assign(this, { dialog, toolbar, modes, status, refresh, close, scroll, empty }); + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(scroll); + }, + + async open() { + this.ensure(); + if (this.dialog.open) return; + this.table = $('#wk-table'); + this.filter = $('#wk-filter'); + this.tableHome = this.table.parentElement; + this.filterHome = this.filter.parentElement; + this.savedOverflow = document.body.style.overflow; + this.savedTitle = document.title; + this.scroll.append(this.table); + this.toolbar.insertBefore(this.filter, this.refresh); + this.scroll.scrollTop = 0; + this.scroll.scrollLeft = 0; + this.dialog.classList.remove('wke-scrolled', 'wke-scrolled-y'); + document.body.style.overflow = 'hidden'; + document.title = ReportClinic.brand + ' — маркетинговая аналитика'; + this.dialog.showModal(); + this.close.focus({ preventScroll: true }); + await WeeklyDashboard.load(); + }, + + sync() { + if (!this.dialog?.open) return; + const { list, loading, error } = WeeklyDashboard; + const periods = WeeklyDashboard.periods(); + const monthly = WeeklyDashboard.mode === 'months'; + for (const button of this.modes.children) button.setAttribute('aria-pressed', String(button.dataset.mode === WeeklyDashboard.mode)); + // Keep the page's top tabs in sync when the mode changes inside the report. + for (const button of document.querySelectorAll('#rp-tabs button[data-mode]')) button.setAttribute('aria-selected', String(button.dataset.mode === WeeklyDashboard.mode)); + const heading = $('#tab-reports .rp-head h1'); + if (heading) heading.textContent = monthly ? 'Дашборд по месяцам' : 'Дашборд по неделям'; + this.dialog.setAttribute('aria-label', ReportClinic.brand + ' — маркетинговая аналитика ' + (monthly ? 'по месяцам' : 'по неделям')); + const visible = WeeklyDashboard.filtered(); + this.table.style.setProperty('--wk-period-count', visible.length); + this.refresh.disabled = loading; + this.dialog.setAttribute('aria-busy', String(loading)); + this.status.textContent = loading ? 'Загрузка отчётов…' + : error ? `${error}${list.length ? ' · показаны ранее загруженные данные' : ''}` + : `Периодов: ${periods.length}${visible.length !== periods.length ? ' · показано: ' + visible.length : ''}`; + this.status.classList.toggle('wke-status--error', Boolean(error)); + this.scroll.hidden = false; + this.empty.hidden = Boolean(visible.length); + this.empty.textContent = loading ? 'Загрузка реальных данных…' + : error ? 'Не удалось загрузить отчёты. Нажмите «Обновить», чтобы повторить.' + : periods.length ? 'За выбранные даты отчётов нет. Измените даты или нажмите «Показать все».' + : monthly ? 'Месячных отчётов пока нет. Загрузите выгрузку 1С за полный календарный месяц во вкладке «Новый отчёт». Недельные отчёты не суммируются, чтобы не задвоить пациентов.' + : 'Недельных отчётов пока нет. Загрузите выгрузку 1С с указанным периодом во вкладке «Новый отчёт».'; + this.resize(); + }, + + resize() { + if (!this.dialog?.open) return; + const style = getComputedStyle(this.dialog); + const frozenWidth = parseFloat(style.getPropertyValue('--wk-channel-width')) + parseFloat(style.getPropertyValue('--wk-metric-width')); + const count = Math.max(WeeklyDashboard.filtered().length, 1); + // Few actual weeks must not stretch into enormous columns or get fake dates. + const width = Math.max(112, Math.min(160, (this.scroll.clientWidth - frozenWidth - 24) / count)); + this.table.style.setProperty('--wk-period-width', width + 'px'); + }, +}; diff --git a/internal/api/adminui/weekly.js b/internal/api/adminui/weekly.js new file mode 100644 index 0000000..bcdd169 --- /dev/null +++ b/internal/api/adminui/weekly.js @@ -0,0 +1,521 @@ +/* «Дашборд по неделям»: только просмотр накопленной истории периодов. Сами периоды появляются + автоматически при загрузке выгрузки 1С на вкладке «Новый отчёт» (если в файле есть строка + «Период ДД.ММ.ГГГГ - ДД.ММ.ГГГГ»). Здесь — таблица, фильтр по датам и правка бюджета. */ + +// Conditional-format rules read from «Показатели Дана (11).xlsx», «Дана неделя». +// Excel ranges identify the corresponding metric. In the live dashboard they +// extend across that metric's full history, including weeks hidden by the filter. +const WeeklyExcelFormatting = { + rules: { + callcenter: { + unique: { range: 'C8:P8', colors: ['#E78273', '#FFD666', '#57BB8A'] }, + revenue: { range: 'C12:AA12', colors: ['#E67C73', '#FFD666', '#57BB8A'] }, + }, + 'сайт': { + leads: { range: 'C17:U17', colors: ['#E78273', '#FFD666', '#57BB8A'] }, + revenue: { range: 'C22:Z22', colors: ['#E98972', '#FFD666', '#57BB8A'] }, + }, + '2гис': { + leads: { range: 'C35 / D35:Z35', separatePeriod: '30.03-05.04', colors: ['#E78273', '#FFD666', '#57BB8A'] }, + revenue: { range: 'C40:AA40', colors: ['#E67C73', '#FFD666', '#57BB8A'] }, + }, + 'яндекскарточка': { + leads: { range: 'C43:U44', colors: ['#E78273', '#FFD666', '#57BB8A'] }, + revenue: { range: 'C49:P49', colors: ['#E78273', '#FFD666', '#57BB8A'] }, + }, + 'продокторов': { + leads: { range: 'C62:P62', colors: ['#E78273', '#FFD666', '#57BB8A'] }, + revenue: { range: 'C67:P67', colors: ['#E78273', '#FFD666', '#57BB8A'] }, + }, + 'напоправку': { revenue: { range: 'C76:P76', colors: ['#E78273', '#FFD666', '#E78273'] } }, + 'сберздоровье': { revenue: { range: 'C85:P85', colors: ['#E78273', '#FFD666', '#57BB8A'] } }, + zoon: { revenue: { range: 'C94:P94', colors: ['#E78273', '#FFD666', '#57BB8A'] } }, + 'докту': { revenue: { range: 'C103:P103', colors: ['#E78273', '#FFD666', '#57BB8A'] } }, + 'тв': { revenue: { range: 'C127:P127', colors: ['#E78273', '#FFD666', '#57BB8A'] } }, + 'инстаграм': { + leads: { range: 'C149:U149', colors: ['#E78273', '#FFD666', '#57BB8A'] }, + revenue: { range: 'C154:P154', colors: ['#E78273', '#FFD666', '#57BB8A'] }, + }, + }, + + // Ordinary cell fills and manually highlighted rows on «Дана неделя». + // These are not colorScale rules: do not invent numeric thresholds for them. + fills: { + google: { revenue: { range: 'C58:U58', color: '#83C380' } }, + zoon: { leads: { range: 'C89:U89', color: '#E67C73' } }, + 'докту': { leads: { range: 'C98:U98', color: '#E98A71' } }, + 'тв': { leads: { range: 'C122:U122', color: '#E98A71' } }, + 'радио': { + leads: { range: 'C131:U131', color: '#E98972' }, + revenue: { range: 'C136:U136', color: '#F4CCCC' }, + }, + 'баннера': { revenue: { range: 'C145:U145', color: '#F4CCCC' } }, + 'маршрутки': { revenue: { range: 'C163:U163', color: '#F4CCCC' } }, + 'вотсапрассылки': { revenue: { range: 'C172:U172', color: '#F4CCCC' } }, + 'полиграфия': { revenue: { range: 'C181:U181', color: '#F4CCCC' } }, + 'итогоинтернет': { conversion: { range: 'C112:U112', color: '#F4CCCC' } }, + }, + + rule(source, metric) { + let key = String(source || '').toLocaleLowerCase('ru-RU').replace(/[\s_-]+/g, ''); + key = ({ '2gis': '2гис', 'яндекс': 'яндекскарточка', 'инстаграмм': 'инстаграм', instagram: 'инстаграм' })[key] || key; + return this.rules[key]?.[metric]; + }, + + fill(source, metric) { + const key = WeeklySourceLayout.key(source); + if (this.fills[key]?.[metric]) return this.fills[key][metric]; + if (key === 'callcenter') return { range: 'C2:U15', color: '#F9CB9C' }; + if (WeeklySourceLayout.sources.some(([name]) => WeeklySourceLayout.key(name) === key)) + return { range: 'C17:U187', color: '#EAE5D8' }; + return null; + }, + + colors(values, rule, reference = values, labels = [], referenceLabels = labels) { + if (!rule) return values.map(() => ''); + const periodKey = (label) => String(label || '').replace(/[–—]/g, '-').replace(/\s/g, ''); + const separate = (label) => Boolean(rule.separatePeriod && periodKey(label) === rule.separatePeriod); + const sorted = (isolated) => reference.filter((v, i) => Number.isFinite(v) && separate(referenceLabels[i]) === isolated).sort((a, b) => a - b); + const groups = [sorted(false), sorted(true)]; + const rgb = rule.colors.map((color) => [1, 3, 5].map((offset) => parseInt(color.slice(offset, offset + 2), 16))); + return values.map((value, i) => { + if (!Number.isFinite(value)) return ''; + const nums = groups[Number(separate(labels[i]))]; + if (!nums.length) return ''; + const min = nums[0], max = nums[nums.length - 1]; + // PERCENTILE.INC(range, .5): the middle value, or the mean of the two middle values. + const median = (nums[Math.floor((nums.length - 1) / 2)] + nums[Math.ceil((nums.length - 1) / 2)]) / 2; + // Excel uses the upper colour for a singleton/equal-value scale (e.g. C35). + if (min === max || value >= max) return rule.colors[2]; + if (value <= min) return rule.colors[0]; + if (value === median) return rule.colors[1]; + const lowerHalf = value < median; + const lower = lowerHalf ? min : median, upper = lowerHalf ? median : max; + const from = rgb[lowerHalf ? 0 : 1], to = rgb[lowerHalf ? 1 : 2]; + const fraction = (value - lower) / (upper - lower); + return `rgb(${from.map((start, component) => Math.round(start + (to[component] - start) * fraction)).join(', ')})`; + }); + }, +}; + +const WeeklySourceLayout = { + // Source order from the reference, with «Медийка» excluded at the user's request. + sources: [ + ['сайт', 'Сайт'], ['2гис', '2Гис'], + ['яндекскарточка', 'Яндекс карточка'], ['google', 'Google карточка'], + ['продокторов', 'Продокторов'], ['напоправку', 'НаПоправку'], + ['сберздоровье', 'Сберздоровье'], ['zoon', 'ZOON'], ['докту', 'Докту'], + ['итого-интернет', 'ИТОГО интернет'], ['тв', 'ТВ'], ['радио', 'Радио'], + ['баннера', 'Баннера'], ['инстаграм', 'Инстаграм'], ['маршрутки', 'Маршрутки'], + ['вотсапрассылки', 'Вотсап рассылки'], ['полиграфия', 'Полиграфия'], + ], + + key(value) { + const key = String(value || '').toLocaleLowerCase('ru-RU').replace(/[\s_-]+/g, ''); + return ({ + '2gis': '2гис', 'яндекс': 'яндекскарточка', googleкарточка: 'google', + 'баннер': 'баннера', 'инстаграмм': 'инстаграм', instagram: 'инстаграм', + whatsapp: 'вотсапрассылки', whatsappрассылки: 'вотсапрассылки', + 'вотсап': 'вотсапрассылки', 'ватсап': 'вотсапрассылки', 'ватсапрассылки': 'вотсапрассылки', + })[key] || key; + }, + + arrange(reports) { + const available = []; + const seen = new Set(); + for (const report of reports) { + for (const channel of report.channels) { + // Hide this source from the dashboard, including previously saved reports. + // The saved channel and its calculations remain untouched. + if (this.key(channel.key) === 'медийка' || this.key(channel.title) === 'медийка') continue; + if (this.key(channel.key) === 'всеномера' || seen.has(channel.key)) continue; + seen.add(channel.key); + available.push(channel); + } + } + const result = []; + const used = new Set(); + for (const [key, title] of this.sources) { + const canonical = this.key(key); + const matches = available.filter((channel) => !used.has(channel.key) + && (this.key(channel.key) === canonical || this.key(channel.title) === canonical)); + if (matches.length) { + for (const channel of matches) { + result.push({ key: channel.key, title }); + used.add(channel.key); + } + } else { + result.push({ key, title }); + } + } + // Keep additional real channels; never discard data absent from the template. + return result.concat(available.filter((channel) => !used.has(channel.key)).map(({ key, title }) => ({ key, title }))); + }, +}; + +const WeeklyDashboard = { + list: [], + // Страница по ссылке для маркетолога (share.html): только просмотр — без удаления периодов и правки бюджета. + readOnly: false, + loading: false, + error: '', + requestId: 0, + mode: 'weeks', + + dateKey(value) { + const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Moscow', year: 'numeric', month: '2-digit', day: '2-digit' }); + const parts = Object.fromEntries(formatter.formatToParts(new Date(value)).map(({ type, value }) => [type, value])); + return `${parts.year}-${parts.month}-${parts.day}`; + }, + + isMonth(report) { + const from = this.dateKey(report.period_from), to = this.dateKey(report.period_to); + const [year, month, day] = from.split('-').map(Number); + return day === 1 && to === new Date(Date.UTC(year, month, 1)).toISOString().slice(0, 10); + }, + + periods() { + return this.list.filter((report) => this.isMonth(report) === (this.mode === 'months')); + }, + + setMode(mode) { + if (!['weeks', 'months'].includes(mode) || this.mode === mode) return; + this.mode = mode; + this.render(); + }, + + periodLabel(report) { + if (this.mode !== 'months') return report.label.replace(/[–—]/g, '-'); + const label = new Intl.DateTimeFormat('ru-RU', { timeZone: 'Europe/Moscow', month: 'long', year: 'numeric' }).format(new Date(report.period_from)).replace(/\s*г\.$/, ''); + return label[0].toLocaleUpperCase('ru-RU') + label.slice(1); + }, + + async open() { + await WeeklyDashboard.load(); + }, + + async load() { + const requestId = ++WeeklyDashboard.requestId; + WeeklyDashboard.loading = true; + WeeklyDashboard.error = ''; + WeeklyDashboard.render(); + try { + const r = await api(WeeklyDashboard.readOnly ? '/share/weekly-reports' : ReportClinic.q('/admin/weekly-reports')); + if (requestId !== WeeklyDashboard.requestId) return; + WeeklyDashboard.list = Array.isArray(r.data) ? r.data : []; + } catch (e) { + if (requestId !== WeeklyDashboard.requestId) return; + WeeklyDashboard.error = e.message || 'Не удалось загрузить отчёты'; + toast(WeeklyDashboard.error, true); + } finally { + if (requestId === WeeklyDashboard.requestId) { + WeeklyDashboard.loading = false; + WeeklyDashboard.render(); + } + } + }, + + async setBudget(reportId, channel, raw) { + const amount = Number(String(raw).trim().replace(',', '.')); + if (!Number.isFinite(amount) || amount < 0) { + toast('Бюджет должен быть неотрицательным числом', true); + WeeklyDashboard.render(); + return false; + } + try { + await api(`/admin/weekly-reports/${reportId}/budget`, { method: 'PUT', body: JSON.stringify({ channel, amount }) }); + const saved = WeeklyDashboard.list.find((r) => r.id === reportId)?.channels.find((c) => c.key === channel); + if (saved) saved.budget = amount; + WeeklyDashboard.render(); + return true; + } catch (e) { + toast(e.message, true); + WeeklyDashboard.render(); + return false; + } + }, + + async remove(id) { + if (!confirm('Удалить этот период из дашборда?')) return; + try { + await api('/admin/weekly-reports/' + id, { method: 'DELETE' }); + toast('Период удалён'); + WeeklyDashboard.load(); + } catch (e) { toast(e.message, true); } + }, + + /* ---------- фильтр по датам ---------- */ + filtered() { + const from = $('#wk-filter-from').value, to = $('#wk-filter-to').value; + const periods = WeeklyDashboard.periods(); + if (!from && !to) return periods; + return periods.filter((r) => { + const pf = WeeklyDashboard.dateKey(r.period_from), pt = WeeklyDashboard.dateKey(r.period_to); + // Storage uses Moscow midnight and an exclusive period_to (next day). + if (from && pt <= from) return false; + if (to && pf > to) return false; + return true; + }); + }, + + /* ---------- таблица ---------- */ + render() { + const all = WeeklyDashboard.periods(); + const list = WeeklyDashboard.filtered(); + $('#wk-title').textContent = all.length + ? `Периодов в дашборде: ${all.length}` + (list.length !== all.length ? ` · показано: ${list.length}` : '') + : 'Периодов пока нет'; + $('#wk-sub').textContent = all.length + ? `С ${all[0].label} по ${all[all.length - 1].label}` + : 'Периоды появляются сами: загрузите выгрузку 1С на вкладке «Новый отчёт» — если в ней есть строка «Период ДД.ММ.ГГГГ - ДД.ММ.ГГГГ», результат попадёт и сюда.'; + $('#wk-filter').hidden = all.length < 2; + // Keep source names/icons visible even if this mode/filter has no periods. + $('#wk-board').hidden = false; + + const sources = WeeklySourceLayout.arrange(WeeklyDashboard.list); + const order = sources.map((source) => source.key); + const titleByKey = Object.fromEntries(sources.map((source) => [source.key, source.title])); + + const fmtN = (v) => Math.round(v || 0).toLocaleString('ru-RU'); + const fmtRub = (v) => '₽ ' + (v || 0).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + const fmtPct = (v) => (v || 0).toFixed(1).replace('.', ',') + '%'; + const fmtF = (v) => (v || 0).toFixed(2).replace('.', ','); + const fmtTime = (sec) => { sec = sec || 0; const m = Math.floor(sec / 60), s = sec % 60; return m + ':' + String(s).padStart(2, '0'); }; + + const table = $('#wk-table'); + table.textContent = ''; + const cols = el('colgroup'); + for (const name of ['channel', 'metric', ...list.map(() => 'period')]) cols.append(el('col', { className: 'wk-col--' + name })); + table.append(cols); + const thead = el('thead'); + const hr = el('tr'); + const brand = el('th', { className: 'wk-brand', colSpan: '2' }); + brand.append(el('span', { textContent: ReportClinic.brand }), el('small', { textContent: 'Маркетинговая аналитика' })); + hr.append(brand); + for (const rep of list) { + const th = el('th', { className: 'wk-period' }); + th.append(el('span', { textContent: WeeklyDashboard.periodLabel(rep), title: rep.label })); + if (!WeeklyDashboard.readOnly) { + const del = el('button', { type: 'button', className: 'wk-del', title: 'Удалить период', ariaLabel: `Удалить период ${rep.label}`, textContent: '×' }); + del.onclick = () => WeeklyDashboard.remove(rep.id); + th.append(del); + } + hr.append(th); + } + thead.append(hr); + table.append(thead); + + const tbody = el('tbody'); + // Original service icons are served locally, without redrawing or recolouring. + const sourceMarks = { + 'сайт': ['#83b8a2', 'website', 'svg'], + '2гис': ['#17a623', '2gis'], '2gis': ['#17a623', '2gis'], + 'яндекс': ['#ff4025', 'yandex'], 'яндекскарточка': ['#ff4025', 'yandex'], + 'продокторов': ['#3581fa', 'prodoctorov'], + 'напоправку': ['#ffb900', 'napopravku'], + 'сберздоровье': ['#20b7c5', 'sber'], + zoon: ['#6934ff', 'zoon'], 'докту': ['#12a344', 'doctu', 'svg'], + 'инстаграм': ['#e4409b', 'instagram'], 'инстаграмм': ['#e4409b', 'instagram'], instagram: ['#e4409b', 'instagram'], + 'вк': ['#0077ff', 'vk'], 'вконтакте': ['#0077ff', 'vk'], vk: ['#0077ff', 'vk'], + telegram: ['#26a5e4', 'telegram'], 'телеграм': ['#26a5e4', 'telegram'], + google: ['#4285f4', 'google'], max: ['#7354ff', 'max'], + }; + const utilityMarks = { + phone: '', + website: '', + internet: '', + banner: '', + tv: '', + route: '', + radio: '', + print: '', + video: '', + chat: '', + generic: '', + }; + const utilityKeys = { + 'сайт': 'website', 'итогоинтернет': 'internet', 'баннер': 'banner', 'баннера': 'banner', 'баннераимаршрутки': 'banner', + 'тв': 'tv', 'маршрутки': 'route', 'радио': 'radio', 'полиграфия': 'print', 'ролик': 'video', + 'медийка': 'video', 'вотсапрассылки': 'chat', + }; + const utilityColors = { phone: '#93bfa7', website: '#83b8a2', internet: '#6d9de0', banner: '#e89b3c', tv: '#9a83cb', route: '#6b9dbc', radio: '#d8a47d', print: '#8598a9', video: '#719dcc', chat: '#5daf83', generic: '#6d9de0' }; + const sourceTint = (color) => { + const match = /^#([\da-f]{6})$/i.exec(color || ''); + if (!match) return ''; + const rgb = [0, 2, 4].map((offset) => parseInt(match[1].slice(offset, offset + 2), 16)); + return `#${rgb.map((value) => Math.round(value + (255 - value) * .955).toString(16).padStart(2, '0')).join('')}`; + }; + // Номера источников — как в таблице «Показатели Дана», лист своей клиники (подпись под названием канала). + const ochevidnoNumbers = { + 'сайт': 'Звонок +79285300063\nВотсап +79285300063', + '2гис': 'Звонок +79285140077\nВотсап +79285140077', + 'яндекскарточка': 'Звонок +79285300213\nВотсап +79285300213', + 'продокторов': '+74993254182', + 'напоправку': '+74993254273', + 'сберздоровье': '+74993254221', + 'zoon': '+74993254228', + 'тв': '+79285300068', + 'баннераимаршрутки': '+79285300066', + 'инстаграм': 'Instagram Direct\nВотсап +79285300077', + }; + const danaNumbers = { + 'сайт': 'Звонок +79289620094\nВотсап +79289620094\nЗаявки с сайта', + '2гис': 'Звонок 89285440094\nВотсап 89285440094', + 'яндекскарточка': 'Звонок 89285088306\nВотсап 89285088306', + 'продокторов': '+74993256643', + 'напоправку': '+74993256599', + 'сберздоровье': '+74993256736', + 'zoon': '+74993256585', + 'докту': '+74993256742', + 'тв': '+7 499 325-72-04', + 'радио': '+7 499 325-49-40', + 'баннера': '89282970055', + 'инстаграм': 'Звонок\n+74993253118 — Эстетик\n+74993253092 — Гинекология\n+74993253084 — Остео\n+74993253079 — Дети\n+74993253016 — Дана медикал\nВотсап\n89280470055', + 'маршрутки': '+7 499 325-69-18', + 'полиграфия': '+7 499 325-70-82', + }; + const sourceNumbers = ReportClinic.key === 'ochevidno' ? ochevidnoNumbers : danaNumbers; + const channelCell = (title, kind, rows, sourceKey) => { + const normalizedKey = WeeklySourceLayout.key(sourceKey); + const titleKey = WeeklySourceLayout.key(title); + let brand = sourceMarks[normalizedKey] || sourceMarks[titleKey]; + // канал «Сайт» — значок сайта своей клиники (у «Очевидно» — с ochevidno.ru); нет значка — общая иконка + const siteLogo = { dana: '/favicon.ico?v=20261005-dana-site-icon', ochevidno: 'assets/channels/ochevidno-site.png?v=20261014' }[ReportClinic.key]; + if (brand && brand[1] === 'website') brand = siteLogo ? (ReportClinic.key === 'ochevidno' ? ['#26365a', 'website'] : brand) : null; + const td = el('td', { className: `wk-channel wk-channel--${kind}`, rowSpan: String(rows) }); + // The reference uses cream for 2GIS and a barely tinted blush for Yandex. + if (brand && kind !== 'pin' && kind !== 'ya') td.style.setProperty('--wk-tint', sourceTint(brand[0])); + const icon = el('span', { className: `wk-channel__icon wk-channel__icon--${brand ? 'brand' : 'utility'}` }); + icon.setAttribute('aria-hidden', 'true'); + if (brand) { + icon.style.setProperty('--wk-source-color', brand[0]); + const mark = el('img', { + src: brand[1] === 'website' ? siteLogo : `assets/channels/${brand[1]}.${brand[2] || 'png'}?v=20261002-original-icons`, + alt: '', width: 32, height: 32, decoding: 'async', + }); + if (brand[1] === 'doctu') { + icon.style.width = '84px'; + mark.style.width = '84px'; + mark.width = 84; + } + icon.append(mark); + } else { + const name = utilityKeys[normalizedKey] || utilityKeys[titleKey] || (kind === 'web' ? 'website' : kind === 'phone' ? 'phone' : 'generic'); + td.style.setProperty('--wk-accent', utilityColors[name]); + if (kind !== 'phone' && kind !== 'web') td.style.setProperty('--wk-tint', sourceTint(utilityColors[name])); + icon.dataset.mark = name; + if (name === 'banner') { + icon.classList.add('wk-channel__icon--banner'); + td.style.setProperty('--wk-accent', '#e89b3c'); + td.style.setProperty('--wk-tint', sourceTint('#e89b3c')); + } + icon.innerHTML = ``; + } + td.append(icon, el('strong', { className: 'wk-channel__title', textContent: title })); + // У колл-центра подписи нет; у источников — их номера (если известны), иначе общая надпись. + if (kind !== 'phone') td.append(el('small', { className: 'wk-channel__hint', textContent: sourceNumbers[normalizedKey] || sourceNumbers[titleKey] || 'Канал обращений' })); + return td; + }; + const metricRow = (label, values, channel, channelKind, channelRows, sourceKey, heat, rule, reference, metric) => { + const tr = el('tr'); + if (channel) tr.append(channelCell(channel, channelKind, channelRows, sourceKey)); + tr.append(el('td', { className: 'wk-metric', textContent: label })); + const colors = heat ? WeeklyExcelFormatting.colors(heat, rule, reference, list.map((r) => r.label), all.map((r) => r.label)) : []; + const fill = WeeklyExcelFormatting.fill(sourceKey, metric); + for (const [i, v] of values.entries()) { + const td = el('td', { className: `wk-value${colors[i] ? ' wk-heat' : ''}`, textContent: v }); + if (colors[i]) { + td.style.setProperty('--wk-heat', colors[i]); + td.title = `Как в Excel: минимум → медиана (50-й процентиль) → максимум. Диапазон: вся история этого показателя и канала. Правило: ${rule.range}`; + } else if (fill && v !== '—' && v !== '') { + td.classList.add('wk-fill'); + td.style.background = fill.color; + td.title = `Заливка строки из Excel: ${fill.range}. Без градиента по значениям.`; + } + tr.append(td); + } + return tr; + }; + const appendGap = () => { + const gap = el('tr', { className: 'wk-gap', ariaHidden: 'true' }); + gap.append(el('td', { colSpan: String(list.length + 2) })); + tbody.append(gap); + }; + // У недель, добавленных из внешней таблицы, колл-центра нет (в базе пустой объект) — показываем «—», а не нули. + const hasCC = (r) => ['total', 'incoming', 'outgoing', 'unique', 'patients', 'revenue'].some((k) => (r.callcenter && r.callcenter[k]) > 0); + const cc = (format) => (r) => hasCC(r) ? format(r) : '—'; + const ccHeat = (pick) => (r) => hasCC(r) ? pick(r) : NaN; + const ccRows = [ + ['Всего звонков', cc((r) => fmtN(r.callcenter.total))], + ['Входящих', cc((r) => fmtN(r.callcenter.incoming))], + ['Исходящих', cc((r) => fmtN(r.callcenter.outgoing))], + ['Не принятых', cc((r) => fmtN(r.callcenter.missed))], + ['Доля пропущенных', cc((r) => fmtPct(r.callcenter.missed_pct))], + ['Уникальных позвонивших', cc((r) => fmtN(r.callcenter.unique)), ccHeat((r) => r.callcenter.unique), 'unique'], + ['Количество пациентов', cc((r) => fmtN(r.callcenter.patients))], + ['Реализованных услуг', cc((r) => fmtN(r.callcenter.services))], + ['Выручка', cc((r) => fmtRub(r.callcenter.revenue)), ccHeat((r) => r.callcenter.revenue), 'revenue'], + ['Средний чек', cc((r) => fmtRub(r.callcenter.avg_check))], + ['Среднее время разговора', cc((r) => fmtTime(r.callcenter.talk_seconds))], + ['Среднее ожидание ответа', cc((r) => fmtTime(r.callcenter.wait_seconds))], + ]; + ccRows.forEach(([label, format, heat, metric], i) => tbody.append(metricRow(label, list.map(format), i === 0 ? 'Колл-центр' : null, 'phone', ccRows.length, 'callcenter', heat ? list.map(heat) : null, WeeklyExcelFormatting.rule('callcenter', metric), heat ? all.map(heat) : null, metric))); + + if (order.length) appendGap(); + + for (const key of order) { + const byRep = list.map((rep) => rep.channels.find((c) => c.key === key)); + const allByRep = all.map((rep) => rep.channels.find((c) => c.key === key)); + const rows = 8; + const kind = /все номера|колл/i.test(titleByKey[key]) ? 'phone' + : /2гис/i.test(titleByKey[key]) ? 'pin' : /яндекс/i.test(titleByKey[key]) ? 'ya' + : /сайт/i.test(titleByKey[key]) ? 'web' : 'source'; + const rowsData = [ + ['Лиды', (c) => c ? fmtN(c.leads) : '—', (c) => c ? c.leads : NaN, 'leads'], + ['Количество пациентов', (c) => c ? fmtN(c.patients) : '—'], + ['Конверсия звонка в пациента', (c) => c && c.leads ? fmtPct((c.patients / c.leads) * 100) : '—', null, 'conversion'], + ['Количество реализованных услуг', (c) => c ? fmtN(c.services) : '—'], + ['Среднее кол. услуг/пациент', (c) => c && c.patients ? fmtF(c.services / c.patients) : '—'], + ['Выручка', (c) => c ? fmtRub(c.revenue) : '—', (c) => c ? c.revenue : NaN, 'revenue'], + ['Средний чек', (c) => c ? fmtRub(c.avg_check) : '—'], + ]; + rowsData.forEach(([label, format, heat, metric], i) => tbody.append(metricRow(label, byRep.map(format), i === 0 ? titleByKey[key] : null, kind, rows, key, heat ? byRep.map(heat) : null, WeeklyExcelFormatting.rule(key, metric), heat ? allByRep.map(heat) : null, metric))); + const budgetRow = el('tr'); + budgetRow.append(el('td', { className: 'wk-metric', textContent: 'Бюджет' })); + byRep.forEach((c, i) => { + const td = el('td', { className: 'wk-value' }); + if (c && key !== 'итого-интернет' && WeeklyDashboard.readOnly) { + const fill = WeeklyExcelFormatting.fill(key, 'budget'); + if (fill) td.style.background = fill.color; + td.textContent = c.budget ? fmtRub(c.budget) : '—'; + } else if (c && key !== 'итого-интернет') { + const fill = WeeklyExcelFormatting.fill(key, 'budget'); + if (fill) td.style.background = fill.color; + const inp = el('input', { type: 'number', min: '0', step: '0.01', value: c.budget ?? '', placeholder: '—', className: 'wk-budget', ariaLabel: `Бюджет: ${titleByKey[key]}, ${list[i].label}` }); + inp.onchange = async () => { + inp.disabled = true; + await WeeklyDashboard.setBudget(list[i].id, key, inp.value); + }; + td.append(inp); + } else { + td.textContent = '—'; + } + budgetRow.append(td); + }); + tbody.append(budgetRow); + if (key !== order.at(-1)) { + appendGap(); + } + } + table.append(tbody); + if (typeof WeeklyFullscreen !== 'undefined') WeeklyFullscreen.sync(); + }, +}; + +$('#wk-filter-from').onchange = () => WeeklyDashboard.render(); +$('#wk-filter-to').onchange = () => WeeklyDashboard.render(); +$('#wk-filter-reset').onclick = () => { + $('#wk-filter-from').value = ''; + $('#wk-filter-to').value = ''; + WeeklyDashboard.render(); +}; diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..a3474f3 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,327 @@ +package api + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "reflect" + "strings" + "time" + + "dana/internal/bitrix" + "dana/internal/megafon" + "dana/internal/notify" + "dana/internal/onec" + "dana/internal/prodoctorov" + "dana/internal/sberhealth" + "dana/internal/storage" +) + +type API struct { + store *storage.Storage + mediaDir string + notifier notify.Notifier + website *website + pbx *megafon.Client + pbxClinics map[string]*megafon.Client // АТС других клиник (clinics.go) + onec *onec.Client + onecHook string + onecEvery time.Duration + onecLongPoll bool + bitrix *bitrix.Client + smscLogin string + smscPassword string + smscCallbackToken string + prodoctorov *prodoctorov.Client + sberhealth *sberhealth.Client + limiter *rateLimiter + confirmKick chan struct{} // будит подтверждение записей (booking_confirm.go) +} + +// WithBitrix включает разовую выгрузку номеров из Битрикс24 для «Отчёта по каналам». +// Без вебхука кнопка в отчёте просто не предлагается. +func (a *API) WithBitrix(c *bitrix.Client) { a.bitrix = c } + +func (a *API) WithSMSC(login, password string) { a.smscLogin = login; a.smscPassword = password } + +// WithSMSCCallbackToken включает приём обратных вызовов SMSC: без токена в адресе они отклоняются. +func (a *API) WithSMSCCallbackToken(t string) { a.smscCallbackToken = strings.TrimSpace(t) } + +func New(store *storage.Storage, mediaDir string) *API { + return &API{store: store, mediaDir: mediaDir, limiter: newRateLimiter(), confirmKick: make(chan struct{}, 1)} +} + +func (a *API) Routes() http.Handler { + mux := http.NewServeMux() + if a.website != nil { + mux.HandleFunc("GET /robots.txt", a.robots) + mux.HandleFunc("GET /sitemap.xml", a.sitemap) + mux.HandleFunc("GET /", a.websitePage) + } + + mux.HandleFunc("GET /api/v1/health", a.health) + mux.HandleFunc("GET /api/v1/services", a.services) + mux.HandleFunc("GET /api/v1/services/{path...}", a.service) + mux.HandleFunc("GET /api/v1/specialties", a.specialties) + mux.HandleFunc("GET /api/v1/doctors", a.doctors) + mux.HandleFunc("GET /api/v1/doctors/{slug}", a.doctor) + mux.HandleFunc("GET /api/v1/doctors/{slug}/slots", a.doctorSlots) + mux.HandleFunc("GET /api/v1/prices", a.prices) + mux.HandleFunc("GET /api/v1/price-sections", a.priceSections) + + mux.HandleFunc("GET /api/v1/checkups", a.checkups) + mux.HandleFunc("GET /api/v1/checkups/{slug}", a.checkup) + + mux.HandleFunc("GET /api/v1/news", a.posts("news")) + mux.HandleFunc("GET /api/v1/news/{slug}", a.post("news")) + mux.HandleFunc("GET /api/v1/articles", a.posts("article")) + mux.HandleFunc("GET /api/v1/articles/{slug}", a.post("article")) + mux.HandleFunc("GET /api/v1/promos", a.posts("promo")) + mux.HandleFunc("GET /api/v1/promos/{slug}", a.post("promo")) + + mux.HandleFunc("GET /api/v1/reviews", a.reviews) + mux.HandleFunc("GET /api/v1/faq", a.faq) + mux.HandleFunc("GET /api/v1/documents", a.documents) + mux.HandleFunc("GET /api/v1/partners", a.partners) + mux.HandleFunc("GET /api/v1/vacancies", a.vacancies) + mux.HandleFunc("GET /api/v1/branches", a.branches) + mux.HandleFunc("GET /api/v1/gallery", a.gallery) + mux.HandleFunc("GET /api/v1/settings", a.settings) + mux.HandleFunc("GET /api/v1/pages/{slug}", a.page) + mux.HandleFunc("GET /api/v1/banners", a.banners) + mux.HandleFunc("GET /api/v1/search", a.search) + + // Формы и заявки с сайта. + mux.HandleFunc("GET /api/v1/forms", a.forms) + mux.HandleFunc("POST /api/v1/leads", a.createLead) + mux.HandleFunc("POST /api/v1/appointments", a.createAppointment) + mux.HandleFunc("POST /api/v1/auth/wait-call/init", a.waitCallInit) + mux.HandleFunc("GET /api/v1/auth/wait-call/status", a.waitCallStatus) + mux.HandleFunc("POST /api/v1/auth/smsc-callback", a.smscCallback) + mux.HandleFunc("GET /api/v1/auth/smsc-callback", a.smscCallback) + + // --- админка --- + mux.HandleFunc("POST /api/v1/admin/login", a.adminLogin) + mux.HandleFunc("POST /api/v1/admin/logout", a.adminLogout) + mux.HandleFunc("GET /api/v1/admin/me", a.requireAuth(a.adminMe)) + + mux.HandleFunc("GET /api/v1/admin/services", a.requireAuth(a.adminServices)) + mux.HandleFunc("POST /api/v1/admin/services", a.requireAuth(a.adminCreateService)) + mux.HandleFunc("PUT /api/v1/admin/services/{id}", a.requireAuth(a.adminUpdateService)) + + mux.HandleFunc("POST /api/v1/onec/events", a.onecEvents) + mux.HandleFunc("GET /api/v1/admin/onec", a.requireAuth(a.adminOnec)) + mux.HandleFunc("POST /api/v1/admin/onec/sync", a.requireAuth(a.adminOnecSync)) + mux.HandleFunc("PUT /api/v1/admin/onec/doctors/{onec_id}/link", a.requireAuth(a.adminOnecLink)) + mux.HandleFunc("PUT /api/v1/admin/onec/doctors/{onec_id}/booking", a.requireAuth(a.adminOnecBooking)) + mux.HandleFunc("GET /api/v1/admin/onec/grid", a.requireAuth(a.adminOnecGrid)) + mux.HandleFunc("GET /api/v1/admin/onec/appointments", a.requireAuth(a.adminOnecAppointments)) + mux.HandleFunc("GET /api/v1/admin/booking-settings", a.requireAuth(a.adminBookingSettings)) + mux.HandleFunc("PUT /api/v1/admin/booking-settings", a.requireAuth(a.adminUpdateBookingSettings)) + + mux.HandleFunc("GET /api/v1/admin/leads", a.requireAuth(a.adminLeads)) + mux.HandleFunc("PUT /api/v1/admin/leads/{id}", a.requireAuth(a.adminUpdateLead)) + + mux.HandleFunc("GET /api/v1/admin/reviews", a.requireAuth(a.adminReviews)) + mux.HandleFunc("POST /api/v1/admin/reviews", a.requireAuth(a.adminCreateReview)) + mux.HandleFunc("PUT /api/v1/admin/reviews/{id}", a.requireAuth(a.adminUpdateReview)) + mux.HandleFunc("DELETE /api/v1/admin/reviews/{id}", a.requireAuth(a.adminDeleteReview)) + + mux.HandleFunc("GET /api/v1/admin/banners", a.requireAuth(a.adminBanners)) + mux.HandleFunc("POST /api/v1/admin/banners", a.requireAuth(a.adminCreateBanner)) + mux.HandleFunc("PUT /api/v1/admin/banners/{id}", a.requireAuth(a.adminUpdateBanner)) + mux.HandleFunc("DELETE /api/v1/admin/banners/{id}", a.requireAuth(a.adminDeleteBanner)) + mux.HandleFunc("POST /api/v1/admin/media", a.requireAuth(a.adminUploadMedia)) + + mux.HandleFunc("GET /api/v1/admin/doctors", a.requireAuth(a.adminDoctors)) + mux.HandleFunc("GET /api/v1/admin/doctors-quality", a.requireAuth(a.adminDoctorQuality)) + mux.HandleFunc("POST /api/v1/admin/doctors", a.requireAuth(a.adminCreateDoctor)) + mux.HandleFunc("PUT /api/v1/admin/doctors/{id}", a.requireAuth(a.adminUpdateDoctor)) + mux.HandleFunc("GET /api/v1/admin/specialties", a.requireAuth(a.adminSpecialties)) + mux.HandleFunc("POST /api/v1/admin/specialties", a.requireAuth(a.adminCreateSpecialty)) + mux.HandleFunc("PUT /api/v1/admin/specialties/{id}", a.requireAuth(a.adminUpdateSpecialty)) + mux.HandleFunc("DELETE /api/v1/admin/specialties/{id}", a.requireAuth(a.adminDeleteSpecialty)) + mux.HandleFunc("PUT /api/v1/admin/doctors/{id}/links", a.requireAuth(a.adminSetDoctorLinks)) + mux.HandleFunc("GET /api/v1/admin/doctors/{id}/bio", a.requireAuth(a.adminBioEntries)) + mux.HandleFunc("POST /api/v1/admin/bio-entries", a.requireAuth(a.adminCreateBioEntry)) + mux.HandleFunc("PUT /api/v1/admin/bio-entries/{id}", a.requireAuth(a.adminUpdateBioEntry)) + mux.HandleFunc("DELETE /api/v1/admin/bio-entries/{id}", a.requireAuth(a.adminDeleteBioEntry)) + + mux.HandleFunc("GET /api/v1/admin/price-sections", a.requireAuth(a.adminPriceSections)) + mux.HandleFunc("GET /api/v1/admin/price-items", a.requireAuth(a.adminPriceItems)) + mux.HandleFunc("POST /api/v1/admin/price-items", a.requireAuth(a.adminCreatePriceItem)) + mux.HandleFunc("PUT /api/v1/admin/price-items/{id}", a.requireAuth(a.adminUpdatePriceItem)) + mux.HandleFunc("DELETE /api/v1/admin/price-items/{id}", a.requireAuth(a.adminDeletePriceItem)) + + mux.HandleFunc("GET /api/v1/admin/promos", a.requireAuth(a.adminPromos)) + mux.HandleFunc("POST /api/v1/admin/promos", a.requireAuth(a.adminCreatePromo)) + mux.HandleFunc("PUT /api/v1/admin/promos/{id}", a.requireAuth(a.adminUpdatePromo)) + mux.HandleFunc("DELETE /api/v1/admin/promos/{id}", a.requireAuth(a.adminDeletePromo)) + + mux.HandleFunc("GET /api/v1/admin/checkups", a.requireAuth(a.adminCheckups)) + mux.HandleFunc("POST /api/v1/admin/checkups", a.requireAuth(a.adminCreateCheckup)) + mux.HandleFunc("PUT /api/v1/admin/checkups/{id}", a.requireAuth(a.adminUpdateCheckup)) + + // Новости (/admin/news) и статьи (/admin/articles) — одинаковые обработчики с разным видом записи. + for section, kind := range postKinds { + base := "/api/v1/admin/" + section + mux.HandleFunc("GET "+base, a.requireAuth(a.adminPosts(kind))) + mux.HandleFunc("POST "+base, a.requireAuth(a.adminCreatePost(kind))) + mux.HandleFunc("PUT "+base+"/{id}", a.requireAuth(a.adminUpdatePost(kind))) + mux.HandleFunc("DELETE "+base+"/{id}", a.requireAuth(a.adminDeletePost(kind))) + } + + mux.HandleFunc("GET /api/v1/admin/vacancies", a.requireAuth(a.adminVacancies)) + mux.HandleFunc("POST /api/v1/admin/vacancies", a.requireAuth(a.adminCreateVacancy)) + mux.HandleFunc("PUT /api/v1/admin/vacancies/{id}", a.requireAuth(a.adminUpdateVacancy)) + mux.HandleFunc("DELETE /api/v1/admin/vacancies/{id}", a.requireAuth(a.adminDeleteVacancy)) + + mux.HandleFunc("GET /api/v1/admin/documents", a.requireAuth(a.adminDocuments)) + mux.HandleFunc("POST /api/v1/admin/documents", a.requireAuth(a.adminCreateDocument)) + mux.HandleFunc("PUT /api/v1/admin/documents/{id}", a.requireAuth(a.adminUpdateDocument)) + mux.HandleFunc("DELETE /api/v1/admin/documents/{id}", a.requireAuth(a.adminDeleteDocument)) + + mux.HandleFunc("GET /api/v1/admin/partners", a.requireAuth(a.adminPartners)) + mux.HandleFunc("POST /api/v1/admin/partners", a.requireAuth(a.adminCreatePartner)) + mux.HandleFunc("PUT /api/v1/admin/partners/{id}", a.requireAuth(a.adminUpdatePartner)) + mux.HandleFunc("DELETE /api/v1/admin/partners/{id}", a.requireAuth(a.adminDeletePartner)) + + mux.HandleFunc("GET /api/v1/admin/dashboard", a.requireAuth(a.adminDashboard)) + mux.HandleFunc("GET /api/v1/admin/stats", a.requireAuth(a.adminStats)) + mux.HandleFunc("GET /api/v1/admin/clinics", a.requireAuth(a.adminClinics)) + mux.HandleFunc("GET /api/v1/admin/share-link", a.requireAuth(a.adminShareLink)) + mux.HandleFunc("POST /api/v1/admin/share-link", a.requireAuth(a.adminCreateShareLink)) + mux.HandleFunc("DELETE /api/v1/admin/share-link", a.requireAuth(a.adminDeleteShareLink)) + mux.HandleFunc("GET /api/v1/share/weekly-reports", a.shareWeeklyReports) + mux.HandleFunc("GET /api/v1/admin/pbx/status", a.requireAuth(a.adminPBXStatus)) + mux.HandleFunc("POST /api/v1/admin/pbx/check", a.requireAuth(a.adminPBXCheck)) + mux.HandleFunc("POST /api/v1/admin/pbx/sync", a.requireAuth(a.adminPBXSync)) + mux.HandleFunc("GET /api/v1/admin/pbx/lines", a.requireAuth(a.adminPBXLines)) + mux.HandleFunc("PUT /api/v1/admin/pbx/lines", a.requireAuth(a.adminSetPBXLine)) + mux.HandleFunc("GET /api/v1/admin/pbx/report", a.requireAuth(a.adminPBXReport)) + mux.HandleFunc("GET /api/v1/admin/reports", a.requireAuth(a.adminReports)) + mux.HandleFunc("POST /api/v1/admin/reports", a.requireAuth(a.adminCreateReport)) + mux.HandleFunc("POST /api/v1/admin/reports/period", a.requireAuth(a.adminReportPeriod)) + mux.HandleFunc("GET /api/v1/admin/reports/{id}", a.requireAuth(a.adminReport)) + mux.HandleFunc("POST /api/v1/admin/reports/{id}/cancel", a.requireAuth(a.adminCancelReport)) + mux.HandleFunc("DELETE /api/v1/admin/reports/{id}", a.requireAuth(a.adminDeleteReport)) + mux.HandleFunc("GET /api/v1/admin/reports/{id}/download", a.requireAuth(a.adminDownloadReport)) + mux.HandleFunc("GET /api/v1/admin/weekly-reports", a.requireAuth(a.adminWeeklyReports)) + mux.HandleFunc("DELETE /api/v1/admin/weekly-reports/{id}", a.requireAuth(a.adminDeleteWeeklyReport)) + mux.HandleFunc("PUT /api/v1/admin/weekly-reports/{id}/budget", a.requireAuth(a.adminSetWeeklyBudget)) + mux.HandleFunc("GET /api/v1/admin/redirects", a.requireAuth(a.adminRedirects)) + mux.HandleFunc("POST /api/v1/admin/redirects", a.requireAuth(a.adminCreateRedirect)) + mux.HandleFunc("DELETE /api/v1/admin/redirects/{id}", a.requireAuth(a.adminDeleteRedirect)) + mux.HandleFunc("DELETE /api/v1/admin/not-found", a.requireAuth(a.adminDeleteNotFound)) + + mux.HandleFunc("GET /api/v1/admin/pages", a.requireAuth(a.adminPages)) + mux.HandleFunc("PUT /api/v1/admin/pages/{id}", a.requireAuth(a.adminUpdatePage)) + + mux.HandleFunc("GET /api/v1/admin/settings", a.requireAuth(a.adminSettings)) + mux.HandleFunc("PUT /api/v1/admin/settings", a.requireAuth(a.adminUpdateSettings)) + + mux.HandleFunc("GET /api/v1/admin/branches", a.requireAuth(a.adminBranches)) + mux.HandleFunc("POST /api/v1/admin/branches", a.requireAuth(a.adminCreateBranch)) + mux.HandleFunc("PUT /api/v1/admin/branches/{id}", a.requireAuth(a.adminUpdateBranch)) + + mux.HandleFunc("GET /api/v1/admin/faq", a.requireAuth(a.adminFAQ)) + mux.HandleFunc("POST /api/v1/admin/faq", a.requireAuth(a.adminCreateFAQ)) + mux.HandleFunc("PUT /api/v1/admin/faq/{id}", a.requireAuth(a.adminUpdateFAQ)) + mux.HandleFunc("DELETE /api/v1/admin/faq/{id}", a.requireAuth(a.adminDeleteFAQ)) + + // Интерфейс админки (встроен в бинарник). + mux.Handle("GET /admin/", http.StripPrefix("/admin/", http.FileServer(http.FS(adminFS())))) + mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/admin/", http.StatusFound) + }) + + // Раздача загруженных картинок: /media/... -> файлы из mediaDir. + if a.mediaDir != "" { + mux.Handle("GET /media/", mediaHandler(a.mediaDir)) + } + + return logging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + // Заголовки безопасности на все ответы. CSP только frame-ancestors — + // защита от кликджекинга без риска сломать SPA (скрипты/стили не трогаем). + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "SAMEORIGIN") + // Безопасные директивы, не ломающие SPA: запрет плагинов, чужого , + // отправки форм на сторонние адреса и встраивания сайта в чужие фреймы. + // Полный script-src/style-src добавляется отдельно (сначала в Report-Only). + h.Set("Content-Security-Policy", "frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + h.Set("Strict-Transport-Security", "max-age=31536000") // браузер учитывает только по HTTPS + if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/admin" || strings.HasPrefix(r.URL.Path, "/admin/") { + h.Set("X-Robots-Tag", "noindex, nofollow") + } + mux.ServeHTTP(w, r) + })) +} + +type envelope struct { + Data any `json:"data"` + Meta map[string]any `json:"meta,omitempty"` +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(payload); err != nil { + slog.Error("encode response", "err", err) + } +} + +func ok(w http.ResponseWriter, data any, meta map[string]any) { + writeJSON(w, http.StatusOK, envelope{Data: emptyIfNil(data), Meta: meta}) +} + +// emptyIfNil превращает nil-срез в пустой: Go кодирует его как null, и фронт +// падал бы на пустой выборке (поиск без результатов, врачи без отзывов). +func emptyIfNil(data any) any { + v := reflect.ValueOf(data) + if v.Kind() == reflect.Slice && v.IsNil() { + return reflect.MakeSlice(v.Type(), 0, 0).Interface() + } + return data +} + +type apiError struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +func fail(w http.ResponseWriter, status int, code, message string) { + var e apiError + e.Error.Code = code + e.Error.Message = message + writeJSON(w, status, e) +} + +// handleErr переводит ошибки хранилища в ответ API. +func handleErr(w http.ResponseWriter, err error) { + if errors.Is(err, storage.ErrNotFound) { + fail(w, http.StatusNotFound, "not_found", "Не найдено") + return + } + if errors.Is(err, storage.ErrBadReference) { + fail(w, http.StatusUnprocessableEntity, "bad_reference", "Связанная запись не найдена, обновите страницу") + return + } + if errors.Is(err, storage.ErrConflict) { + fail(w, http.StatusConflict, "conflict", "Такой адрес уже занят другой записью") + return + } + slog.Error("request failed", "err", err) + fail(w, http.StatusInternalServerError, "internal", "Внутренняя ошибка") +} + +func logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + slog.Debug("request", "method", r.Method, "path", r.URL.Path) + next.ServeHTTP(w, r) + }) +} diff --git a/internal/api/booking_confirm.go b/internal/api/booking_confirm.go new file mode 100644 index 0000000..49e785a --- /dev/null +++ b/internal/api/booking_confirm.go @@ -0,0 +1,405 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "html" + "log/slog" + "net/http" + "strings" + "time" + + "dana/internal/bitrix" + "dana/internal/onec" + "dana/internal/storage" +) + +// Подтверждение онлайн-записи оператором. Запись сразу занимает окно в 1С, сайт заводит лид +// в Битрикс24 и раз в минуту смотрит его стадию: +// - успех («Клиент записался») — запись подтверждена; +// - провал (любая провальная стадия) — отменяем запись в 1С, окно освобождается; +// - решения нет к сроку (24 ч после записи или 2 ч до приёма, что раньше) — отменяем сами +// и переводим лид в «Клиент не записался». +// Работает, когда настроены и 1С, и вебхук Битрикса; иначе запись ведёт себя как раньше. + +const ( + confirmEvery = time.Minute + // confirmMinGrace — меньше этого оператору на звонок не даём, даже если до приёма совсем мало. + confirmMinGrace = 30 * time.Minute + // bitrixSiteSourceID — источник «Сайт danamedical.ru» в CRM клиники: отчёт по каналам относит его к «Сайту». + bitrixSiteSourceID = "112" +) + +type confirmAction int + +const ( + confirmWait confirmAction = iota // ждём оператора + confirmOK // лид в успешной стадии + confirmCancel // лид в провальной стадии — отменить в 1С + confirmExpire // срок вышел — отменить в 1С самим + confirmMissed // приём уже наступил — ничего не отменяем, просто перестаём ждать +) + +// errBookingLimit — подозрительная активность при выключенном SMSC: окно в 1С не занимаем, +// передаём заявку оператору (со SMSC в этом случае просим проверочный звонок). +var errBookingLimit = errors.New("лимит онлайн-записей (защита расписания от ботов)") + +// bookingRiskReason — почему запись выглядит подозрительной ("" — всё обычно). Срабатывание не отказ: +// сайт просит проверочный звонок, который настоящий пациент делает за минуту, а бот — нет. +func (a *API) bookingRiskReason(ctx context.Context, cfg storage.BookingSettings, doctorOnecID, phone string, start time.Time) (string, error) { + if cfg.MaxPerHour > 0 { + n, err := a.store.SiteBookingsSince(ctx, time.Now().Add(-time.Hour)) + if err != nil { + return "", err + } + if n >= cfg.MaxPerHour { + return fmt.Sprintf("за час %d онлайн-записей при лимите %d", n, cfg.MaxPerHour), nil + } + } + if cfg.MaxPerDoctorDay > 0 && a.bookingConfirmEnabled() { + d := start.In(moscow) + from := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, moscow) + n, err := a.store.WaitingForDoctor(ctx, doctorOnecID, from, from.AddDate(0, 0, 1)) + if err != nil { + return "", err + } + if n >= cfg.MaxPerDoctorDay { + return fmt.Sprintf("у врача на %s уже %d неподтверждённых при лимите %d", from.Format("02.01"), n, cfg.MaxPerDoctorDay), nil + } + } + if cfg.MaxActivePerPhone > 0 { + n, err := a.store.ActiveBookingsForPhone(ctx, lastDigits(phone, 10)) + if err != nil { + return "", err + } + if n >= cfg.MaxActivePerPhone { + return fmt.Sprintf("на номер уже %d будущих записей при лимите %d", n, cfg.MaxActivePerPhone), nil + } + } + return "", nil +} + +func (a *API) bookingConfirmEnabled() bool { + return a.bitrix.Configured() && a.onec.Configured() +} + +// kickConfirm будит фоновую проверку сразу после новой записи, чтобы лид появился без ожидания минуты. +func (a *API) kickConfirm() { + select { + case a.confirmKick <- struct{}{}: + default: + } +} + +// confirmDeadline — когда отменить неподтверждённую запись. ok=false — автоотмена выключена. +func confirmDeadline(created, start time.Time, cfg storage.BookingSettings) (deadline time.Time, ok bool) { + if cfg.AutoCancelHours <= 0 && cfg.CancelBeforeHours <= 0 { + return time.Time{}, false + } + if cfg.AutoCancelHours > 0 { + deadline = created.Add(time.Duration(cfg.AutoCancelHours) * time.Hour) + } + if cfg.CancelBeforeHours > 0 { + before := start.Add(-time.Duration(cfg.CancelBeforeHours) * time.Hour) + if deadline.IsZero() || before.Before(deadline) { + deadline = before + } + } + if earliest := created.Add(confirmMinGrace); deadline.Before(earliest) { + deadline = earliest + } + return deadline, true +} + +// decideConfirm — что делать с записью по стадии лида. semantic пустой — лид ещё в работе или удалён в CRM. +func decideConfirm(semantic string, created, start, now time.Time, cfg storage.BookingSettings) confirmAction { + if semantic == bitrix.SemanticSuccess { + return confirmOK + } + // после начала приёма запись в 1С не трогаем: её судьбу решает регистратура + if !now.Before(start) { + return confirmMissed + } + if semantic == bitrix.SemanticFailure { + return confirmCancel + } + if d, ok := confirmDeadline(created, start, cfg); ok && !now.Before(d) { + return confirmExpire + } + return confirmWait +} + +// RunBookingConfirm — фоновый цикл: заводит лиды и следит за их стадиями. +func (a *API) RunBookingConfirm(ctx context.Context) { + if !a.bookingConfirmEnabled() { + slog.Info("booking confirm disabled: нужны и 1С, и BITRIX_WEBHOOK_URL") + return + } + slog.Info("booking confirm enabled: онлайн-записи подтверждает оператор в Битрикс24") + t := time.NewTicker(confirmEvery) + defer t.Stop() + for { + c, cancel := context.WithTimeout(ctx, 2*time.Minute) + if err := a.processConfirmQueue(c, time.Now()); err != nil { + slog.Warn("booking confirm", "err", err) + } + cancel() + select { + case <-ctx.Done(): + return + case <-t.C: + case <-a.confirmKick: + } + } +} + +func (a *API) processConfirmQueue(ctx context.Context, now time.Time) error { + if err := a.sendLeadOnly(ctx); err != nil { + return err + } + cfg, err := a.store.BookingSettings(ctx) + if err != nil { + return fmt.Errorf("настройки записи: %w", err) + } + items, err := a.store.ConfirmQueue(ctx, 100) + if err != nil { + return err + } + if len(items) == 0 { + return nil + } + + // 1. Лиды для новых записей. Не получилось — запись ждёт следующей попытки и автоматически + // не отменяется: оператор её ещё не видел. + var ids []string + for i := range items { + it := &items[i] + if it.BitrixLeadID == "" { + id, err := a.bitrix.AddLead(ctx, leadDraft(*it, cfg)) + if err != nil { + slog.Warn("booking confirm: лид не создан", "appointment", it.ID, "err", err) + _ = a.store.SetConfirmError(ctx, it.ID, "Битрикс: "+err.Error()) + continue + } + if err := a.store.SetBitrixLead(ctx, it.ID, id); err != nil { + return err + } + it.BitrixLeadID = id + slog.Info("booking confirm: лид создан", "appointment", it.ID, "lead", id) + } + ids = append(ids, it.BitrixLeadID) + } + if len(ids) == 0 { + return nil + } + + // 2. Стадии лидов. Без ответа Битрикса ничего не отменяем: вдруг оператор уже подтвердил. + sem, err := a.bitrix.LeadSemantics(ctx, ids) + if err != nil { + return err + } + for _, it := range items { + if it.BitrixLeadID == "" { + continue + } + switch decideConfirm(sem[it.BitrixLeadID], it.CreatedAt, it.Start, now, cfg) { + case confirmOK: + a.finishConfirm(ctx, it, "confirmed") + case confirmMissed: + a.finishConfirm(ctx, it, "missed") + case confirmCancel: + if a.cancelUnconfirmed(ctx, it) { + a.finishConfirm(ctx, it, "cancelled") + a.leadNote(ctx, it, "Сайт отменил запись в 1С: лид переведён в провальную стадию. Окно у врача снова свободно.") + } + case confirmExpire: + if a.cancelUnconfirmed(ctx, it) { + a.finishConfirm(ctx, it, "expired") + if err := a.bitrix.FailLead(ctx, it.BitrixLeadID); err != nil { + slog.Warn("booking confirm: стадия лида не обновлена", "lead", it.BitrixLeadID, "err", err) + } + a.leadNote(ctx, it, "Запись не подтвердили вовремя — сайт отменил её в 1С и перевёл лид в «Клиент не записался». "+ + "Если пациент всё же придёт, запишите его заново.") + } + } + } + return nil +} + +// sendLeadOnly передаёт оператору заявки, которые не удалось записать в 1С (1С недоступна или +// отклонила запись). Подтверждать тут нечего — записи в 1С нет, оператор записывает вручную. +func (a *API) sendLeadOnly(ctx context.Context) error { + items, err := a.store.LeadOnlyQueue(ctx, 50) + if err != nil { + return err + } + for _, it := range items { + id, err := a.bitrix.AddLead(ctx, leadOnlyDraft(it)) + if err != nil { + slog.Warn("booking confirm: лид по заявке не создан", "appointment", it.ID, "err", err) + _ = a.store.SetConfirmError(ctx, it.ID, "Битрикс: "+err.Error()) + continue + } + if err := a.store.LeadOnlySent(ctx, it.ID, id); err != nil { + return err + } + slog.Info("booking confirm: заявка передана в Битрикс", "appointment", it.ID, "lead", id) + } + return nil +} + +func (a *API) finishConfirm(ctx context.Context, it storage.ConfirmItem, state string) { + if err := a.store.FinishBookingConfirm(ctx, it.ID, state); err != nil { + slog.Error("booking confirm: состояние не сохранено", "appointment", it.ID, "state", state, "err", err) + return + } + slog.Info("booking confirm", "appointment", it.ID, "lead", it.BitrixLeadID, "state", state) +} + +// cancelUnconfirmed отменяет запись в 1С. false — 1С не ответила, попробуем на следующем круге. +func (a *API) cancelUnconfirmed(ctx context.Context, it storage.ConfirmItem) bool { + if it.OnecID != "" { + err := a.onec.Cancel(ctx, it.OnecID) + // 404 1С возвращает как «не найден»: записи там уже нет — значит, и отменять нечего + if err != nil && !errors.Is(err, onec.ErrDoctorNotFound) { + slog.Warn("booking confirm: 1С не отменила запись", "appointment", it.ID, "err", err) + _ = a.store.SetConfirmError(ctx, it.ID, "1С: "+onecErrText(err)) + return false + } + } + if err := a.resyncOnecDoctor(ctx, it.DoctorOnec); err != nil { + slog.Warn("booking confirm: окна врача не обновились", "doctor", it.DoctorOnec, "err", err) + } + return true +} + +func (a *API) leadNote(ctx context.Context, it storage.ConfirmItem, text string) { + if err := a.bitrix.AddLeadComment(ctx, it.BitrixLeadID, text); err != nil { + slog.Warn("booking confirm: комментарий в лид не добавлен", "lead", it.BitrixLeadID, "err", err) + } +} + +// leadDraft — лид для оператора: всё, что нужно для звонка, и что будет, если не подтвердить. +// Поля пациента экранируются: в COMMENTS Битрикс показывает HTML. +func leadDraft(it storage.ConfirmItem, cfg storage.BookingSettings) bitrix.LeadDraft { + when := it.Start.In(moscow).Format("02.01 15:04") + phone := it.Phone + if n := normalizeRuPhone(it.Phone); n != "" { + phone = "+" + n + } + lines := []string{ + "Пациент записался онлайн, окно в 1С уже занято за ним.", + "Врач: " + html.EscapeString(it.DoctorName), + "Приём: " + formatSlotTime(it.Start), + "Филиал: " + html.EscapeString(branchOrDash(it.Branch)), + } + if it.ServiceName != "" { + lines = append(lines, "Услуга: "+html.EscapeString(it.ServiceName)) + } + if it.OnecID != "" { + lines = append(lines, "№ записи в 1С: "+html.EscapeString(it.OnecID)) + } + lines = append(lines, "", + "Позвоните пациенту. Подтвердил — переведите лид в «Клиент записался». "+ + "Любая провальная стадия отменит запись в 1С.") + if d, ok := confirmDeadline(it.CreatedAt, it.Start, cfg); ok { + lines = append(lines, "Без подтверждения запись отменится автоматически "+d.In(moscow).Format("02.01 в 15:04")+".") + } + return bitrix.LeadDraft{ + Title: "Онлайн-запись: " + it.DoctorName + " — " + when, + Name: it.Patient, + Phone: phone, + SourceID: bitrixSiteSourceID, + SourceDescr: "Онлайн-запись на сайте", + Comments: strings.Join(lines, "
"), + } +} + +// leadOnlyDraft — лид для заявки без записи в 1С. Причину сбоя 1С не пишем: в ней внутренние адреса. +func leadOnlyDraft(it storage.ConfirmItem) bitrix.LeadDraft { + phone := it.Phone + if n := normalizeRuPhone(it.Phone); n != "" { + phone = "+" + n + } + reason := "Пациент хотел записаться онлайн, но запись в 1С не прошла: 1С была недоступна или отклонила запись." + if strings.HasPrefix(it.Error, errBookingLimit.Error()) { + reason = "Пациент хотел записаться онлайн, но сработала защита от ботов: слишком много неподтверждённых онлайн-записей. " + + "Если пациент настоящий — запишите его." + } + lines := []string{ + reason, + "Окно за пациентом НЕ закреплено.", + "Врач: " + html.EscapeString(it.DoctorName), + "Выбранное время: " + formatSlotTime(it.Start), + "Филиал: " + html.EscapeString(branchOrDash(it.Branch)), + } + if it.ServiceName != "" { + lines = append(lines, "Услуга: "+html.EscapeString(it.ServiceName)) + } + lines = append(lines, "", "Позвоните пациенту и запишите его в 1С вручную.") + return bitrix.LeadDraft{ + Title: "Заявка на запись: " + it.DoctorName + " — " + it.Start.In(moscow).Format("02.01 15:04"), + Name: it.Patient, + Phone: phone, + SourceID: bitrixSiteSourceID, + SourceDescr: "Онлайн-запись на сайте (1С не записала)", + Comments: strings.Join(lines, "
"), + } +} + +// ---------- Настройки в админке ---------- + +func (a *API) adminBookingSettings(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + cfg, err := a.store.BookingSettings(r.Context()) + if err != nil { + handleErr(w, err) + return + } + ok(w, map[string]any{ + "settings": cfg, + "confirm_enabled": a.bookingConfirmEnabled(), + "bitrix_configured": a.bitrix.Configured(), + "smsc_configured": a.smscLogin != "", + }, nil) +} + +func (a *API) adminUpdateBookingSettings(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + var in storage.BookingSettings + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return + } + if in.AutoCancelHours < 0 || in.AutoCancelHours > 168 { + fail(w, http.StatusUnprocessableEntity, "validation", "Автоотмена — от 0 до 168 часов после записи (0 — не отменять)") + return + } + if in.CancelBeforeHours < 0 || in.CancelBeforeHours > 48 { + fail(w, http.StatusUnprocessableEntity, "validation", "Отмена до приёма — от 0 до 48 часов (0 — не отменять)") + return + } + for _, c := range []struct { + v, max int + msg string + }{ + {in.MaxPerDoctorDay, 50, "Неподтверждённых к врачу в день — от 0 до 50 (0 — без лимита)"}, + {in.MaxPerHour, 1000, "Онлайн-записей за час — от 0 до 1000 (0 — без лимита)"}, + {in.MaxActivePerPhone, 20, "Будущих записей на номер — от 0 до 20 (0 — без лимита)"}, + } { + if c.v < 0 || c.v > c.max { + fail(w, http.StatusUnprocessableEntity, "validation", c.msg) + return + } + } + if in.PhoneCheck && a.smscLogin == "" { + fail(w, http.StatusUnprocessableEntity, "validation", "Проверочный звонок недоступен: на сервере не настроен SMSC") + return + } + if err := a.store.SetBookingSettings(r.Context(), in); err != nil { + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "update", "booking_settings", 0, clientIP(r)) + a.adminBookingSettings(w, r, u) +} diff --git a/internal/api/booking_confirm_test.go b/internal/api/booking_confirm_test.go new file mode 100644 index 0000000..634ac71 --- /dev/null +++ b/internal/api/booking_confirm_test.go @@ -0,0 +1,106 @@ +package api + +import ( + "strings" + "testing" + "time" + + "dana/internal/bitrix" + "dana/internal/storage" +) + +func TestConfirmDeadline(t *testing.T) { + cfg := storage.DefaultBookingSettings // 24 ч после записи или 2 ч до приёма + created := time.Date(2026, 10, 9, 22, 0, 0, 0, moscow) + cases := []struct { + name string + start time.Time + want time.Time + }{ + {"приём через три дня — 24 ч после записи", created.Add(72 * time.Hour), created.Add(24 * time.Hour)}, + {"приём завтра утром — за 2 ч до приёма", created.Add(12 * time.Hour), created.Add(10 * time.Hour)}, + {"приём через час — не раньше 30 минут после записи", created.Add(time.Hour), created.Add(30 * time.Minute)}, + } + for _, c := range cases { + got, ok := confirmDeadline(created, c.start, cfg) + if !ok || !got.Equal(c.want) { + t.Errorf("%s: срок %v, ждали %v", c.name, got, c.want) + } + } + if _, ok := confirmDeadline(created, created.Add(72*time.Hour), storage.BookingSettings{}); ok { + t.Error("оба правила выключены — автоотмены быть не должно") + } +} + +func TestDecideConfirm(t *testing.T) { + cfg := storage.DefaultBookingSettings + created := time.Date(2026, 10, 9, 10, 0, 0, 0, moscow) + start := created.Add(48 * time.Hour) + soon := created.Add(time.Hour) + late := created.Add(25 * time.Hour) // срок (24 ч) прошёл, приём ещё впереди + cases := []struct { + name string + semantic string + now time.Time + want confirmAction + }{ + {"оператор подтвердил", bitrix.SemanticSuccess, soon, confirmOK}, + {"оператор отменил", bitrix.SemanticFailure, soon, confirmCancel}, + {"в работе, срок не вышел", bitrix.SemanticProcess, soon, confirmWait}, + {"лид удалён, срок не вышел", "", soon, confirmWait}, + {"в работе, срок вышел", bitrix.SemanticProcess, late, confirmExpire}, + {"подтвердили даже после срока — подтверждаем", bitrix.SemanticSuccess, late, confirmOK}, + {"провал после начала приёма — в 1С не трогаем", bitrix.SemanticFailure, start.Add(time.Minute), confirmMissed}, + {"никто не ответил до приёма — в 1С не трогаем", bitrix.SemanticProcess, start, confirmMissed}, + } + for _, c := range cases { + if got := decideConfirm(c.semantic, created, start, c.now, cfg); got != c.want { + t.Errorf("%s: %v, ждали %v", c.name, got, c.want) + } + } + if got := decideConfirm(bitrix.SemanticProcess, created, start, late, storage.BookingSettings{}); got != confirmWait { + t.Errorf("автоотмена выключена: %v, ждали ожидание", got) + } +} + +func TestLeadOnlyDraftAsksToBookManually(t *testing.T) { + it := storage.ConfirmItem{ + Start: time.Date(2026, 10, 12, 15, 30, 0, 0, moscow), Patient: "Анна", Phone: "+7 928 000 00 00", + DoctorName: "Петрова `); diff --git a/internal/api/video.go b/internal/api/video.go new file mode 100644 index 0000000..2478a47 --- /dev/null +++ b/internal/api/video.go @@ -0,0 +1,61 @@ +package api + +import ( + "errors" + "net/url" + "regexp" + "strings" +) + +var ( + youtubeIDRE = regexp.MustCompile(`^[A-Za-z0-9_-]{11}$`) + rutubeIDRE = regexp.MustCompile(`^[a-f0-9]{32}$`) + errVideoURL = errors.New("video url") +) + +// embedVideoURL приводит ссылку на видеоотзыв к адресу для встраивания. +// Адрес попадает в src у iframe, поэтому пропускаем только YouTube и Rutube: +// произвольная ссылка (например, javascript:) выполнилась бы на странице сайта. +func embedVideoURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") { + return "", errVideoURL + } + host := strings.TrimPrefix(strings.ToLower(u.Hostname()), "www.") + host = strings.TrimPrefix(host, "m.") + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + + youtube := func(id string) (string, error) { + if !youtubeIDRE.MatchString(id) { + return "", errVideoURL + } + return "https://www.youtube.com/embed/" + id, nil + } + switch host { + case "youtu.be": + return youtube(parts[0]) + case "youtube.com", "youtube-nocookie.com": + switch { + case len(parts) == 1 && parts[0] == "watch": + return youtube(u.Query().Get("v")) + case len(parts) == 2 && (parts[0] == "embed" || parts[0] == "shorts" || parts[0] == "live"): + return youtube(parts[1]) + } + case "rutube.ru": + // rutube.ru/video// и rutube.ru/play/embed/ + id := "" + if len(parts) == 2 && parts[0] == "video" { + id = parts[1] + } else if len(parts) == 3 && parts[0] == "play" && parts[1] == "embed" { + id = parts[2] + } + if rutubeIDRE.MatchString(id) { + return "https://rutube.ru/play/embed/" + id, nil + } + } + return "", errVideoURL +} diff --git a/internal/api/video_test.go b/internal/api/video_test.go new file mode 100644 index 0000000..38d5a9d --- /dev/null +++ b/internal/api/video_test.go @@ -0,0 +1,34 @@ +package api + +import "testing" + +func TestEmbedVideoURL(t *testing.T) { + good := map[string]string{ + "": "", + "https://www.youtube.com/embed/iZ-PIqFOY0M": "https://www.youtube.com/embed/iZ-PIqFOY0M", + "https://youtu.be/V1w8DhkWARc?si=abc": "https://www.youtube.com/embed/V1w8DhkWARc", + "https://www.youtube.com/watch?v=-oaWiVEUSx8": "https://www.youtube.com/embed/-oaWiVEUSx8", + "https://m.youtube.com/shorts/V1w8DhkWARc": "https://www.youtube.com/embed/V1w8DhkWARc", + " https://rutube.ru/video/0123456789abcdef0123456789abcdef/ ": "https://rutube.ru/play/embed/0123456789abcdef0123456789abcdef", + "https://rutube.ru/play/embed/0123456789abcdef0123456789abcdef": "https://rutube.ru/play/embed/0123456789abcdef0123456789abcdef", + } + for in, want := range good { + got, err := embedVideoURL(in) + if err != nil || got != want { + t.Errorf("embedVideoURL(%q) = %q, %v; want %q", in, got, err, want) + } + } + for _, in := range []string{ + "javascript:alert(1)", + "https://evil.example/embed/iZ-PIqFOY0M", + "https://youtube.com.evil.example/embed/iZ-PIqFOY0M", + "https://www.youtube.com/embed/iZ-PIqFOY0M/../../evil", + "https://www.youtube.com/watch?v=", + "//www.youtube.com/embed/iZ-PIqFOY0M", + } { + if got, err := embedVideoURL(in); err == nil { + t.Errorf("embedVideoURL(%q) accepted as %q", in, got) + } + } +} diff --git a/internal/api/waitcall.go b/internal/api/waitcall.go new file mode 100644 index 0000000..b687a7f --- /dev/null +++ b/internal/api/waitcall.go @@ -0,0 +1,343 @@ +package api + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +type WaitCallSession struct { + ID string `json:"session_id"` + Phone string `json:"phone"` // e.g. "79896657520" + TargetPhone string `json:"target_phone"` // e.g. "79019605980" + DisplayPhone string `json:"display_phone"` // e.g. "+7 (901) 960-59-80" + Verified bool `json:"verified"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +type waitCallManager struct { + mu sync.RWMutex + sessions map[string]*WaitCallSession + phoneSessions map[string][]*WaitCallSession + lastAnswerPoll time.Time +} + +var wcm = &waitCallManager{ + sessions: make(map[string]*WaitCallSession), + phoneSessions: make(map[string][]*WaitCallSession), +} + +func (m *waitCallManager) set(s *WaitCallSession) { + m.mu.Lock() + defer m.mu.Unlock() + m.sessions[s.ID] = s + m.phoneSessions[s.Phone] = append(m.phoneSessions[s.Phone], s) +} + +func (m *waitCallManager) get(id string) *WaitCallSession { + m.mu.RLock() + defer m.mu.RUnlock() + s, ok := m.sessions[id] + if !ok || time.Now().After(s.ExpiresAt) { + return nil + } + return s +} + +func (m *waitCallManager) isVerified(id, phone string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + s, ok := m.sessions[id] + if !ok || time.Now().After(s.ExpiresAt) { + return false + } + norm := normalizeRuPhone(phone) + return s.Verified && (s.Phone == norm || s.Phone == phone) +} + +// consume гасит подтверждение после успешной записи: одно подтверждение — одна запись. +func (m *waitCallManager) consume(id string) { + m.mu.Lock() + defer m.mu.Unlock() + if s, ok := m.sessions[id]; ok { + s.Verified = false + s.ExpiresAt = time.Now() + } +} + +func (m *waitCallManager) markVerified(phone string) int { + m.mu.Lock() + defer m.mu.Unlock() + norm := normalizeRuPhone(phone) + list, ok := m.phoneSessions[norm] + if !ok { + return 0 + } + count := 0 + now := time.Now() + for _, s := range list { + if now.Before(s.ExpiresAt) { + s.Verified = true + count++ + } + } + return count +} + +func (m *waitCallManager) checkSMSCFallback(login, password, targetPhone string) { + if login == "" || password == "" { + return + } + m.mu.Lock() + if time.Since(m.lastAnswerPoll) < 3*time.Second { + m.mu.Unlock() + return + } + m.lastAnswerPoll = time.Now() + m.mu.Unlock() + + endpoint := fmt.Sprintf( + "https://smsc.ru/sys/get.php?get_answers=1&hour=1&login=%s&psw=%s&fmt=3", + url.QueryEscape(login), + url.QueryEscape(password), + ) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + var answers []struct { + ID any `json:"id"` + Phone string `json:"phone"` + Message string `json:"message"` + Text string `json:"text"` + } + if err := json.NewDecoder(resp.Body).Decode(&answers); err != nil { + return + } + + for _, a := range answers { + txt := a.Message + if txt == "" { + txt = a.Text + } + if strings.Contains(txt, "[waitcall]") || strings.Contains(txt, "waitcall") || txt == "" { + norm := normalizeRuPhone(a.Phone) + if norm != "" { + m.markVerified(norm) + } + } + } +} + +// formatRuPhone formats Russian phone number to "+7 (XXX) XXX-XX-XX" +func formatRuPhone(p string) string { + d := onlyDigits(p) + if len(d) == 11 && (d[0] == '7' || d[0] == '8') { + return fmt.Sprintf("+7 (%s) %s-%s-%s", d[1:4], d[4:7], d[7:9], d[9:11]) + } + if len(d) == 10 { + return fmt.Sprintf("+7 (%s) %s-%s-%s", d[0:3], d[3:6], d[6:8], d[8:10]) + } + return "+" + d +} + +// normalizeRuPhone converts various formats to "7XXXXXXXXXX" +func normalizeRuPhone(p string) string { + d := onlyDigits(p) + if len(d) == 10 { + return "7" + d + } + if len(d) == 11 && (d[0] == '8' || d[0] == '7') { + return "7" + d[1:] + } + return d +} + +func (a *API) waitCallInit(w http.ResponseWriter, r *http.Request) { + if a.tooMany(w, "wc_init:"+clientIP(r), 10, 5*time.Minute) { + return + } + if a.smscLogin == "" || a.smscPassword == "" { + fail(w, http.StatusServiceUnavailable, "verification_disabled", "Подтверждение номера сейчас недоступно") + return + } + var in struct { + Phone string `json:"phone"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "bad_json", "Некорректный запрос") + return + } + phone := normalizeRuPhone(in.Phone) + if len(phone) != 11 || phone[0] != '7' { + fail(w, http.StatusBadRequest, "invalid_phone", "Укажите корректный российский номер телефона") + return + } + if a.tooMany(w, "wc_phone:"+phone, 4, 5*time.Minute) { + return + } + + endpoint := fmt.Sprintf( + "https://smsc.ru/sys/wait_call.php?login=%s&psw=%s&phone=%s&fmt=3", + url.QueryEscape(a.smscLogin), + url.QueryEscape(a.smscPassword), + url.QueryEscape(phone), + ) + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + handleErr(w, err) + return + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + // в адресе логин и пароль SMSC — в лог пишем ошибку без него + slog.Error("smsc wait_call request failed", "err", redactURL(err)) + fail(w, http.StatusBadGateway, "smsc_error", "Сервис проверки номеров временно недоступен") + return + } + defer resp.Body.Close() + + var smscResp struct { + Phone string `json:"phone"` + AllPhones []string `json:"all_phones"` + Error string `json:"error"` + ErrorCode int `json:"error_code"` + } + if err := json.NewDecoder(resp.Body).Decode(&smscResp); err != nil { + slog.Error("smsc wait_call decode failed", "err", err) + fail(w, http.StatusBadGateway, "smsc_error", "Ошибка ответа сервиса проверки") + return + } + if smscResp.ErrorCode != 0 { + slog.Warn("smsc wait_call error", "code", smscResp.ErrorCode, "err", smscResp.Error) + msg := "Не удалось получить номер для проверки" + if smscResp.ErrorCode == 9 { + msg = "Слишком много запросов. Пожалуйста, подождите 1 минуту." + } + fail(w, http.StatusBadRequest, "smsc_error", msg) + return + } + + targetPhone := smscResp.Phone + if targetPhone == "" && len(smscResp.AllPhones) > 0 { + targetPhone = smscResp.AllPhones[0] + } + if targetPhone == "" { + fail(w, http.StatusBadGateway, "smsc_error", "Не получен номер для звонка") + return + } + + b := make([]byte, 16) + _, _ = rand.Read(b) + sessionID := hex.EncodeToString(b) + + sess := &WaitCallSession{ + ID: sessionID, + Phone: phone, + TargetPhone: targetPhone, + DisplayPhone: formatRuPhone(targetPhone), + Verified: false, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(10 * time.Minute), + } + wcm.set(sess) + + ok(w, map[string]any{ + "session_id": sess.ID, + "target_phone": sess.TargetPhone, + "display_phone": sess.DisplayPhone, + "expires_in": 600, + }, nil) +} + +func (a *API) waitCallStatus(w http.ResponseWriter, r *http.Request) { + sessionID := r.URL.Query().Get("session_id") + if sessionID == "" { + fail(w, http.StatusBadRequest, "missing_session", "Укажите session_id") + return + } + sess := wcm.get(sessionID) + if sess == nil { + fail(w, http.StatusNotFound, "not_found", "Сессия не найдена или истекла") + return + } + + if !sess.Verified { + wcm.checkSMSCFallback(a.smscLogin, a.smscPassword, sess.Phone) + } + + ok(w, map[string]any{ + "session_id": sess.ID, + "phone": sess.Phone, + "target_phone": sess.TargetPhone, + "display_phone": sess.DisplayPhone, + "verified": sess.Verified, + }, nil) +} + +func (a *API) smscCallback(w http.ResponseWriter, r *http.Request) { + // Обратный вызов приходит из интернета, поэтому без секрета в адресе его не принимаем: + // подтверждение номера всё равно придёт через опрос ответов SMSC (waitCallStatus). + if a.smscCallbackToken == "" || !sameKey(r.URL.Query().Get("token"), a.smscCallbackToken) { + slog.Warn("smsc: callback rejected (нет или неверный token)", "ip", clientIP(r)) + fail(w, http.StatusForbidden, "forbidden", "Запрещено") + return + } + _ = r.ParseForm() + phone := r.FormValue("phone") + if phone == "" { + phone = r.URL.Query().Get("phone") + } + waitcall := r.FormValue("waitcall") + if waitcall == "" { + waitcall = r.URL.Query().Get("waitcall") + } + + if phone == "" && r.Body != nil { + var j struct { + Phone string `json:"phone"` + Waitcall any `json:"waitcall"` + } + _ = json.NewDecoder(r.Body).Decode(&j) + if j.Phone != "" { + phone = j.Phone + } + if j.Waitcall != nil { + waitcall = fmt.Sprint(j.Waitcall) + } + } + + slog.Info("smsc: callback received", "phone", maskPhone(phone), "waitcall", waitcall) + + norm := normalizeRuPhone(phone) + if norm != "" { + verifiedCount := wcm.markVerified(norm) + slog.Info("smsc: sessions marked verified", "phone", maskPhone(norm), "count", verifiedCount) + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) +} diff --git a/internal/api/waitcall.go.root-backup b/internal/api/waitcall.go.root-backup new file mode 100644 index 0000000..8f530dd --- /dev/null +++ b/internal/api/waitcall.go.root-backup @@ -0,0 +1,327 @@ +package api + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +type WaitCallSession struct { + ID string `json:"session_id"` + Phone string `json:"phone"` // e.g. "79896657520" + TargetPhone string `json:"target_phone"` // e.g. "79019605980" + DisplayPhone string `json:"display_phone"` // e.g. "+7 (901) 960-59-80" + Verified bool `json:"verified"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +type waitCallManager struct { + mu sync.RWMutex + sessions map[string]*WaitCallSession + phoneSessions map[string][]*WaitCallSession + lastAnswerPoll time.Time +} + +var wcm = &waitCallManager{ + sessions: make(map[string]*WaitCallSession), + phoneSessions: make(map[string][]*WaitCallSession), +} + +func (m *waitCallManager) set(s *WaitCallSession) { + m.mu.Lock() + defer m.mu.Unlock() + m.sessions[s.ID] = s + m.phoneSessions[s.Phone] = append(m.phoneSessions[s.Phone], s) +} + +func (m *waitCallManager) get(id string) *WaitCallSession { + m.mu.RLock() + defer m.mu.RUnlock() + s, ok := m.sessions[id] + if !ok || time.Now().After(s.ExpiresAt) { + return nil + } + return s +} + +func (m *waitCallManager) isVerified(id, phone string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + s, ok := m.sessions[id] + if !ok || time.Now().After(s.ExpiresAt) { + return false + } + norm := normalizeRuPhone(phone) + return s.Verified && (s.Phone == norm || s.Phone == phone) +} + +func (m *waitCallManager) markVerified(phone string) int { + m.mu.Lock() + defer m.mu.Unlock() + norm := normalizeRuPhone(phone) + list, ok := m.phoneSessions[norm] + if !ok { + return 0 + } + count := 0 + now := time.Now() + for _, s := range list { + if now.Before(s.ExpiresAt) { + s.Verified = true + count++ + } + } + return count +} + +func (m *waitCallManager) checkSMSCFallback(login, password, targetPhone string) { + if login == "" || password == "" { + return + } + m.mu.Lock() + if time.Since(m.lastAnswerPoll) < 3*time.Second { + m.mu.Unlock() + return + } + m.lastAnswerPoll = time.Now() + m.mu.Unlock() + + endpoint := fmt.Sprintf( + "https://smsc.ru/sys/get.php?get_answers=1&hour=1&login=%s&psw=%s&fmt=3", + url.QueryEscape(login), + url.QueryEscape(password), + ) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + var answers []struct { + ID any `json:"id"` + Phone string `json:"phone"` + Message string `json:"message"` + Text string `json:"text"` + } + if err := json.NewDecoder(resp.Body).Decode(&answers); err != nil { + return + } + + for _, a := range answers { + txt := a.Message + if txt == "" { + txt = a.Text + } + if strings.Contains(txt, "[waitcall]") || strings.Contains(txt, "waitcall") || txt == "" { + norm := normalizeRuPhone(a.Phone) + if norm != "" { + m.markVerified(norm) + } + } + } +} + +// formatRuPhone formats Russian phone number to "+7 (XXX) XXX-XX-XX" +func formatRuPhone(p string) string { + d := onlyDigits(p) + if len(d) == 11 && (d[0] == '7' || d[0] == '8') { + return fmt.Sprintf("+7 (%s) %s-%s-%s", d[1:4], d[4:7], d[7:9], d[9:11]) + } + if len(d) == 10 { + return fmt.Sprintf("+7 (%s) %s-%s-%s", d[0:3], d[3:6], d[6:8], d[8:10]) + } + return "+" + d +} + +// normalizeRuPhone converts various formats to "7XXXXXXXXXX" +func normalizeRuPhone(p string) string { + d := onlyDigits(p) + if len(d) == 10 { + return "7" + d + } + if len(d) == 11 && (d[0] == '8' || d[0] == '7') { + return "7" + d[1:] + } + return d +} + +func (a *API) waitCallInit(w http.ResponseWriter, r *http.Request) { + if a.tooMany(w, "wc_init:"+clientIP(r), 10, 5*time.Minute) { + return + } + var in struct { + Phone string `json:"phone"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "bad_json", "Некорректный запрос") + return + } + phone := normalizeRuPhone(in.Phone) + if len(phone) != 11 || phone[0] != '7' { + fail(w, http.StatusBadRequest, "invalid_phone", "Укажите корректный российский номер телефона") + return + } + if a.tooMany(w, "wc_phone:"+phone, 4, 5*time.Minute) { + fail(w, http.StatusTooManyRequests, "too_many_requests", "Слишком много попыток для этого номера. Подождите пару минут.") + return + } + + endpoint := fmt.Sprintf( + "https://smsc.ru/sys/wait_call.php?login=%s&psw=%s&phone=%s&fmt=3", + url.QueryEscape(a.smscLogin), + url.QueryEscape(a.smscPassword), + url.QueryEscape(phone), + ) + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + handleErr(w, err) + return + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + slog.Error("smsc wait_call request failed", "err", err) + fail(w, http.StatusBadGateway, "smsc_error", "Сервис проверки номеров временно недоступен") + return + } + defer resp.Body.Close() + + var smscResp struct { + Phone string `json:"phone"` + AllPhones []string `json:"all_phones"` + Error string `json:"error"` + ErrorCode int `json:"error_code"` + } + if err := json.NewDecoder(resp.Body).Decode(&smscResp); err != nil { + slog.Error("smsc wait_call decode failed", "err", err) + fail(w, http.StatusBadGateway, "smsc_error", "Ошибка ответа сервиса проверки") + return + } + if smscResp.ErrorCode != 0 { + slog.Warn("smsc wait_call error", "code", smscResp.ErrorCode, "err", smscResp.Error) + msg := "Не удалось получить номер для проверки" + if smscResp.ErrorCode == 9 { + msg = "Слишком много запросов. Пожалуйста, подождите 1 минуту." + } + fail(w, http.StatusBadRequest, "smsc_error", msg) + return + } + + targetPhone := smscResp.Phone + if targetPhone == "" && len(smscResp.AllPhones) > 0 { + targetPhone = smscResp.AllPhones[0] + } + if targetPhone == "" { + fail(w, http.StatusBadGateway, "smsc_error", "Не получен номер для звонка") + return + } + + b := make([]byte, 16) + _, _ = rand.Read(b) + sessionID := hex.EncodeToString(b) + + sess := &WaitCallSession{ + ID: sessionID, + Phone: phone, + TargetPhone: targetPhone, + DisplayPhone: formatRuPhone(targetPhone), + Verified: false, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(10 * time.Minute), + } + wcm.set(sess) + + ok(w, map[string]any{ + "session_id": sess.ID, + "target_phone": sess.TargetPhone, + "display_phone": sess.DisplayPhone, + "expires_in": 600, + }, nil) +} + +func (a *API) waitCallStatus(w http.ResponseWriter, r *http.Request) { + sessionID := r.URL.Query().Get("session_id") + if sessionID == "" { + fail(w, http.StatusBadRequest, "missing_session", "Укажите session_id") + return + } + sess := wcm.get(sessionID) + if sess == nil { + fail(w, http.StatusNotFound, "not_found", "Сессия не найдена или истекла") + return + } + + if !sess.Verified { + wcm.checkSMSCFallback(a.smscLogin, a.smscPassword, sess.Phone) + } + + ok(w, map[string]any{ + "session_id": sess.ID, + "phone": sess.Phone, + "target_phone": sess.TargetPhone, + "display_phone": sess.DisplayPhone, + "verified": sess.Verified, + }, nil) +} + +func (a *API) smscCallback(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + phone := r.FormValue("phone") + if phone == "" { + phone = r.URL.Query().Get("phone") + } + waitcall := r.FormValue("waitcall") + if waitcall == "" { + waitcall = r.URL.Query().Get("waitcall") + } + mes := r.FormValue("mes") + + if phone == "" && r.Body != nil { + var j struct { + Phone string `json:"phone"` + Waitcall any `json:"waitcall"` + Mes string `json:"mes"` + } + _ = json.NewDecoder(r.Body).Decode(&j) + if j.Phone != "" { + phone = j.Phone + } + if j.Waitcall != nil { + waitcall = fmt.Sprint(j.Waitcall) + } + if j.Mes != "" { + mes = j.Mes + } + } + + slog.Info("smsc: callback received", "phone", phone, "waitcall", waitcall, "mes", mes) + + norm := normalizeRuPhone(phone) + if norm != "" { + verifiedCount := wcm.markVerified(norm) + slog.Info("smsc: sessions marked verified", "phone", norm, "count", verifiedCount) + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) +} diff --git a/internal/api/waitcall_test.go b/internal/api/waitcall_test.go new file mode 100644 index 0000000..9e7b480 --- /dev/null +++ b/internal/api/waitcall_test.go @@ -0,0 +1,87 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func newWaitSession(phone string) *WaitCallSession { + s := &WaitCallSession{ID: "sess-" + phone, Phone: phone, ExpiresAt: time.Now().Add(time.Minute)} + wcm.set(s) + return s +} + +// Обратный вызов SMSC — публичная точка: без правильного секрета номер подтверждаться не должен. +func TestSMSCCallbackNeedsToken(t *testing.T) { + a := &API{smscCallbackToken: "s3cret-token"} + s := newWaitSession("79280000001") + + for name, query := range map[string]string{ + "без токена": "phone=79280000001", + "чужой токен": "phone=79280000001&token=wrong", + "пустой токен": "phone=79280000001&token=", + "токен не в query": "phone=79280000001&TOKEN=s3cret-token", + } { + rec := httptest.NewRecorder() + a.smscCallback(rec, httptest.NewRequest(http.MethodGet, "/api/v1/auth/smsc-callback?"+query, nil)) + if rec.Code != http.StatusForbidden { + t.Errorf("%s: код %d, ожидали 403", name, rec.Code) + } + if s.Verified { + t.Fatalf("%s: номер подтвердился без секрета", name) + } + } + + rec := httptest.NewRecorder() + a.smscCallback(rec, httptest.NewRequest(http.MethodGet, "/api/v1/auth/smsc-callback?phone=79280000001&token=s3cret-token", nil)) + if rec.Code != http.StatusOK || !s.Verified { + t.Errorf("с верным токеном: код %d, подтверждён=%v", rec.Code, s.Verified) + } +} + +// Если токен на сервере не задан, обратный вызов выключен полностью (в том числе с пустым token=). +func TestSMSCCallbackDisabledWithoutServerToken(t *testing.T) { + a := &API{} + s := newWaitSession("79280000002") + rec := httptest.NewRecorder() + a.smscCallback(rec, httptest.NewRequest(http.MethodGet, "/api/v1/auth/smsc-callback?phone=79280000002&token=", nil)) + if rec.Code != http.StatusForbidden || s.Verified { + t.Errorf("код %d, подтверждён=%v: вызов без настроенного токена должен отклоняться", rec.Code, s.Verified) + } +} + +// Одно подтверждение — одна запись: после consume повторное использование не проходит. +func TestVerificationIsOneTime(t *testing.T) { + s := newWaitSession("79280000003") + wcm.markVerified("79280000003") + if !wcm.isVerified(s.ID, "79280000003") { + t.Fatal("подтверждение не сработало") + } + wcm.consume(s.ID) + if wcm.isVerified(s.ID, "79280000003") { + t.Error("подтверждение осталось действительным после записи") + } +} + +func TestLastDigits(t *testing.T) { + for in, want := range map[string]string{ + "8 (928) 047-00-55": "9280470055", + "+7 928 047 00 55": "9280470055", + "89280470055": "9280470055", + "123": "123", + } { + if got := lastDigits(in, 10); got != want { + t.Errorf("lastDigits(%q)=%q, ожидали %q", in, got, want) + } + } +} + +func TestMaskPhone(t *testing.T) { + for in, want := range map[string]string{"79280470055": "***0055", "+7 (928) 047-00-55": "***0055", "12": "***", "": "***"} { + if got := maskPhone(in); got != want { + t.Errorf("maskPhone(%q)=%q, ожидали %q", in, got, want) + } + } +} diff --git a/internal/api/website.go b/internal/api/website.go new file mode 100644 index 0000000..93ae075 --- /dev/null +++ b/internal/api/website.go @@ -0,0 +1,435 @@ +package api + +import ( + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "dana/internal/domain" + "dana/internal/storage" +) + +type website struct { + dir string + siteURL string + services map[string]bool + local bool + renderer *pageRenderer +} + +// WithWebsite serves build-time rendered React HTML through Go. API-only startup +// is allowed before the first web build, so the generator can read the API. +func (a *API) WithWebsite(dir, siteURL string) error { + if dir == "" { + return nil + } + abs, err := filepath.Abs(dir) + if err != nil { + return err + } + if _, err := os.Stat(filepath.Join(abs, "index.html")); errors.Is(err, os.ErrNotExist) { + slog.Warn("web build not found; API-only startup", "dir", abs) + return nil + } else if err != nil { + return err + } + w := &website{dir: abs, services: map[string]bool{}} + var manifest struct { + SiteURL string `json:"siteURL"` + Services []string `json:"services"` + } + data, err := os.ReadFile(filepath.Join(abs, "seo-manifest.json")) + if err == nil { + if err := json.Unmarshal(data, &manifest); err != nil { + return fmt.Errorf("SEO manifest: %w", err) + } + for _, service := range manifest.Services { + w.services[service] = true + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + siteURL = strings.TrimRight(siteURL, "/") + if siteURL == "" { + siteURL = manifest.SiteURL + } + if siteURL == "" { + siteURL = "http://127.0.0.1:8080" + } + if manifest.SiteURL != "" && manifest.SiteURL != siteURL { + return fmt.Errorf("SITE_URL differs from the rendered canonical origin; rebuild web with SITE_URL=%s", siteURL) + } + u, err := url.Parse(siteURL) + if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil { + return fmt.Errorf("SITE_URL must be an HTTP(S) origin") + } + w.siteURL = siteURL + w.local = u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "::1" + a.website = w + return nil +} + +var rootFiles = map[string]bool{"/favicon.ico": true, "/apple-touch-icon.png": true} + +func canonicalPagePath(path string) string { + if path == "/" { + return path + } + return strings.TrimRight(path, "/") + "/" +} + +func (a *API) websitePage(w http.ResponseWriter, r *http.Request) { + site := a.website + if site.local { + w.Header().Set("X-Robots-Tag", "noindex, nofollow") + } + // Иконки сайта лежат в корне сборки (web/public): браузеры просят их без ссылок из HTML. + if rootFiles[r.URL.Path] { + file := filepath.Join(site.dir, strings.TrimPrefix(r.URL.Path, "/")) + if stat, err := os.Stat(file); err == nil && stat.Mode().IsRegular() { + w.Header().Set("Cache-Control", "public, max-age=604800") + http.ServeFile(w, r, file) + return + } + } + if strings.HasPrefix(r.URL.Path, "/assets/") { + file := filepath.Join(site.dir, filepath.FromSlash(strings.TrimPrefix(r.URL.Path, "/"))) + rel, err := filepath.Rel(site.dir, file) + if err != nil || strings.HasPrefix(rel, "..") { + http.NotFound(w, r) + return + } + if stat, err := os.Stat(file); err != nil || !stat.Mode().IsRegular() { + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + http.ServeFile(w, r, file) + return + } + switch strings.TrimRight(r.URL.Path, "/") { + case "/company/docs", "/company/requisites": + http.Redirect(w, r, "/legaldocs/", http.StatusMovedPermanently) + return + case "/company/history": + http.Redirect(w, r, "/company/", http.StatusMovedPermanently) + return + } + if r.URL.Path == "/privacy" || r.URL.Path == "/privacy/" || r.URL.Path == "/include/licenses_detail.php" { + http.Redirect(w, r, "/legaldocs/privacy/", http.StatusMovedPermanently) + return + } + pagePath := canonicalPagePath(r.URL.Path) + if strings.HasPrefix(pagePath, "/services/") && pagePath != "/services/" { + servicePath := strings.TrimSuffix(strings.TrimPrefix(pagePath, "/services/"), "/") + svc, err := a.store.ServiceByPath(r.Context(), servicePath) + if errors.Is(err, storage.ErrNotFound) { + a.notFound(w, r, pagePath, strings.Split(strings.Trim(pagePath, "/"), "/")) + return + } + if err != nil { + handleErr(w, err) + return + } + if !site.services[servicePath] && site.renderer == nil { + w.Header().Set("X-Robots-Tag", "noindex") + http.Error(w, "Страница ожидает сборки HTML. Выполните npm run build в web.", http.StatusServiceUnavailable) + return + } + if redirectCanonical(w, r, pagePath) { + return + } + if site.renderer != nil { + a.serveSSR(w, r, pagePath, svc) + return + } + file := filepath.Join(site.dir, "services", filepath.FromSlash(servicePath), "index.html") + if _, err := os.Stat(file); err != nil { + http.Error(w, "HTML страницы недоступен", http.StatusServiceUnavailable) + return + } + w.Header().Set("Cache-Control", "no-cache") + http.ServeFile(w, r, file) + return + } + // Only implemented routes get the SPA shell. Unknown routes and unfinished + // legacy sections return a real 404, even though React renders the design. + known := map[string]bool{"/": true, "/services/": true, "/company/staff/": true, "/price/": true, + "/checkup/": true, "/sales/": true, "/contacts/": true, "/legaldocs/": true, + "/legaldocs/privacy/": true, "/legaldocs/consent/": true, "/legaldocs/cookie/": true} + for _, p := range companyPaths { + known[p] = true + } + parts := strings.Split(strings.Trim(pagePath, "/"), "/") + if !known[pagePath] { + valid, err := a.knownContentRoute(r, parts) + if err != nil && !errors.Is(err, storage.ErrNotFound) { + handleErr(w, err) + return + } + if !valid { + a.notFound(w, r, pagePath, parts) + return + } + } + if len(parts) == 4 && parts[0] == "company" && parts[1] == "staff" { + doctor, err := a.store.DoctorBySlug(r.Context(), parts[3]) + if err != nil { + handleErr(w, err) + return + } + canonical := doctorWebPath(*doctor) + if redirectCanonical(w, r, canonical) { + return + } + } + // У вакансии нет своей страницы: старый адрес открывает её в общем списке. + if len(parts) == 3 && parts[0] == "company" && parts[1] == "vacancy" { + http.Redirect(w, r, "/company/vacancy/#"+url.PathEscape(parts[2]), http.StatusMovedPermanently) + return + } + if len(parts) == 2 && parts[0] == "sales" { + http.Redirect(w, r, "/sales/", http.StatusMovedPermanently) + return + } + if redirectCanonical(w, r, pagePath) { + return + } + if site.renderer != nil { + a.serveSSR(w, r, pagePath, nil) + return + } + w.Header().Set("Cache-Control", "no-cache") + http.ServeFile(w, r, filepath.Join(site.dir, "index.html")) +} + +func redirectCanonical(w http.ResponseWriter, r *http.Request, path string) bool { + if r.URL.Path == path { + return false + } + if r.URL.RawQuery != "" { + path += "?" + r.URL.RawQuery + } + http.Redirect(w, r, path, http.StatusMovedPermanently) + return true +} + +// Разделы «Компании» с постоянными адресами старого сайта. +var companyPaths = []string{"/company/", "/news/", "/articles/", "/reviews/", "/company/partners/", + "/company/nadzor/", "/company/vacancy/", "/info/faq/", "/company/licenses/"} + +// postKinds: раздел сайта → вид публикации в базе. +var postKinds = map[string]string{"news": "news", "articles": "article"} + +func (a *API) knownContentRoute(r *http.Request, p []string) (bool, error) { + // «Пациенту» открывается, только когда клиника включила страницу в админке + if len(p) == 1 && p[0] == "patients" { + _, err := a.store.PageBySlug(r.Context(), "patients") + return err == nil, err + } + if len(p) == 2 && postKinds[p[0]] != "" { + _, err := a.store.PostBySlug(r.Context(), postKinds[p[0]], p[1]) + return err == nil, err + } + if len(p) == 3 && p[0] == "company" && p[1] == "vacancy" { + list, err := a.store.Vacancies(r.Context()) + for _, v := range list { + if v.Slug == p[2] { + return true, err + } + } + return false, err + } + if len(p) == 2 && p[0] == "checkup" { + _, err := a.store.CheckupBySlug(r.Context(), p[1]) + return err == nil, err + } + if len(p) == 2 && p[0] == "sales" { + _, err := a.store.PostBySlug(r.Context(), "promo", p[1]) + return err == nil, err + } + if len(p) == 2 && p[0] == "price" { + tree, err := a.store.PriceList(r.Context(), "") + if err != nil { + return false, err + } + var contains func([]*domain.PriceSection) bool + contains = func(list []*domain.PriceSection) bool { + for _, s := range list { + if (s.Slug == p[1] && len(s.Items) > 0) || contains(s.Children) { + return true + } + } + return false + } + return contains(tree), nil + } + if len(p) >= 3 && p[0] == "company" && p[1] == "staff" { + if len(p) == 3 { + list, err := a.store.Specialties(r.Context()) + for _, s := range list { + if s.Slug == p[2] { + return true, err + } + } + return false, err + } + if len(p) == 4 { + doctor, err := a.store.DoctorBySlug(r.Context(), p[3]) + if err != nil { + return false, err + } + if len(doctor.Specialties) == 0 && p[2] == "vrachi" { + return true, nil + } + for _, s := range doctor.Specialties { + if s.Slug == p[2] { + return true, nil + } + } + return false, nil + } + } + return false, nil +} + +func (a *API) website404(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Robots-Tag", "noindex") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + data, err := os.ReadFile(filepath.Join(a.website.dir, "index.html")) + if err != nil { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write(data) +} + +func (a *API) robots(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + if a.website.local { + _, _ = fmt.Fprint(w, "User-agent: *\nDisallow: /\n") + return + } + // Query variants can be crawled to see their noindex response and canonical. + _, _ = fmt.Fprintf(w, "User-agent: *\nDisallow: /admin\nDisallow: /api/\nSitemap: %s/sitemap.xml\n", a.website.siteURL) +} + +func (a *API) sitemap(w http.ResponseWriter, r *http.Request) { + tree, err := a.store.ServiceTree(r.Context()) + if err != nil { + handleErr(w, err) + return + } + type location struct { + URL string `xml:"loc"` + } + set := struct { + XMLName xml.Name `xml:"urlset"` + Namespace string `xml:"xmlns,attr"` + URLs []location `xml:"url"` + }{Namespace: "http://www.sitemaps.org/schemas/sitemap/0.9"} + seen := map[string]bool{} + addPath := func(path string) { + if !seen[path] { + seen[path] = true + set.URLs = append(set.URLs, location{a.website.siteURL + path}) + } + } + for _, p := range []string{"/", "/services/", "/company/staff/", "/price/", "/checkup/", "/sales/", "/contacts/"} { + addPath(p) + } + for _, p := range companyPaths { + addPath(p) + } + if _, err := a.store.PageBySlug(r.Context(), "patients"); err == nil { + addPath("/patients/") + } + for _, section := range []string{"news", "articles"} { + posts, err := a.store.Posts(r.Context(), postKinds[section], 100) + if err != nil { + handleErr(w, err) + return + } + for _, p := range posts { + addPath("/" + section + "/" + p.Slug + "/") + } + } + var add func([]*domain.Service) + add = func(list []*domain.Service) { + for _, s := range list { + if a.website.renderer != nil { + addPath("/services/" + s.Path + "/") + } else if a.website.services[s.Path] { + if _, err := os.Stat(filepath.Join(a.website.dir, "services", filepath.FromSlash(s.Path), "index.html")); err == nil { + addPath("/services/" + s.Path + "/") + } + } + add(s.Children) + } + } + add(tree) + if a.website.renderer != nil { + doctors, err := a.store.Doctors(r.Context(), storage.DoctorFilter{}) + if err != nil { + handleErr(w, err) + return + } + for _, d := range doctors { + addPath(doctorWebPath(d)) + for _, s := range d.Specialties { + addPath("/company/staff/" + s.Slug + "/") + } + } + checkups, err := a.store.Checkups(r.Context(), "") + if err != nil { + handleErr(w, err) + return + } + for _, c := range checkups { + addPath("/checkup/" + c.Slug + "/") + } + prices, err := a.store.PriceList(r.Context(), "") + if err != nil { + handleErr(w, err) + return + } + var addPrices func([]*domain.PriceSection) + addPrices = func(list []*domain.PriceSection) { + for _, s := range list { + if len(s.Items) > 0 { + addPath("/price/" + s.Slug + "/") + } + addPrices(s.Children) + } + } + addPrices(prices) + for _, slug := range []string{"legaldocs", "privacy", "consent", "cookie"} { + _, err := a.store.PageBySlug(r.Context(), slug) + if errors.Is(err, storage.ErrNotFound) { + continue + } + if err != nil { + handleErr(w, err) + return + } + path := "/legaldocs/" + if slug != "legaldocs" { + path += slug + "/" + } + addPath(path) + } + } else { + addPath("/legaldocs/") + } + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + _, _ = fmt.Fprint(w, xml.Header) + _ = xml.NewEncoder(w).Encode(set) +} diff --git a/internal/api/website_test.go b/internal/api/website_test.go new file mode 100644 index 0000000..4db87bd --- /dev/null +++ b/internal/api/website_test.go @@ -0,0 +1,101 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func testWebsite(t *testing.T, origin string) *API { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("
"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "favicon.ico"), []byte{0, 0, 1, 0}, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "seo-manifest.json"), []byte(`{"siteURL":"`+origin+`","services":[]}`), 0600); err != nil { + t.Fatal(err) + } + a := New(nil, "") + if err := a.WithWebsite(dir, origin); err != nil { + t.Fatal(err) + } + return a +} + +func TestCanonicalPagePath(t *testing.T) { + for input, expected := range map[string]string{"/": "/", "/contacts": "/contacts/", "/contacts/": "/contacts/", "/services/estetic/rinoplastika": "/services/estetic/rinoplastika/"} { + if got := canonicalPagePath(input); got != expected { + t.Errorf("%s: %s != %s", input, got, expected) + } + } +} + +func TestWebsiteStatusCodes(t *testing.T) { + a := testWebsite(t, "https://example.org") + for _, tc := range []struct { + path string + status int + redirect string + }{ + {"/", 200, ""}, {"/contacts/", 200, ""}, {"/contacts?utm_source=test", 301, "/contacts/?utm_source=test"}, + {"/no-such-page/", 404, ""}, {"/news/", 200, ""}, {"/gallery/", 404, ""}, + {"/company/history/", 301, "/company/"}, {"/company/docs/", 301, "/legaldocs/"}, {"/assets/no-such-file.js", 404, ""}, + {"/seo-manifest.json", 404, ""}, {"/favicon.ico", 200, ""}, {"/privacy", 301, "/legaldocs/privacy/"}, + } { + r := httptest.NewRequest(http.MethodGet, tc.path, nil) + w := httptest.NewRecorder() + a.Routes().ServeHTTP(w, r) + if w.Code != tc.status { + t.Errorf("%s: status %d, want %d", tc.path, w.Code, tc.status) + } + if tc.redirect != "" && w.Header().Get("Location") != tc.redirect { + t.Errorf("redirect %s", w.Header().Get("Location")) + } + if w.Code == 404 && tc.path == "/no-such-page/" && w.Header().Get("X-Robots-Tag") != "noindex" { + t.Error("404 should be noindex") + } + } +} + +func TestRobots(t *testing.T) { + for _, origin := range []string{"https://example.org", "http://127.0.0.1:8080"} { + a := testWebsite(t, origin) + w := httptest.NewRecorder() + a.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/robots.txt", nil)) + body := w.Body.String() + if a.website.local { + if body != "User-agent: *\nDisallow: /\n" { + t.Fatal(body) + } + } else { + for _, expected := range []string{"Disallow: /admin", "Disallow: /api/", "Sitemap: https://example.org/sitemap.xml"} { + if !strings.Contains(body, expected) { + t.Errorf("missing %s", expected) + } + } + } + } +} + +func TestWebsiteCanonicalMismatch(t *testing.T) { + a := testWebsite(t, "https://example.org") + if err := a.WithWebsite(a.website.dir, "https://other.example.org"); err == nil { + t.Error("canonical mismatch must fail startup") + } +} + +func TestWebsiteInvalidOrigin(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "index.html"), []byte("html"), 0600) + for _, origin := range []string{"javascript:alert(1)", "https://example.org/path", "https://user:secret@example.org", "https://example.org?q=test"} { + if err := New(nil, "").WithWebsite(dir, origin); err == nil { + t.Errorf("accepted %s", origin) + } + } +} diff --git a/internal/bitrix/client.go b/internal/bitrix/client.go new file mode 100644 index 0000000..c8037a2 --- /dev/null +++ b/internal/bitrix/client.go @@ -0,0 +1,134 @@ +// Package bitrix — минимальный клиент REST API Битрикс24 через входящий вебхук. +// Используется для выгрузки лидов и контактов в «Отчёт по каналам» и для лидов онлайн-записи, +// которые подтверждает оператор (см. leads.go). +package bitrix + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +type Client struct { + WebhookURL string + HTTP *http.Client +} + +func New(webhookURL string) *Client { + return &Client{ + WebhookURL: strings.TrimRight(strings.TrimSpace(webhookURL), "/"), + HTTP: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (c *Client) Configured() bool { return c != nil && c.WebhookURL != "" } + +type phoneField struct { + Value string `json:"VALUE"` +} + +type crmItem struct { + ID string `json:"ID"` + SourceID string `json:"SOURCE_ID"` + Phone []phoneField `json:"PHONE"` +} + +type listResponse struct { + Result []crmItem `json:"result"` + Total int `json:"total"` + Error string `json:"error"` + Descr string `json:"error_description"` +} + +// Record — лид или контакт CRM: нужные нам поля, без лишнего. +type Record struct { + ID string + SourceID string // у контактов всегда пусто — поле есть только у лидов + Phones []string +} + +const pageSize = 50 + +// Progress — колбэк хода постраничной выгрузки: сколько записей забрано из скольких всего (по ответу API). +type Progress func(done, total int) + +// list — одна выборка (лиды или контакты) целиком, со всеми страницами. +func (c *Client) list(ctx context.Context, method string, selectFields []string, filter map[string]any, onPage Progress) ([]Record, error) { + var out []Record + start := 0 + for { + body := map[string]any{ + "select": selectFields, + "order": map[string]string{"ID": "ASC"}, + "start": start, + } + if len(filter) > 0 { + body["filter"] = filter + } + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.WebhookURL+"/"+method+".json", strings.NewReader(string(b))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("битрикс %s: %w", method, redactURL(err)) + } + var lr listResponse + decErr := json.NewDecoder(resp.Body).Decode(&lr) + resp.Body.Close() + if decErr != nil { + return nil, fmt.Errorf("битрикс %s: не разобрать ответ: %w", method, decErr) + } + if lr.Error != "" { + return nil, fmt.Errorf("битрикс %s: %s (%s)", method, lr.Error, lr.Descr) + } + for _, item := range lr.Result { + r := Record{ID: item.ID, SourceID: item.SourceID} + for _, p := range item.Phone { + if v := strings.TrimSpace(p.Value); v != "" { + r.Phones = append(r.Phones, v) + } + } + out = append(out, r) + } + if onPage != nil { + onPage(len(out), lr.Total) + } + start += pageSize + if start >= lr.Total || len(lr.Result) == 0 { + break + } + } + return out, nil +} + +// dateFilter — ограничение по дате создания записи (DATE_CREATE), чтобы не тянуть всю историю CRM +// за годы. from/to нулевые — фильтр не применяется (выгружается всё, для старых мест вызова). +func dateFilter(from, to time.Time) map[string]any { + if from.IsZero() || to.IsZero() { + return nil + } + return map[string]any{ + ">=DATE_CREATE": from.Format(time.RFC3339), + "= 300 { + return fmt.Errorf("битрикс %s: HTTP %d", method, resp.StatusCode) + } + if out != nil { + if err := json.Unmarshal(r.Result, out); err != nil { + return fmt.Errorf("битрикс %s: неожиданный ответ: %w", method, err) + } + } + return nil +} + +// AddLead заводит лид в стадии «Не обработан» и возвращает его ID. +func (c *Client) AddLead(ctx context.Context, d LeadDraft) (string, error) { + fields := map[string]any{ + "TITLE": d.Title, + "NAME": d.Name, + "STATUS_ID": "NEW", + "OPENED": "Y", + "COMMENTS": d.Comments, + } + if d.Phone != "" { + fields["PHONE"] = []map[string]string{{"VALUE": d.Phone, "VALUE_TYPE": "MOBILE"}} + } + if d.SourceID != "" { + fields["SOURCE_ID"] = d.SourceID + } + if d.SourceDescr != "" { + fields["SOURCE_DESCRIPTION"] = d.SourceDescr + } + // result — число, но на всякий случай принимаем и строку + var id json.Number + if err := c.call(ctx, "crm.lead.add", map[string]any{ + "fields": fields, + "params": map[string]string{"REGISTER_SONET_EVENT": "Y"}, + }, &id); err != nil { + return "", err + } + s := strings.Trim(id.String(), `"`) + if s == "" || s == "0" { + return "", fmt.Errorf("битрикс crm.lead.add: пустой ID лида") + } + return s, nil +} + +// LeadSemantics — семантика стадии (P/S/F) для каждого найденного лида. Удалённых лидов в ответе нет. +func (c *Client) LeadSemantics(ctx context.Context, ids []string) (map[string]string, error) { + out := map[string]string{} + for len(ids) > 0 { + n := min(len(ids), pageSize) + chunk := ids[:n] + ids = ids[n:] + var items []struct { + ID string `json:"ID"` + Semantic string `json:"STATUS_SEMANTIC_ID"` + } + if err := c.call(ctx, "crm.lead.list", map[string]any{ + "select": []string{"ID", "STATUS_SEMANTIC_ID"}, + "filter": map[string]any{"@ID": chunk}, + }, &items); err != nil { + return nil, err + } + for _, it := range items { + out[it.ID] = it.Semantic + } + } + return out, nil +} + +// AddLeadComment оставляет комментарий в ленте лида — оператор видит, что сделал сайт. +func (c *Client) AddLeadComment(ctx context.Context, leadID, text string) error { + return c.call(ctx, "crm.timeline.comment.add", map[string]any{ + "fields": map[string]any{"ENTITY_ID": leadID, "ENTITY_TYPE": "lead", "COMMENT": text}, + }, nil) +} + +// FailLead переводит лид в «Клиент не записался» — после автоматической отмены записи. +func (c *Client) FailLead(ctx context.Context, leadID string) error { + return c.call(ctx, "crm.lead.update", map[string]any{ + "id": leadID, + "fields": map[string]any{"STATUS_ID": FailedStatusID}, + }, nil) +} + +// redactURL убирает адрес запроса из сетевой ошибки: в адресе вебхука секретный ключ, +// а текст ошибки попадает в лог, в базу и в админку. +func redactURL(err error) error { + var ue *url.Error + if errors.As(err, &ue) { + return fmt.Errorf("%s: %w", ue.Op, ue.Err) + } + return err +} diff --git a/internal/bitrix/leads_test.go b/internal/bitrix/leads_test.go new file mode 100644 index 0000000..ca4404b --- /dev/null +++ b/internal/bitrix/leads_test.go @@ -0,0 +1,96 @@ +package bitrix + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// fakeBitrix отвечает на методы REST так, как отвечает Битрикс24, и запоминает тела запросов. +func fakeBitrix(t *testing.T, answers map[string]string) (*Client, map[string]map[string]any) { + t.Helper() + got := map[string]map[string]any{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/rest/1/key/"), ".json") + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + got[method] = body + ans, ok := answers[method] + if !ok { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"ERROR_METHOD_NOT_FOUND","error_description":"нет такого метода"}`)) + return + } + _, _ = w.Write([]byte(ans)) + })) + t.Cleanup(srv.Close) + return New(srv.URL + "/rest/1/key/"), got +} + +func TestAddLeadSendsFieldsAndReturnsID(t *testing.T) { + c, got := fakeBitrix(t, map[string]string{"crm.lead.add": `{"result":683100,"time":{}}`}) + id, err := c.AddLead(context.Background(), LeadDraft{ + Title: "Онлайн-запись: Иванов — 10.10 15:00", Name: "Анна", Phone: "+79280000000", + SourceID: "112", SourceDescr: "Онлайн-запись на сайте", Comments: "строка
строка", + }) + if err != nil || id != "683100" { + t.Fatalf("id=%q err=%v", id, err) + } + f, _ := got["crm.lead.add"]["fields"].(map[string]any) + if f["STATUS_ID"] != "NEW" || f["SOURCE_ID"] != "112" || f["NAME"] != "Анна" || f["TITLE"] == nil { + t.Fatalf("поля лида: %v", f) + } + phones, _ := f["PHONE"].([]any) + if len(phones) != 1 || phones[0].(map[string]any)["VALUE"] != "+79280000000" { + t.Fatalf("телефон: %v", f["PHONE"]) + } +} + +func TestAddLeadReportsBitrixError(t *testing.T) { + c, _ := fakeBitrix(t, map[string]string{"crm.lead.add": `{"error":"ACCESS_DENIED","error_description":"нет прав"}`}) + if _, err := c.AddLead(context.Background(), LeadDraft{Title: "x"}); err == nil || !strings.Contains(err.Error(), "ACCESS_DENIED") { + t.Fatalf("ожидали ошибку прав, получили %v", err) + } +} + +func TestLeadSemanticsFiltersByIDs(t *testing.T) { + c, got := fakeBitrix(t, map[string]string{ + "crm.lead.list": `{"result":[{"ID":"1","STATUS_SEMANTIC_ID":"S"},{"ID":"2","STATUS_SEMANTIC_ID":"F"}],"total":2}`, + }) + m, err := c.LeadSemantics(context.Background(), []string{"1", "2", "3"}) + if err != nil { + t.Fatal(err) + } + // лида 3 в ответе нет (удалён в CRM) — в карте его тоже нет + if m["1"] != SemanticSuccess || m["2"] != SemanticFailure || len(m) != 2 { + t.Fatalf("семантика: %v", m) + } + filter, _ := got["crm.lead.list"]["filter"].(map[string]any) + if ids, _ := filter["@ID"].([]any); len(ids) != 3 { + t.Fatalf("фильтр по ID: %v", filter) + } +} + +func TestFailLeadAndComment(t *testing.T) { + c, got := fakeBitrix(t, map[string]string{ + "crm.lead.update": `{"result":true}`, + "crm.timeline.comment.add": `{"result":55}`, + }) + if err := c.FailLead(context.Background(), "7"); err != nil { + t.Fatal(err) + } + if err := c.AddLeadComment(context.Background(), "7", "Запись отменена"); err != nil { + t.Fatal(err) + } + upd, _ := got["crm.lead.update"]["fields"].(map[string]any) + if got["crm.lead.update"]["id"] != "7" || upd["STATUS_ID"] != FailedStatusID { + t.Fatalf("обновление лида: %v", got["crm.lead.update"]) + } + cm, _ := got["crm.timeline.comment.add"]["fields"].(map[string]any) + if cm["ENTITY_TYPE"] != "lead" || cm["ENTITY_ID"] != "7" { + t.Fatalf("комментарий: %v", cm) + } +} diff --git a/internal/bitrix/redact_test.go b/internal/bitrix/redact_test.go new file mode 100644 index 0000000..6696cfd --- /dev/null +++ b/internal/bitrix/redact_test.go @@ -0,0 +1,25 @@ +package bitrix + +import ( + "context" + "strings" + "testing" + "time" +) + +// Вебхук недоступен (сеть, таймаут): в тексте ошибки не должно быть адреса с секретным ключом — +// он уходит в лог, в базу и в админку. +func TestNetworkErrorsDoNotLeakWebhookKey(t *testing.T) { + c := New("http://127.0.0.1:1/rest/1/SECRETKEY123/") + c.HTTP.Timeout = 2 * time.Second + _, errAdd := c.AddLead(context.Background(), LeadDraft{Title: "x"}) + _, errList := c.Leads(context.Background(), time.Time{}, time.Time{}, nil) + for name, err := range map[string]error{"crm.lead.add": errAdd, "crm.lead.list": errList} { + if err == nil { + t.Fatalf("%s: ожидали сетевую ошибку", name) + } + if strings.Contains(err.Error(), "SECRETKEY123") { + t.Errorf("%s: ключ вебхука в тексте ошибки: %q", name, err) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d2d70d9 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,187 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + Addr string + DatabaseURL string + MediaDir string + AllowedOrigins string + AdminEmail string + AdminPassword string + ShutdownTimeout time.Duration + Debug bool + SiteURL string + WebDir string + SSRRenderer string + NodeBinary string + + // Уведомления о заявках на почту. Без SMTP_HOST отключены. + SMTPHost string + SMTPPort int + SMTPUser string + SMTPPassword string + NotifyFrom string + NotifyTo []string + + // Виртуальная АТС МегаФон: адрес ВАТС (vats970345.megapbx.ru) и ключ REST API. + // Без ключа загрузка звонков выключена. + MegafonURL string + MegafonKey string + // АТС МегаФон клиники «Очевидно» (vats363024.megapbx.ru) — свои звонки и свой отчёт по каналам. + OchevidnoMegafonURL string + OchevidnoMegafonKey string + + // 1С: расписание врачей и запись. Без адреса и ключа запись через 1С выключена, остаются заявки. + OnecURL string + OnecKey string + OnecAuthHeader string + OnecWebhookKey string + OnecSyncSecs int // период опроса 1С, по умолчанию 300 + OnecLongPoll bool // режим long polling: 1С держит запрос /changes + + // Битрикс24: входящий вебхук для разовой выгрузки номеров лидов/контактов в «Отчёт по каналам». + BitrixWebhookURL string + + // SMSC.RU: подтверждение номера звонком (Wait-Call) + SMSCLogin string + SMSCPassword string + // Секрет в адресе обратного вызова SMSC (?token=…). Без него вызов не принимается. + SMSCCallbackToken string + // TrustedProxies — адреса/подсети прокси, чьим X-Real-IP верим (пусто — только сам сервер). + TrustedProxies string + + // Кабинеты ПроДокторов и СберЗдоровья (скачивание с сервера; пусто = выключено). + ProdoctorovCookie string + ProdoctorovLPU string + SberHealthCookie string + SberHealthClinic string +} + +func Load() (Config, error) { + loadDotEnv(".env") + c := Config{ + Addr: env("APP_ADDR", ":8080"), + DatabaseURL: os.Getenv("DATABASE_URL"), + MediaDir: env("MEDIA_DIR", "./media"), + AllowedOrigins: env("ALLOWED_ORIGINS", "*"), + AdminEmail: env("ADMIN_EMAIL", ""), + AdminPassword: env("ADMIN_PASSWORD", ""), + ShutdownTimeout: 15 * time.Second, + Debug: envBool("DEBUG", false), + SiteURL: env("SITE_URL", ""), + WebDir: env("WEB_DIR", "./web/dist"), + SSRRenderer: env("SSR_RENDERER", "./web/scripts/render-page.mjs"), + NodeBinary: env("NODE_BINARY", "node"), + + SMTPHost: env("SMTP_HOST", ""), + SMTPPort: envInt("SMTP_PORT", 465), + SMTPUser: env("SMTP_USER", ""), + SMTPPassword: os.Getenv("SMTP_PASSWORD"), + NotifyFrom: env("NOTIFY_FROM", os.Getenv("SMTP_USER")), + NotifyTo: envList("NOTIFY_TO"), + + MegafonURL: env("MEGAFON_API_URL", ""), + MegafonKey: os.Getenv("MEGAFON_API_KEY"), + + OchevidnoMegafonURL: env("OCHEVIDNO_MEGAFON_API_URL", ""), + OchevidnoMegafonKey: os.Getenv("OCHEVIDNO_MEGAFON_API_KEY"), + + OnecURL: env("ONEC_API_URL", ""), + OnecKey: os.Getenv("ONEC_API_KEY"), + OnecAuthHeader: env("ONEC_AUTH_HEADER", "X-Api-Key"), + OnecWebhookKey: os.Getenv("ONEC_WEBHOOK_KEY"), + OnecSyncSecs: envInt("ONEC_SYNC_SECONDS", 300), + OnecLongPoll: os.Getenv("ONEC_LONGPOLL") == "1", + + BitrixWebhookURL: os.Getenv("BITRIX_WEBHOOK_URL"), + SMSCLogin: os.Getenv("SMSC_LOGIN"), + SMSCPassword: os.Getenv("SMSC_PASSWORD"), + SMSCCallbackToken: os.Getenv("SMSC_CALLBACK_TOKEN"), + TrustedProxies: os.Getenv("TRUSTED_PROXIES"), + ProdoctorovCookie: os.Getenv("PRODOCTOROV_COOKIE"), + ProdoctorovLPU: os.Getenv("PRODOCTOROV_LPU_ID"), + SberHealthCookie: os.Getenv("SBERHEALTH_COOKIE"), + SberHealthClinic: os.Getenv("SBERHEALTH_CLINIC_ID"), + } + if c.DatabaseURL == "" { + return c, fmt.Errorf("DATABASE_URL is required") + } + if c.SMTPHost != "" && c.NotifyFrom == "" { + return c, fmt.Errorf("NOTIFY_FROM or SMTP_USER is required when SMTP_HOST is set") + } + return c, nil +} + +func envInt(key string, def int) int { + n, err := strconv.Atoi(os.Getenv(key)) + if err != nil { + return def + } + return n +} + +// envList читает список через запятую: «a@x.ru, b@x.ru». +func envList(key string) []string { + var out []string + for _, part := range strings.Split(os.Getenv(key), ",") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + return out +} + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func envBool(key string, def bool) bool { + v := os.Getenv(key) + if v == "" { + return def + } + b, err := strconv.ParseBool(v) + if err != nil { + return def + } + return b +} + +// loadDotEnv читает файл .env рядом с сервером: строки «КЛЮЧ=значение», # — комментарий. +// Переменные, уже заданные в окружении, не перезаписываются. Файл в git не попадает (.gitignore): +// в нём живут секреты — пароль базы, ключ API АТС. +func loadDotEnv(path string) { + data, err := os.ReadFile(path) + if err != nil { + return + } + text := strings.TrimPrefix(string(data), "\ufeff") + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + k = strings.TrimSpace(strings.TrimPrefix(k, "export ")) + v = strings.TrimSpace(v) + if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) { + v = v[1 : len(v)-1] + } + if _, set := os.LookupEnv(k); !set && k != "" { + _ = os.Setenv(k, v) + } + } +} diff --git a/internal/domain/content.go b/internal/domain/content.go new file mode 100644 index 0000000..8be3676 --- /dev/null +++ b/internal/domain/content.go @@ -0,0 +1,157 @@ +package domain + +import ( + "encoding/json" + "time" +) + +// Post — новость, статья или акция. Различаются полем Kind. +type Post struct { + ID int64 `json:"id"` + Kind string `json:"kind"` // news | article | promo + Slug string `json:"slug"` + Title string `json:"title"` + Lead string `json:"lead,omitempty"` + Blocks json.RawMessage `json:"blocks,omitempty"` + Image string `json:"image,omitempty"` + StartsAt *time.Time `json:"starts_at,omitempty"` + EndsAt *time.Time `json:"ends_at,omitempty"` + PublishedAt time.Time `json:"published_at"` + SEO SEO `json:"seo"` + + // Только у акций: размер скидки, условия и чек-ап, на который она действует. + DiscountPercent *int `json:"discount_percent,omitempty"` + Terms string `json:"terms,omitempty"` + CheckupSlug string `json:"checkup_slug,omitempty"` +} + +type Review struct { + ID int64 `json:"id"` + AuthorName string `json:"author_name"` + AuthorPhoto string `json:"author_photo,omitempty"` + Rating *int `json:"rating,omitempty"` + Text string `json:"text"` + VideoURL string `json:"video_url,omitempty"` + DoctorSlug string `json:"doctor_slug,omitempty"` + DoctorName string `json:"doctor_name,omitempty"` + Source string `json:"source"` + Reply string `json:"reply,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type FAQItem struct { + ID int64 `json:"id"` + Question string `json:"question"` + Answer string `json:"answer"` + ServiceID *int64 `json:"service_id,omitempty"` +} + +type Document struct { + ID int64 `json:"id"` + Kind string `json:"kind"` // license | certificate | supervisor | other + Title string `json:"title"` + Body string `json:"body,omitempty"` + File string `json:"file,omitempty"` + Preview string `json:"preview,omitempty"` + IssuedAt *time.Time `json:"issued_at,omitempty"` +} + +type Partner struct { + ID int64 `json:"id"` + Name string `json:"name"` + URL string `json:"url,omitempty"` + Logo string `json:"logo,omitempty"` +} + +type Vacancy struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Duties string `json:"duties,omitempty"` + Requirements string `json:"requirements,omitempty"` + Conditions string `json:"conditions,omitempty"` + Salary string `json:"salary,omitempty"` + BranchID *int64 `json:"branch_id,omitempty"` +} + +type Branch struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Address string `json:"address"` + Phones []string `json:"phones"` + WorkHours []string `json:"work_hours"` + Lat *float64 `json:"lat,omitempty"` + Lon *float64 `json:"lon,omitempty"` +} + +type Checkup struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Audience string `json:"audience"` // adults | kids + Name string `json:"name"` + Lead string `json:"lead,omitempty"` + Blocks json.RawMessage `json:"blocks,omitempty"` + Price *float64 `json:"price,omitempty"` + OldPrice *float64 `json:"old_price,omitempty"` + Image string `json:"image,omitempty"` + Featured bool `json:"featured"` + Items []string `json:"items,omitempty"` + SEO SEO `json:"seo"` +} + +type Page struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Blocks json.RawMessage `json:"blocks,omitempty"` + SEO SEO `json:"seo"` +} + +type GalleryAlbum struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Photos []GalleryPhoto `json:"photos,omitempty"` +} + +type GalleryPhoto struct { + ID int64 `json:"id"` + Path string `json:"path"` + Caption string `json:"caption,omitempty"` +} + +// --- формы и заявки --- + +type Form struct { + ID int64 `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + SuccessText string `json:"success_text,omitempty"` + Fields []FormField `json:"fields"` +} + +type FormField struct { + Code string `json:"code"` + Label string `json:"label"` + Type string `json:"type"` + Required bool `json:"required"` + Options json.RawMessage `json:"options,omitempty"` +} + +// LeadInput — то, что приходит с сайта при отправке формы. +type LeadInput struct { + FormCode string `json:"form"` + Name string `json:"name"` + Phone string `json:"phone"` + Email string `json:"email"` + ServiceID *int64 `json:"service_id"` + DoctorID *int64 `json:"doctor_id"` + BranchID *int64 `json:"branch_id"` + Message string `json:"message"` + Extra map[string]any `json:"extra"` + PageURL string `json:"page_url"` + UTM map[string]string `json:"utm"` + Consent bool `json:"consent"` +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go new file mode 100644 index 0000000..b55aab6 --- /dev/null +++ b/internal/domain/domain.go @@ -0,0 +1,120 @@ +package domain + +import "encoding/json" + +type SEO struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + H1 string `json:"h1,omitempty"` +} + +type Service struct { + ID int64 `json:"id"` + ParentID *int64 `json:"parent_id,omitempty"` + Slug string `json:"slug"` + Path string `json:"path"` + Name string `json:"name"` + ShortName string `json:"short_name,omitempty"` + Lead string `json:"lead,omitempty"` + Icon string `json:"icon,omitempty"` + Image string `json:"image,omitempty"` + Blocks json.RawMessage `json:"blocks,omitempty"` + SEO SEO `json:"seo"` + Children []*Service `json:"children,omitempty"` + + // Заполняется только для карточки услуги. + PriceFrom *float64 `json:"price_from,omitempty"` + Prices []PriceItem `json:"prices,omitempty"` + Doctors []Doctor `json:"doctors,omitempty"` +} + +type Specialty struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + NamePlural string `json:"name_plural"` + Count int `json:"count,omitempty"` +} + +type Doctor struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + LastName string `json:"last_name"` + FirstName string `json:"first_name"` + MiddleName string `json:"middle_name,omitempty"` + FullName string `json:"full_name"` + Position string `json:"position,omitempty"` + Category string `json:"category,omitempty"` + Degree string `json:"degree,omitempty"` + Experience *int `json:"experience_years,omitempty"` + Photo string `json:"photo,omitempty"` + Quote string `json:"quote,omitempty"` + Bio string `json:"bio,omitempty"` + Languages []string `json:"languages,omitempty"` + KidsFrom *int `json:"accepts_kids_from,omitempty"` + Booking Booking `json:"booking"` + Absent bool `json:"absent"` + // Рейтинг может быть задан в админке; иначе считается по опубликованным отзывам. + Rating *float64 `json:"rating,omitempty"` + ReviewCount int `json:"review_count"` + Specialties []Specialty `json:"specialties,omitempty"` + SEO SEO `json:"seo"` + + BioEntries []BioEntry `json:"bio_entries,omitempty"` + Services []DoctorService `json:"services,omitempty"` + Branches []DoctorBranch `json:"branches,omitempty"` +} + +// DoctorBranch — филиал, где принимает врач; адрес показывается в карточке. +type DoctorBranch struct { + Slug string `json:"slug"` + Name string `json:"name"` + Address string `json:"address"` +} + +// DoctorService — услуга, которую ведёт врач; для вкладки «Услуги» в карточке. +type DoctorService struct { + Name string `json:"name"` + Path string `json:"path"` + Lead string `json:"lead,omitempty"` +} + +type Booking struct { + Provider string `json:"provider"` // none | native | medflex + URL string `json:"url,omitempty"` +} + +type BioEntry struct { + Kind string `json:"kind"` + YearFrom *int `json:"year_from,omitempty"` + YearTo *int `json:"year_to,omitempty"` + Title string `json:"title"` + Description string `json:"description,omitempty"` +} + +type PriceSection struct { + ID int64 `json:"id"` + ParentID *int64 `json:"parent_id,omitempty"` + Slug string `json:"slug"` + Name string `json:"name"` + Items []PriceItem `json:"items,omitempty"` + Children []*PriceSection `json:"children,omitempty"` +} + +type PriceItem struct { + ID int64 `json:"id"` + Name string `json:"name"` + Price float64 `json:"price"` + Unit string `json:"unit,omitempty"` + From bool `json:"from,omitempty"` + ServiceID *int64 `json:"service_id,omitempty"` +} + +// PriceSectionBrief — раздел прайса без позиций: для меню в шапке, +// где нужны только название, адрес и число услуг. +type PriceSectionBrief struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Count int `json:"count"` +} diff --git a/internal/domain/notice.go b/internal/domain/notice.go new file mode 100644 index 0000000..3190691 --- /dev/null +++ b/internal/domain/notice.go @@ -0,0 +1,22 @@ +package domain + +import "time" + +// LeadNotice — заявка в том виде, в каком она уходит администратору уведомлением: +// вместо идентификаторов — названия формы, услуги, врача и адрес филиала. +type LeadNotice struct { + ID int64 + FormCode string + FormTitle string + Recipients []string // адреса из настроек формы; пусто — берутся общие из конфига + Name string + Phone string + Email string + Service string + Doctor string + Branch string + Message string + Extra map[string]any + PageURL string + CreatedAt time.Time +} diff --git a/internal/domain/site.go b/internal/domain/site.go new file mode 100644 index 0000000..05b6e16 --- /dev/null +++ b/internal/domain/site.go @@ -0,0 +1,42 @@ +package domain + +// Banner — слайд или промо-блок. +// +// Layout: image — готовая картинка (текст нарисован на ней, заголовок необязателен); +// text — заголовок и подзаголовок на цветном фоне; doctor — фото врача, плашка с датой, +// заголовок, имя врача (Subtitle) и плашки-преимущества (Chips). Theme — цвет фона. +type Banner struct { + ID int64 `json:"id"` + Position string `json:"position"` + Layout string `json:"layout"` + Theme string `json:"theme"` + Title string `json:"title,omitempty"` + Subtitle string `json:"subtitle,omitempty"` + Badge string `json:"badge,omitempty"` + Chips []string `json:"chips,omitempty"` + Image string `json:"image,omitempty"` + MobileImage string `json:"mobile_image,omitempty"` + LinkURL string `json:"link_url,omitempty"` + ButtonText string `json:"button_text,omitempty"` + Disclaimer string `json:"disclaimer,omitempty"` +} + +// SearchHit — одна находка поиска по сайту. +type SearchHit struct { + Type string `json:"type"` // service | doctor | price | checkup + Title string `json:"title"` + Subtitle string `json:"subtitle,omitempty"` + Path string `json:"path,omitempty"` // для услуги + Slug string `json:"slug,omitempty"` // для врача и чек-апа + Price *float64 `json:"price,omitempty"` + Image string `json:"image,omitempty"` +} + +// SearchResult группирует находки по типам — так их показывает выпадающий поиск. +type SearchResult struct { + Query string `json:"query"` + Services []SearchHit `json:"services"` + Doctors []SearchHit `json:"doctors"` + Prices []SearchHit `json:"prices"` + Checkups []SearchHit `json:"checkups"` +} diff --git a/internal/megafon/client.go b/internal/megafon/client.go new file mode 100644 index 0000000..c1770f1 --- /dev/null +++ b/internal/megafon/client.go @@ -0,0 +1,226 @@ +// Package megafon — клиент REST API Виртуальной АТС МегаФон (раздел «Интеграция с CRM» → «Ваша CRM / REST API»). +// +// Запросы: GET https://<домен>.megapbx.ru/crmapi/v1/<команда>, заголовок X-API-KEY. +// История звонков — /history/json?start=…&end=…&type=…, время периода в UTC, формат 20060102T150405Z. +package megafon + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// ErrUnauthorized — АТС отклонила ключ: он неверный, отозван или API выключен в настройках ВАТС. +var ErrUnauthorized = errors.New("megafon: ключ API не принят") + +type Client struct { + Base string // https://vats970345.megapbx.ru/crmapi/v1 + Key string + HTTP *http.Client +} + +// New собирает адрес API из домена ВАТС или полного адреса. +func New(baseOrDomain, key string) *Client { + b := strings.TrimRight(strings.TrimSpace(baseOrDomain), "/") + if b != "" && !strings.Contains(b, "://") { + b = "https://" + b + } + if b != "" && !strings.HasSuffix(strings.ToLower(b), "/crmapi/v1") { + b += "/crmapi/v1" + } + return &Client{Base: b, Key: strings.TrimSpace(key), HTTP: &http.Client{Timeout: 60 * time.Second}} +} + +func (c *Client) Configured() bool { return c != nil && c.Base != "" && c.Key != "" } + +// Domain — домен ВАТС для показа в админке (без ключа). +func (c *Client) Domain() string { + if c == nil { + return "" + } + if u, err := url.Parse(c.Base); err == nil { + return u.Host + } + return "" +} + +// Call — звонок из истории. Номера — как их прислала АТС. +type Call struct { + UID string + Type string // in | out | missed | … + Status string // success | missed | busy | notavailable | notallowed | … + Client string // номер клиента + Via string // номер, на который звонил клиент или с которого звонили («Через») + User string // логин сотрудника АТС + UserName string // имя сотрудника («DANA - 2ГИС», «КЦ DANA общий») + Start time.Time + Wait int // секунды ожидания + Duration int // секунды разговора + RecordURL string + Raw json.RawMessage +} + +// History — звонки за период [from, to). typ: "" или "all", "in", "out", "missed". +func (c *Client) History(ctx context.Context, from, to time.Time, typ string) ([]Call, error) { + q := url.Values{} + q.Set("start", from.UTC().Format("20060102T150405Z")) + q.Set("end", to.UTC().Format("20060102T150405Z")) + if typ != "" { + q.Set("type", typ) + } + body, err := c.get(ctx, "/history/json", q) + if err != nil { + return nil, err + } + return ParseHistory(body) +} + +// User — сотрудник АТС. +type User struct { + Login string `json:"login"` + Name string `json:"name"` + Ext string `json:"ext"` + Telnum string `json:"telnum"` + Email string `json:"email"` +} + +// Users — сотрудники АТС; заодно проверка, что ключ рабочий. +func (c *Client) Users(ctx context.Context) ([]User, error) { + body, err := c.get(ctx, "/users", nil) + if err != nil { + return nil, err + } + var out []User + if err := json.Unmarshal(body, &out); err != nil { + var wrap struct { + Items []User `json:"items"` + } + if err2 := json.Unmarshal(body, &wrap); err2 != nil { + return nil, fmt.Errorf("megafon: ответ /users не разобран: %w", err) + } + out = wrap.Items + } + return out, nil +} + +func (c *Client) get(ctx context.Context, path string, q url.Values) ([]byte, error) { + if !c.Configured() { + return nil, errors.New("megafon: не задан адрес или ключ API") + } + u := c.Base + path + if len(q) > 0 { + u += "?" + q.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-API-KEY", c.Key) + req.Header.Set("Accept", "application/json") + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("megafon: АТС не ответила: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) + if err != nil { + return nil, err + } + switch { + case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: + return nil, ErrUnauthorized + case resp.StatusCode >= 300: + msg := strings.TrimSpace(string(body)) + if len(msg) > 200 { + msg = msg[:200] + } + return nil, fmt.Errorf("megafon: %s — HTTP %d %s", path, resp.StatusCode, msg) + } + return body, nil +} + +// ParseHistory разбирает ответ /history/json. Разбор терпимый к версиям API: имена полей +// и типы значений (число или строка) у разных ВАТС отличаются; исходная запись сохраняется в Raw. +func ParseHistory(body []byte) ([]Call, error) { + var items []json.RawMessage + if err := json.Unmarshal(body, &items); err != nil { + var wrap struct { + Items []json.RawMessage `json:"items"` + Data []json.RawMessage `json:"data"` + } + if err2 := json.Unmarshal(body, &wrap); err2 != nil { + return nil, fmt.Errorf("megafon: история не разобрана: %w", err) + } + items = append(wrap.Items, wrap.Data...) + } + out := make([]Call, 0, len(items)) + for _, raw := range items { + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + continue + } + c := Call{ + UID: str(m, "uid", "callid", "id"), + Type: strings.ToLower(str(m, "type", "direction")), + Status: strings.ToLower(str(m, "status")), + Client: str(m, "client", "phone", "from"), + Via: str(m, "diversion", "via", "line", "telnum", "to"), + User: str(m, "user", "account", "login"), + UserName: str(m, "user_name", "username", "name"), + Wait: num(m, "wait"), + Duration: num(m, "duration"), + RecordURL: str(m, "record", "link", "record_url"), + Raw: raw, + } + c.Start = parseTime(str(m, "start", "date", "time")) + if c.UID == "" || c.Start.IsZero() { + continue // без идентификатора и времени звонок не сохранить + } + out = append(out, c) + } + return out, nil +} + +func str(m map[string]any, keys ...string) string { + for _, k := range keys { + switch v := m[k].(type) { + case string: + if v = strings.TrimSpace(v); v != "" { + return v + } + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + } + } + return "" +} + +func num(m map[string]any, key string) int { + switch v := m[key].(type) { + case float64: + return int(v) + case string: + n, _ := strconv.Atoi(strings.TrimSpace(v)) + return n + } + return 0 +} + +func parseTime(s string) time.Time { + for _, f := range []string{time.RFC3339, "2006-01-02T15:04:05Z", "20060102T150405Z", "2006-01-02 15:04:05"} { + if t, err := time.Parse(f, s); err == nil { + return t + } + } + if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 1e9 { + return time.Unix(n, 0) + } + return time.Time{} +} diff --git a/internal/megafon/client_test.go b/internal/megafon/client_test.go new file mode 100644 index 0000000..0ca4c97 --- /dev/null +++ b/internal/megafon/client_test.go @@ -0,0 +1,65 @@ +package megafon + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestNewBase(t *testing.T) { + for in, want := range map[string]string{ + "vats970345.megapbx.ru": "https://vats970345.megapbx.ru/crmapi/v1", + "https://vats970345.megapbx.ru/": "https://vats970345.megapbx.ru/crmapi/v1", + "https://vats970345.megapbx.ru/crmapi/v1": "https://vats970345.megapbx.ru/crmapi/v1", + "https://vats970345.megapbx.ru/crmapi/v1/": "https://vats970345.megapbx.ru/crmapi/v1", + } { + if got := New(in, "k").Base; got != want { + t.Errorf("New(%q).Base = %q, want %q", in, got, want) + } + } + if New("", "k").Configured() || New("x.ru", "").Configured() { + t.Error("без адреса или ключа клиент не настроен") + } +} + +func TestHistory(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-API-KEY") != "secret" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.URL.Path != "/crmapi/v1/history/json" || r.URL.Query().Get("start") != "20260920T210000Z" || r.URL.Query().Get("type") != "all" { + t.Errorf("запрос: %s", r.URL.String()) + } + _, _ = w.Write([]byte(`[ + {"uid":"1755936870","type":"in","status":"success","client":"79894787136","diversion":"79280470055", + "user":"kc","user_name":"КЦ DANA общий","start":"2026-09-23T08:22:00Z","wait":14,"duration":"9","record":"https://x/rec.mp3"}, + {"uid":1755936871,"type":"missed","status":"missed","client":"79894787136","via":"+7 (928) 047-00-55","start":"2026-09-23T08:21:00Z","wait":1,"duration":0}, + {"type":"in","client":"79000000000"} + ]`)) + })) + defer srv.Close() + + c := New(srv.URL, "secret") + calls, err := c.History(context.Background(), time.Date(2026, 9, 20, 21, 0, 0, 0, time.UTC), time.Date(2026, 9, 23, 21, 0, 0, 0, time.UTC), "all") + if err != nil { + t.Fatal(err) + } + if len(calls) != 2 { + t.Fatalf("звонков %d, ждали 2 (без uid — пропуск)", len(calls)) + } + a, b := calls[0], calls[1] + if a.UID != "1755936870" || a.Via != "79280470055" || a.UserName != "КЦ DANA общий" || a.Duration != 9 || a.Wait != 14 || a.RecordURL == "" { + t.Errorf("первый звонок: %+v", a) + } + if b.UID != "1755936871" || b.Type != "missed" || b.Via != "+7 (928) 047-00-55" { + t.Errorf("второй звонок: %+v", b) + } + + if _, err := New(srv.URL, "wrong").History(context.Background(), time.Now(), time.Now(), ""); !errors.Is(err, ErrUnauthorized) { + t.Errorf("неверный ключ: %v", err) + } +} diff --git a/internal/notify/email.go b/internal/notify/email.go new file mode 100644 index 0000000..865ed76 --- /dev/null +++ b/internal/notify/email.go @@ -0,0 +1,207 @@ +// Package notify отправляет администраторам уведомления о новых заявках. +// +// Канал — электронная почта через SMTP. Мессенджеры с зарубежными серверами +// (Telegram и т. п.) сюда намеренно не добавлены: в заявке имя, телефон и услуга, +// а политика обработки ПДн клиники (п. 11) заявляет, что трансграничной передачи нет. +package notify + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "mime" + "net" + "net/mail" + "net/smtp" + "strconv" + "strings" + "time" + + "dana/internal/domain" +) + +type Notifier interface { + LeadCreated(ctx context.Context, lead domain.LeadNotice) error +} + +// Email шлёт письмо о заявке. Порт 465 — сразу TLS (так работает smtp.yandex.ru), +// другие порты — обычное соединение с переходом на TLS через STARTTLS. +type Email struct { + Host string + Port int + User string + Password string + From string // «DANA — сайт » или просто адрес + To []string // общие получатели, если в форме свои не заданы + SiteURL string // для ссылки на заявку в админке; можно пустым +} + +var errNoRecipients = errors.New("notify: no recipients") + +func (e *Email) LeadCreated(ctx context.Context, lead domain.LeadNotice) error { + to := validAddresses(lead.Recipients) + if len(to) == 0 { + to = validAddresses(e.To) + } + if len(to) == 0 { + return errNoRecipients + } + from, err := mail.ParseAddress(e.From) + if err != nil { + return fmt.Errorf("notify: bad From %q: %w", e.From, err) + } + + subject := "Заявка с сайта" + if lead.FormTitle != "" { + subject += ": " + lead.FormTitle + } + msg := buildMessage(from, to, subject, leadText(lead, e.SiteURL)) + return e.send(ctx, from.Address, to, msg) +} + +func (e *Email) send(ctx context.Context, from string, to []string, msg []byte) error { + addr := net.JoinHostPort(e.Host, strconv.Itoa(e.Port)) + dialer := &net.Dialer{Timeout: 15 * time.Second} + tlsCfg := &tls.Config{ServerName: e.Host, MinVersion: tls.VersionTLS12} + + var conn net.Conn + var err error + if e.Port == 465 { + conn, err = (&tls.Dialer{NetDialer: dialer, Config: tlsCfg}).DialContext(ctx, "tcp", addr) + } else { + conn, err = dialer.DialContext(ctx, "tcp", addr) + } + if err != nil { + return fmt.Errorf("notify: dial %s: %w", addr, err) + } + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + c, err := smtp.NewClient(conn, e.Host) + if err != nil { + conn.Close() + return fmt.Errorf("notify: smtp handshake: %w", err) + } + defer c.Close() + + if e.Port != 465 { + if ok, _ := c.Extension("STARTTLS"); ok { + if err := c.StartTLS(tlsCfg); err != nil { + return fmt.Errorf("notify: starttls: %w", err) + } + } + } + if e.User != "" { + if err := c.Auth(smtp.PlainAuth("", e.User, e.Password, e.Host)); err != nil { + return fmt.Errorf("notify: auth: %w", err) + } + } + if err := c.Mail(from); err != nil { + return fmt.Errorf("notify: MAIL FROM: %w", err) + } + for _, rcpt := range to { + if err := c.Rcpt(rcpt); err != nil { + return fmt.Errorf("notify: RCPT TO %s: %w", rcpt, err) + } + } + w, err := c.Data() + if err != nil { + return fmt.Errorf("notify: DATA: %w", err) + } + if _, err := w.Write(msg); err != nil { + w.Close() + return fmt.Errorf("notify: write: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("notify: finish DATA: %w", err) + } + return c.Quit() +} + +func validAddresses(list []string) []string { + out := make([]string, 0, len(list)) + for _, raw := range list { + if a, err := mail.ParseAddress(strings.TrimSpace(raw)); err == nil { + out = append(out, a.Address) + } + } + return out +} + +// oneLine убирает переводы строк: значения из формы не должны превращаться в заголовки письма. +func oneLine(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +func leadText(l domain.LeadNotice, siteURL string) string { + var b strings.Builder + fmt.Fprintf(&b, "Новая заявка № %d\n", l.ID) + if !l.CreatedAt.IsZero() { + fmt.Fprintf(&b, "Время: %s\n", l.CreatedAt.In(moscow).Format("02.01.2006 15:04")) + } + b.WriteString("\n") + + line := func(label, value string) { + if value = strings.TrimSpace(value); value != "" { + fmt.Fprintf(&b, "%s: %s\n", label, value) + } + } + line("Форма", l.FormTitle) + line("Имя", oneLine(l.Name)) + line("Телефон", oneLine(l.Phone)) + line("E-mail", oneLine(l.Email)) + line("Услуга", l.Service) + line("Врач", l.Doctor) + line("Филиал", l.Branch) + if v, ok := l.Extra["date"].(string); ok { + line("Желаемая дата", oneLine(v)) + } + if v, ok := l.Extra["time"].(string); ok { + line("Удобное время", oneLine(v)) + } + if v, ok := l.Extra["rating"].(float64); ok { + line("Оценка", strconv.Itoa(int(v))) + } + if msg := strings.TrimSpace(l.Message); msg != "" { + fmt.Fprintf(&b, "\nСообщение:\n%s\n", msg) + } + b.WriteString("\n") + line("Страница", l.PageURL) + if siteURL != "" { + line("Все заявки", strings.TrimRight(siteURL, "/")+"/admin/") + } + return b.String() +} + +var moscow = func() *time.Location { + if loc, err := time.LoadLocation("Europe/Moscow"); err == nil { + return loc + } + return time.FixedZone("MSK", 3*60*60) +}() + +func buildMessage(from *mail.Address, to []string, subject, body string) []byte { + var buf bytes.Buffer + header := func(k, v string) { fmt.Fprintf(&buf, "%s: %s\r\n", k, v) } + + header("From", from.String()) + header("To", strings.Join(to, ", ")) + header("Subject", mime.BEncoding.Encode("utf-8", oneLine(subject))) + header("Date", time.Now().Format(time.RFC1123Z)) + header("MIME-Version", "1.0") + header("Content-Type", "text/plain; charset=utf-8") + header("Content-Transfer-Encoding", "base64") + buf.WriteString("\r\n") + + encoded := base64.StdEncoding.EncodeToString([]byte(body)) + for len(encoded) > 76 { + buf.WriteString(encoded[:76] + "\r\n") + encoded = encoded[76:] + } + buf.WriteString(encoded + "\r\n") + return buf.Bytes() +} diff --git a/internal/notify/email_test.go b/internal/notify/email_test.go new file mode 100644 index 0000000..b293484 --- /dev/null +++ b/internal/notify/email_test.go @@ -0,0 +1,166 @@ +package notify + +import ( + "bufio" + "context" + "encoding/base64" + "net" + "strconv" + "strings" + "testing" + "time" + + "dana/internal/domain" +) + +// fakeSMTP — минимальный SMTP-сервер без TLS и авторизации: принимает одно письмо. +func fakeSMTP(t *testing.T) (host string, port int, got <-chan string) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + + ch := make(chan string, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + r := bufio.NewReader(conn) + w := func(s string) { conn.Write([]byte(s + "\r\n")) } + + w("220 fake") + var transcript strings.Builder + inData := false + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + if inData { + if line == ".\r\n" { + inData = false + w("250 queued") + continue + } + transcript.WriteString(line) + continue + } + cmd := strings.ToUpper(strings.TrimSpace(line)) + switch { + case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"): + w("250 fake") + case strings.HasPrefix(cmd, "MAIL FROM"), strings.HasPrefix(cmd, "RCPT TO"): + transcript.WriteString(strings.TrimSpace(line) + "\n") + w("250 ok") + case cmd == "DATA": + inData = true + w("354 go") + case cmd == "QUIT": + w("221 bye") + ch <- transcript.String() + return + default: + w("250 ok") + } + } + }() + + addr := ln.Addr().(*net.TCPAddr) + return "127.0.0.1", addr.Port, ch +} + +func TestEmailLeadCreated(t *testing.T) { + host, port, got := fakeSMTP(t) + e := &Email{Host: host, Port: port, From: "DANA сайт ", To: []string{"admin@example.ru"}, SiteURL: "https://danamedical.ru"} + + lead := domain.LeadNotice{ + ID: 42, + FormTitle: "Запись на приём", + Name: "Иван\r\nBcc: evil@example.com", + Phone: "+7 (928) 000-00-99", + Service: "Консультация педиатра", + Extra: map[string]any{"date": "2026-09-20", "time": "Утро, 08:00–12:00"}, + Message: "Тестовая заявка", + PageURL: "/services/detskaya-klinika/priem-pediatra/", + CreatedAt: time.Date(2026, 9, 15, 9, 30, 0, 0, time.UTC), + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := e.LeadCreated(ctx, lead); err != nil { + t.Fatalf("LeadCreated: %v", err) + } + + var transcript string + select { + case transcript = <-got: + case <-time.After(5 * time.Second): + t.Fatal("fake SMTP did not receive message") + } + + if !strings.Contains(transcript, "MAIL FROM:") || !strings.Contains(transcript, "RCPT TO:") { + t.Fatalf("envelope not as expected:\n%s", transcript) + } + // перевод строки в имени не должен стать заголовком письма + if strings.Contains(transcript, "\r\nBcc:") { + t.Fatalf("header injection:\n%s", transcript) + } + + body := decodeBody(t, transcript) + for _, want := range []string{ + "Новая заявка № 42", + "Время: 15.09.2026 12:30", + "Форма: Запись на приём", + "Имя: Иван Bcc: evil@example.com", + "Телефон: +7 (928) 000-00-99", + "Услуга: Консультация педиатра", + "Желаемая дата: 2026-09-20", + "Удобное время: Утро, 08:00–12:00", + "Тестовая заявка", + "Все заявки: https://danamedical.ru/admin/", + } { + if !strings.Contains(body, want) { + t.Errorf("body has no %q:\n%s", want, body) + } + } +} + +func TestFormRecipientsOverrideDefault(t *testing.T) { + host, port, got := fakeSMTP(t) + e := &Email{Host: host, Port: port, From: "site@example.ru", To: []string{"default@example.ru"}} + lead := domain.LeadNotice{ID: 1, Recipients: []string{"doctors@example.ru", "не адрес"}} + + if err := e.LeadCreated(context.Background(), lead); err != nil { + t.Fatal(err) + } + transcript := <-got + if !strings.Contains(transcript, "RCPT TO:") || strings.Contains(transcript, "default@example.ru") { + t.Fatalf("recipients of the form must replace defaults:\n%s", transcript) + } +} + +func TestNoRecipients(t *testing.T) { + e := &Email{Host: "127.0.0.1", Port: 1, From: "site@example.ru"} + if err := e.LeadCreated(context.Background(), domain.LeadNotice{ID: 1}); err != errNoRecipients { + t.Fatalf("want errNoRecipients, got %v", err) + } +} + +func decodeBody(t *testing.T, transcript string) string { + t.Helper() + parts := strings.SplitN(transcript, "\r\n\r\n", 2) + if len(parts) != 2 { + t.Fatalf("no body separator:\n%s", transcript) + } + raw, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(parts[1], "\r\n", "")) + if err != nil { + t.Fatalf("decode body: %v", err) + } + return string(raw) +} + +var _ = strconv.Itoa diff --git a/internal/onec/client.go b/internal/onec/client.go new file mode 100644 index 0000000..53050fc --- /dev/null +++ b/internal/onec/client.go @@ -0,0 +1,297 @@ +// Package onec — клиент HTTP-сервиса 1С: филиалы, врачи, расписание и запись на приём. +// Контракт описан в ТЗ «расписание врачей и запись из 1С»; адреса методов в 1С могут отличаться — +// тогда правится только этот файл. +package onec + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +var ( + ErrUnauthorized = errors.New("1С не приняла ключ доступа") + ErrSlotBusy = errors.New("окно уже занято") + ErrDoctorNotFound = errors.New("врач не найден в 1С") + ErrValidation = errors.New("1С отклонила данные записи") + ErrUnavailable = errors.New("1С недоступна") +) + +type Client struct { + Base string + Key string + AuthHeader string + HTTP *http.Client +} + +func New(base, key, authHeader string) *Client { + if authHeader == "" { + authHeader = "X-Api-Key" + } + return &Client{ + Base: strings.TrimRight(strings.TrimSpace(base), "/"), + Key: strings.TrimSpace(key), + AuthHeader: authHeader, + HTTP: &http.Client{Timeout: 20 * time.Second}, + } +} + +func (c *Client) Configured() bool { return c != nil && c.Base != "" && c.Key != "" } + +// Host — адрес 1С для показа в админке (без ключа и пути). +func (c *Client) Host() string { + if c == nil { + return "" + } + if u, err := url.Parse(c.Base); err == nil { + return u.Host + } + return "" +} + +type Branch struct { + ID string `json:"id"` + Name string `json:"name"` + Address string `json:"address"` +} + +type Doctor struct { + ID string `json:"id"` + Name string `json:"name"` + Specialties []string `json:"specialties"` + BranchIDs []string `json:"branch_ids"` + KidsFrom *int `json:"kids_from"` + Active *bool `json:"active"` +} + +func (d Doctor) IsActive() bool { return d.Active == nil || *d.Active } + +type Service struct { + ID string `json:"id"` + Name string `json:"name"` + DurationMin int `json:"duration_min"` + Price float64 `json:"price"` +} + +type Slot struct { + DoctorID string `json:"doctor_id"` + BranchID string `json:"branch_id"` + Cabinet string `json:"cabinet"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + Status string `json:"status"` // free | busy + SlotID string `json:"slot_id"` +} + +func (s Slot) Free() bool { return s.Status == "free" } + +type AppointmentRequest struct { + RequestID string `json:"request_id"` + DoctorID string `json:"doctor_id"` + BranchID string `json:"branch_id"` + ServiceID string `json:"service_id,omitempty"` + SlotID string `json:"slot_id,omitempty"` + Start time.Time `json:"start"` + Name string `json:"patient_name"` + Phone string `json:"patient_phone"` + BirthDate string `json:"birth_date,omitempty"` + Comment string `json:"comment,omitempty"` + Source string `json:"source"` +} + +type Appointment struct { + ID string `json:"appointment_id"` + Status string `json:"status"` // booked | arrived | cancelled + Start time.Time `json:"start"` + End time.Time `json:"end"` +} + +func (c *Client) Branches(ctx context.Context) ([]Branch, error) { + var out []Branch + return out, c.getList(ctx, "/branches", nil, "branches", &out) +} + +func (c *Client) Doctors(ctx context.Context) ([]Doctor, error) { + var out []Doctor + return out, c.getList(ctx, "/doctors", nil, "doctors", &out) +} + +func (c *Client) Services(ctx context.Context, doctorID string) ([]Service, error) { + var out []Service + return out, c.getList(ctx, "/doctors/"+url.PathEscape(doctorID)+"/services", nil, "services", &out) +} + +// Schedule — окна за период [from, to]; doctorID пустой — по всем врачам. +func (c *Client) Schedule(ctx context.Context, from, to time.Time, doctorID string) ([]Slot, error) { + q := url.Values{} + q.Set("from", from.Format(time.RFC3339)) + q.Set("to", to.Format(time.RFC3339)) + if doctorID != "" { + q.Set("doctor_id", doctorID) + } + var out []Slot + return out, c.getList(ctx, "/schedule", q, "slots", &out) +} + +// Changes — long polling: 1С держит запрос до wait, пока версия расписания не станет больше since, +// и отвечает {"version": N, "doctor_ids": [...]} (пустой doctor_ids — изменилось у многих). +func (c *Client) Changes(ctx context.Context, since int64, wait time.Duration) (int64, []string, error) { + q := url.Values{} + q.Set("since", fmt.Sprint(since)) + q.Set("wait", fmt.Sprint(int(wait.Seconds()))) + raw, err := c.doWith(ctx, &http.Client{Timeout: wait + 10*time.Second}, http.MethodGet, "/changes", q, nil) + if err != nil { + return since, nil, err + } + var out struct { + Version int64 `json:"version"` + DoctorIDs []string `json:"doctor_ids"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return since, nil, fmt.Errorf("onec: /changes — ответ не разобран: %s", short(raw)) + } + return out.Version, out.DoctorIDs, nil +} + +func (c *Client) Book(ctx context.Context, req AppointmentRequest) (*Appointment, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + raw, err := c.do(ctx, http.MethodPost, "/appointments", nil, body) + if err != nil { + return nil, err + } + var a Appointment + if err := json.Unmarshal(raw, &a); err != nil || a.ID == "" { + return nil, fmt.Errorf("onec: ответ на запись не разобран: %s", short(raw)) + } + return &a, nil +} + +func (c *Client) Appointment(ctx context.Context, id string) (*Appointment, error) { + raw, err := c.do(ctx, http.MethodGet, "/appointments/"+url.PathEscape(id), nil, nil) + if err != nil { + return nil, err + } + var a Appointment + if err := json.Unmarshal(raw, &a); err != nil { + return nil, fmt.Errorf("onec: статус записи не разобран: %s", short(raw)) + } + return &a, nil +} + +func (c *Client) Cancel(ctx context.Context, id string) error { + _, err := c.do(ctx, http.MethodDelete, "/appointments/"+url.PathEscape(id), nil, nil) + return err +} + +// getList принимает ответ и в виде массива, и в виде объекта {"": [...]}. +func (c *Client) getList(ctx context.Context, path string, q url.Values, key string, out any) error { + raw, err := c.do(ctx, http.MethodGet, path, q, nil) + if err != nil { + return err + } + trimmed := bytes.TrimSpace(raw) + if len(trimmed) > 0 && trimmed[0] == '[' { + return json.Unmarshal(trimmed, out) + } + var wrap map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &wrap); err != nil { + return fmt.Errorf("onec: %s — ответ не разобран: %s", path, short(raw)) + } + part, found := wrap[key] + if !found { + return fmt.Errorf("onec: %s — в ответе нет поля %q", path, key) + } + return json.Unmarshal(part, out) +} + +func (c *Client) do(ctx context.Context, method, path string, q url.Values, body []byte) ([]byte, error) { + return c.doWith(ctx, c.HTTP, method, path, q, body) +} + +func (c *Client) doWith(ctx context.Context, hc *http.Client, method, path string, q url.Values, body []byte) ([]byte, error) { + if !c.Configured() { + return nil, errors.New("onec: не задан адрес или ключ 1С") + } + u := c.Base + path + if len(q) > 0 { + u += "?" + q.Encode() + } + var rd io.Reader + if body != nil { + rd = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, u, rd) + if err != nil { + return nil, err + } + req.Header.Set(c.AuthHeader, c.Key) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json; charset=utf-8") + } + resp, err := hc.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + if resp.StatusCode/100 == 2 { + return raw, nil + } + return nil, apiError(resp.StatusCode, raw) +} + +// apiError превращает ошибку 1С ({"code":"slot_busy","message":"…"} или {"error":{…}}) в типизированную. +func apiError(status int, raw []byte) error { + var e struct { + Code string `json:"code"` + Message string `json:"message"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + _ = json.Unmarshal(raw, &e) + code, msg := e.Code, e.Message + if e.Error != nil { + code, msg = e.Error.Code, e.Error.Message + } + var base error + switch { + case code == "slot_busy" || status == http.StatusConflict: + base = ErrSlotBusy + case code == "doctor_not_found" || status == http.StatusNotFound: + base = ErrDoctorNotFound + case code == "unauthorized" || status == http.StatusUnauthorized || status == http.StatusForbidden: + base = ErrUnauthorized + case code == "validation" || status == http.StatusBadRequest: + base = ErrValidation + default: + base = ErrUnavailable + } + if msg == "" { + msg = short(raw) + } + return fmt.Errorf("%w (HTTP %d): %s", base, status, msg) +} + +func short(b []byte) string { + s := strings.TrimSpace(string(b)) + if r := []rune(s); len(r) > 200 { + s = string(r[:200]) + "…" + } + return s +} diff --git a/internal/onec/client_test.go b/internal/onec/client_test.go new file mode 100644 index 0000000..099597e --- /dev/null +++ b/internal/onec/client_test.go @@ -0,0 +1,93 @@ +package onec + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func newFake(t *testing.T, h http.HandlerFunc) *Client { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return New(srv.URL, "secret", "") +} + +func TestClientSendsKeyAndParsesBothShapes(t *testing.T) { + c := newFake(t, func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Api-Key") != "secret" { + http.Error(w, `{"code":"unauthorized","message":"нет ключа"}`, http.StatusUnauthorized) + return + } + switch r.URL.Path { + case "/branches": + _, _ = w.Write([]byte(`[{"id":"b1","name":"Ляхова, 22","address":"ул. Ляхова, 22"}]`)) + case "/doctors": + _, _ = w.Write([]byte(`{"doctors":[{"id":"d1","name":"Иванов Иван Иванович","branch_ids":["b1"],"active":false}]}`)) + case "/schedule": + if r.URL.Query().Get("doctor_id") != "d1" { + t.Errorf("doctor_id не передан: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"slots":[{"doctor_id":"d1","branch_id":"b1","start":"2026-10-01T10:00:00+03:00","end":"2026-10-01T10:30:00+03:00","status":"free"}]}`)) + } + }) + ctx := context.Background() + br, err := c.Branches(ctx) + if err != nil || len(br) != 1 || br[0].ID != "b1" { + t.Fatalf("branches: %v %+v", err, br) + } + docs, err := c.Doctors(ctx) + if err != nil || len(docs) != 1 || docs[0].IsActive() { + t.Fatalf("doctors: %v %+v", err, docs) + } + from := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + slots, err := c.Schedule(ctx, from, from.Add(24*time.Hour), "d1") + if err != nil || len(slots) != 1 || !slots[0].Free() { + t.Fatalf("schedule: %v %+v", err, slots) + } + + bad := New(c.Base, "wrong", "") + if _, err := bad.Branches(ctx); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("ждали ErrUnauthorized, получили %v", err) + } +} + +func TestBookErrors(t *testing.T) { + cases := []struct { + status int + body string + want error + }{ + {409, `{"code":"slot_busy","message":"Окно занято"}`, ErrSlotBusy}, + {404, `{"error":{"code":"doctor_not_found","message":"нет врача"}}`, ErrDoctorNotFound}, + {400, `{"code":"validation","message":"нет телефона"}`, ErrValidation}, + {500, `oops`, ErrUnavailable}, + } + for _, tc := range cases { + c := newFake(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + }) + _, err := c.Book(context.Background(), AppointmentRequest{DoctorID: "d1"}) + if !errors.Is(err, tc.want) { + t.Errorf("HTTP %d: ждали %v, получили %v", tc.status, tc.want, err) + } + } +} + +func TestBookSuccess(t *testing.T) { + c := newFake(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/appointments" { + t.Errorf("не тот запрос: %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"appointment_id":"A-1","status":"booked","start":"2026-10-01T10:00:00+03:00","end":"2026-10-01T10:30:00+03:00"}`)) + }) + a, err := c.Book(context.Background(), AppointmentRequest{DoctorID: "d1", Source: "Сайт"}) + if err != nil || a.ID != "A-1" || a.Status != "booked" { + t.Fatalf("book: %v %+v", err, a) + } +} diff --git a/internal/prodoctorov/client.go b/internal/prodoctorov/client.go new file mode 100644 index 0000000..6e51912 --- /dev/null +++ b/internal/prodoctorov/client.go @@ -0,0 +1,111 @@ +// Package prodoctorov reads the cabinet's authenticated online-appointment export. +// This is NOT a public API key integration and does not export telephone calls. +package prodoctorov + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +const downloadURL = "https://prodoctorov.ru/api/cabinet/lpu/appointments/direct/download/" +const maxDownload = 10 << 20 + +type Client struct { + cookie, lpu string + http *http.Client + endpoint string +} + +// New keeps the cabinet session on the server. Both empty values disable the +// integration. Supplying only one, or malformed credentials, is a config error. +func New(cookie, lpu string) (*Client, error) { + lpu = strings.TrimSpace(lpu) + if cookie == "" && lpu == "" { + return nil, nil + } + if strings.TrimSpace(cookie) == "" || strings.ContainsAny(cookie, "\r\n") || + len(cookie) > 16384 || !strings.Contains(cookie, "=") || + !regexp.MustCompile(`^[1-9][0-9]{0,17}$`).MatchString(lpu) { + return nil, fmt.Errorf("ПроДокторов: задайте корректные PRODOCTOROV_COOKIE и PRODOCTOROV_LPU_ID на сервере") + } + return &Client{cookie: cookie, lpu: lpu, endpoint: downloadURL, http: &http.Client{ + Timeout: 45 * time.Second, + // Never forward the cabinet session through a redirect, even same-host: + // login redirects are an explicit expired-session error, not empty data. + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }}, nil +} + +func (c *Client) Configured() bool { return c != nil && c.cookie != "" && c.lpu != "" } + +// Download returns XLSX bytes for one calendar month. No body, cookies, patient +// values, or response URLs are included in errors or logs. +func (c *Client) Download(ctx context.Context, year int, month time.Month) ([]byte, error) { + if !c.Configured() { + return nil, fmt.Errorf("ПроДокторов не подключён") + } + if year < 2000 || year > 2100 || month < time.January || month > time.December { + return nil, fmt.Errorf("ПроДокторов: некорректный месяц выгрузки") + } + u, err := url.Parse(c.endpoint) + if err != nil { + return nil, fmt.Errorf("ПроДокторов: некорректный адрес выгрузки") + } + q := u.Query() + q.Set("month", strconv.Itoa(int(month))) + q.Set("year", strconv.Itoa(year)) + q.Set("lpu", c.lpu) + u.RawQuery = q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("ПроДокторов: не удалось подготовить запрос") + } + req.Header.Set("Cookie", c.cookie) + req.Header.Set("Accept", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + res, err := c.http.Do(req) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("ПроДокторов: не удалось получить выгрузку (соединение или таймаут)") + } + defer res.Body.Close() + // Servicepipe can reject the request before it reaches the cabinet. This + // does not establish that the cabinet session has expired. Do not retry a + // challenge, expose its body, or publish it as an empty appointment export. + protected := res.Header.Get("X-SP-CRID") != "" + if res.StatusCode != http.StatusOK { + if res.StatusCode == http.StatusForbidden && protected { + return nil, fmt.Errorf("ПроДокторов: доступ запрещён (HTTP 403, ответ через Servicepipe); это не подтверждает истечение сессии. Проверьте полный Cookie; при повторном отказе согласуйте серверную выгрузку с поддержкой ПроДокторов") + } + if res.StatusCode == http.StatusUnauthorized || res.StatusCode >= 300 && res.StatusCode < 400 { + return nil, fmt.Errorf("ПроДокторов: кабинет не авторизовал выгрузку (HTTP %d); войдите заново и обновите полный Cookie", res.StatusCode) + } + if res.StatusCode == http.StatusForbidden { + return nil, fmt.Errorf("ПроДокторов: доступ к выгрузке не получен (HTTP %d); проверьте сессию кабинета и защиту сайта", res.StatusCode) + } + return nil, fmt.Errorf("ПроДокторов: ошибка выгрузки (HTTP %d)", res.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(res.Body, maxDownload+1)) + if err != nil { + return nil, fmt.Errorf("ПроДокторов: файл не дочитался") + } + if len(data) > maxDownload { + return nil, fmt.Errorf("ПроДокторов: файл больше 10 МБ") + } + if !bytes.HasPrefix(data, []byte{'P', 'K', 3, 4}) { + if protected { + return nil, fmt.Errorf("ПроДокторов: вместо XLSX получена другая страница через Servicepipe; автоматическая выгрузка не выполнена") + } + return nil, fmt.Errorf("ПроДокторов: вместо XLSX получена другая страница; проверьте сессию кабинета и защиту сайта") + } + return data, nil +} diff --git a/internal/prodoctorov/client_test.go b/internal/prodoctorov/client_test.go new file mode 100644 index 0000000..5e2517f --- /dev/null +++ b/internal/prodoctorov/client_test.go @@ -0,0 +1,145 @@ +package prodoctorov + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestConfig(t *testing.T) { + c, err := New("", "") + if err != nil || c.Configured() { + t.Fatal("empty configuration must disable the connector") + } + for _, args := range [][2]string{{"", "82347"}, {"session=test", ""}, {"session=x\r\nInjected: bad", "82347"}, {"session=test", "-1"}, {"session=test", "82347&lpu=other"}, {"Cookie", "82347"}} { + if _, err := New(args[0], args[1]); err == nil { + t.Fatal("malformed configuration accepted") + } + } +} + +func TestDownloadQueryAndCookie(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/cabinet/lpu/appointments/direct/download/" || r.Method != "GET" || + r.URL.Query().Get("month") != "10" || r.URL.Query().Get("year") != "2026" || r.URL.Query().Get("lpu") != "82347" || + r.Header.Get("Cookie") != "session=synthetic" { + t.Error("wrong export request") + } + _, _ = w.Write([]byte{'P', 'K', 3, 4, 'x'}) + })) + defer s.Close() + c, _ := New("session=synthetic", "82347") + c.endpoint = s.URL + "/api/cabinet/lpu/appointments/direct/download/" + data, err := c.Download(context.Background(), 2026, time.October) + if err != nil || !bytes.HasPrefix(data, []byte("PK")) { + t.Fatal("valid export failed", err) + } +} + +func TestDownloadErrorsDoNotLeakResponse(t *testing.T) { + for _, status := range []int{200, 401, 403, 429, 500} { + t.Run(http.StatusText(status), func(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = io.WriteString(w, "patient-secret session=synthetic") + })) + defer s.Close() + c, _ := New("session=synthetic", "82347") + c.endpoint = s.URL + _, err := c.Download(context.Background(), 2026, time.October) + if err == nil || strings.Contains(err.Error(), "patient-secret") || strings.Contains(err.Error(), "synthetic") { + t.Fatal("missing or leaking error") + } + }) + } +} + +func TestDownloadProtectionIsNotExpiredSession(t *testing.T) { + for _, tc := range []struct { + name string + status int + body string + want string + }{ + {"forbidden", http.StatusForbidden, "private challenge", "HTTP 403, ответ через Servicepipe"}, + {"challenge page", http.StatusOK, "private challenge", "другая страница через Servicepipe"}, + } { + t.Run(tc.name, func(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-SP-CRID", "private-request-id") + w.WriteHeader(tc.status) + _, _ = io.WriteString(w, tc.body) + })) + defer s.Close() + c, _ := New("session=synthetic", "82347") + c.endpoint = s.URL + _, err := c.Download(context.Background(), 2026, time.October) + if err == nil || !strings.Contains(err.Error(), tc.want) || + strings.Contains(err.Error(), "private") || strings.Contains(err.Error(), "synthetic") { + t.Fatal("protection was misreported or secret data leaked") + } + }) + } +} + +func TestDownloadProtectionHeaderDoesNotRejectXLSX(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-SP-CRID", "synthetic-request-id") + _, _ = w.Write([]byte{'P', 'K', 3, 4, 'x'}) + })) + defer s.Close() + c, _ := New("session=synthetic", "82347") + c.endpoint = s.URL + if _, err := c.Download(context.Background(), 2026, time.October); err != nil { + t.Fatal("valid export rejected solely because of proxy header", err) + } +} + +func TestRedirectDoesNotForwardSession(t *testing.T) { + for _, sameHost := range []bool{false, true} { + calls := 0 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ })) + var source *httptest.Server + source = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/login" { + calls++ + return + } + location := target.URL + if sameHost { + location = source.URL + "/login" + } + http.Redirect(w, r, location, http.StatusFound) + })) + c, _ := New("session=synthetic", "82347") + c.endpoint = source.URL + _, err := c.Download(context.Background(), 2026, time.October) + source.Close() + target.Close() + if err == nil || calls != 0 { + t.Fatal("redirect followed or accepted") + } + } +} + +func TestDownloadBoundAndCancellation(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(append([]byte{'P', 'K', 3, 4}, bytes.Repeat([]byte{'x'}, maxDownload)...)) + })) + defer s.Close() + c, _ := New("session=synthetic", "82347") + c.endpoint = s.URL + if _, err := c.Download(context.Background(), 2026, time.October); err == nil { + t.Fatal("oversized export accepted") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := c.Download(ctx, 2026, time.October); err != context.Canceled { + t.Fatal("cancellation lost", err) + } +} diff --git a/internal/sberhealth/biff.go b/internal/sberhealth/biff.go new file mode 100644 index 0000000..c0d57a5 --- /dev/null +++ b/internal/sberhealth/biff.go @@ -0,0 +1,470 @@ +package sberhealth + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "math" + "strconv" + "unicode/utf16" + + "github.com/extrame/ole2" +) + +// Минимальный читатель книг Excel 97–2003 (BIFF8). Нужен потому, что общая таблица строк (SST) +// у больших выгрузок разбивается на записи CONTINUE посреди строки, и библиотека extrame/xls на +// этих границах смещает значения: часть ячеек получала чужие данные, а все строки ниже сдвигались. +// Здесь строки собираются строго по спецификации: у продолжения строки первым байтом идёт флаг +// кодировки (1 или 2 байта на символ), а блоки форматирования и фонетики могут пересекать границу. +// +// Поддерживаются только ячейки, которые встречаются в выгрузках: LABELSST, LABEL, NUMBER, RK, MULRK +// (и пустые BLANK/MULBLANK). Формулы, ошибки и прочее — явная ошибка: данные нельзя тихо терять. + +const ( + recEOF = 0x000A + recFormula = 0x0006 + recBoundSheet = 0x0085 + recMulRK = 0x00BD + recMulBlank = 0x00BE + recRString = 0x00D6 + recSST = 0x00FC + recLabelSST = 0x00FD + recContinue = 0x003C + recBlank = 0x0201 + recNumber = 0x0203 + recLabel = 0x0204 + recBoolErr = 0x0205 + recString = 0x0207 + recRK = 0x027E + recBOF = 0x0809 + + biff8Version = 0x0600 + maxSheetRows = 50000 + maxSheetCols = 100 + maxSSTItems = 1_000_000 +) + +type record struct { + id uint16 + data []byte + off int +} + +type biffSheet struct { + name string + rows [][]string +} + +// workbookStream достаёт из контейнера OLE2 поток Workbook (или Book) целиком. +func workbookStream(data []byte) ([]byte, error) { + o, err := ole2.Open(bytes.NewReader(data), "utf-8") + if err != nil || o == nil { + return nil, fmt.Errorf("контейнер XLS не открылся") + } + dir, err := o.ListDir() + if err != nil { + return nil, fmt.Errorf("каталог XLS не прочитан") + } + var book, root *ole2.File + for _, f := range dir { + switch f.Name() { + case "Workbook", "Book": + book = f + case "Root Entry": + root = f + } + } + if book == nil { + return nil, fmt.Errorf("в XLS нет потока Workbook") + } + stream, err := io.ReadAll(io.LimitReader(o.OpenFile(book, root), maxDownload+1)) + if err != nil { + return nil, fmt.Errorf("поток Workbook не дочитался") + } + if len(stream) > maxDownload { + return nil, fmt.Errorf("поток Workbook слишком велик") + } + return stream, nil +} + +func readRecords(stream []byte) ([]record, error) { + var recs []record + for pos := 0; pos < len(stream); { + if pos+4 > len(stream) { + if allZero(stream[pos:]) { + break + } + return nil, fmt.Errorf("оборванная запись в конце книги") + } + id := binary.LittleEndian.Uint16(stream[pos:]) + n := int(binary.LittleEndian.Uint16(stream[pos+2:])) + if id == 0 && n == 0 && allZero(stream[pos:]) { + break // выравнивание нулями после последней записи + } + if pos+4+n > len(stream) { + return nil, fmt.Errorf("запись выходит за пределы книги") + } + recs = append(recs, record{id: id, data: stream[pos+4 : pos+4+n], off: pos}) + pos += 4 + n + if len(recs) > 2_000_000 { + return nil, fmt.Errorf("слишком много записей в книге") + } + } + return recs, nil +} + +func allZero(b []byte) bool { + for _, v := range b { + if v != 0 { + return false + } + } + return true +} + +// chunkReader читает данные, разбитые на записи SST и CONTINUE. +type chunkReader struct { + chunks [][]byte + ci int + pos int +} + +func (r *chunkReader) avail() int { + if r.ci >= len(r.chunks) { + return 0 + } + return len(r.chunks[r.ci]) - r.pos +} + +// advance переходит к следующему непустому фрагменту; false — данные кончились. +func (r *chunkReader) advance() bool { + for { + r.ci++ + r.pos = 0 + if r.ci >= len(r.chunks) { + return false + } + if len(r.chunks[r.ci]) > 0 { + return true + } + } +} + +// take отдаёт n непрерывных байт из текущего фрагмента (заголовки полей через границу не переходят). +func (r *chunkReader) take(n int) ([]byte, error) { + if r.avail() == 0 && !r.advance() { + return nil, fmt.Errorf("таблица строк оборвана") + } + if r.avail() < n { + return nil, fmt.Errorf("поле таблицы строк разорвано границей записи") + } + b := r.chunks[r.ci][r.pos : r.pos+n] + r.pos += n + return b, nil +} + +// skip пропускает n байт, переходя между фрагментами без флага кодировки. +func (r *chunkReader) skip(n int) error { + for n > 0 { + if r.avail() == 0 && !r.advance() { + return fmt.Errorf("таблица строк оборвана при пропуске форматирования") + } + k := min(n, r.avail()) + r.pos += k + n -= k + } + return nil +} + +// readString читает одну строку Unicode (cch, флаги, символы, блоки форматирования/фонетики). +func (r *chunkReader) readString() (string, error) { + head, err := r.take(3) + if err != nil { + return "", err + } + cch := int(binary.LittleEndian.Uint16(head)) + flags := head[2] + high := flags&0x01 != 0 + runs, ext := 0, 0 + if flags&0x08 != 0 { + b, err := r.take(2) + if err != nil { + return "", err + } + runs = int(binary.LittleEndian.Uint16(b)) + } + if flags&0x04 != 0 { + b, err := r.take(4) + if err != nil { + return "", err + } + ext = int(binary.LittleEndian.Uint32(b)) + } + units := make([]uint16, 0, cch) + for remaining := cch; remaining > 0; { + if r.avail() == 0 { + // Продолжение строки: первый байт нового фрагмента — флаг кодировки. + if !r.advance() { + return "", fmt.Errorf("таблица строк оборвана внутри строки") + } + b, err := r.take(1) + if err != nil { + return "", err + } + high = b[0]&0x01 != 0 + } + width := 1 + if high { + width = 2 + } + n := min(remaining, r.avail()/width) + if n == 0 { + return "", fmt.Errorf("символ разорван границей записи") + } + raw, _ := r.take(n * width) + for i := 0; i < n; i++ { + if high { + units = append(units, binary.LittleEndian.Uint16(raw[i*2:])) + } else { + units = append(units, uint16(raw[i])) + } + } + remaining -= n + } + if err := r.skip(runs*4 + ext); err != nil { + return "", err + } + return string(utf16.Decode(units)), nil +} + +// parseSST разбирает общую таблицу строк, начиная с записи SST (i) и её CONTINUE. +func parseSST(recs []record, i int) ([]string, error) { + data := recs[i].data + if len(data) < 8 { + return nil, fmt.Errorf("слишком короткая таблица строк") + } + unique := int(binary.LittleEndian.Uint32(data[4:])) + if unique < 0 || unique > maxSSTItems { + return nil, fmt.Errorf("неверный размер таблицы строк") + } + chunks := [][]byte{data[8:]} + for j := i + 1; j < len(recs) && recs[j].id == recContinue; j++ { + chunks = append(chunks, recs[j].data) + } + r := &chunkReader{chunks: chunks} + out := make([]string, 0, unique) + for k := 0; k < unique; k++ { + s, err := r.readString() + if err != nil { + return nil, fmt.Errorf("строка %d из %d: %w", k+1, unique, err) + } + out = append(out, s) + } + return out, nil +} + +func decodeRK(v uint32) float64 { + var f float64 + if v&2 != 0 { + f = float64(int32(v) >> 2) + } else { + f = math.Float64frombits(uint64(v&0xFFFFFFFC) << 32) + } + if v&1 != 0 { + f /= 100 + } + return f +} + +func numberString(f float64) string { + if f == math.Trunc(f) && math.Abs(f) < 1e15 { + return strconv.FormatInt(int64(f), 10) + } + return strconv.FormatFloat(f, 'f', -1, 64) +} + +type boundSheet struct { + name string + offset int + kind byte +} + +func parseBoundSheet(d []byte) (boundSheet, error) { + if len(d) < 8 { + return boundSheet{}, fmt.Errorf("повреждённая запись листа") + } + cch, flags := int(d[6]), d[7] + width := 1 + if flags&1 != 0 { + width = 2 + } + if len(d) < 8+cch*width { + return boundSheet{}, fmt.Errorf("повреждённое имя листа") + } + units := make([]uint16, cch) + for i := range units { + if width == 2 { + units[i] = binary.LittleEndian.Uint16(d[8+i*2:]) + } else { + units[i] = uint16(d[8+i]) + } + } + return boundSheet{name: string(utf16.Decode(units)), offset: int(binary.LittleEndian.Uint32(d)), kind: d[5]}, nil +} + +// parseBIFF8 читает все листы книги из потока Workbook. +func parseBIFF8(stream []byte) ([]biffSheet, error) { + recs, err := readRecords(stream) + if err != nil { + return nil, err + } + if len(recs) == 0 || recs[0].id != recBOF || len(recs[0].data) < 4 || + binary.LittleEndian.Uint16(recs[0].data) != biff8Version { + return nil, fmt.Errorf("поддерживается только формат Excel 97–2003 (BIFF8)") + } + byOffset := make(map[int]int, len(recs)) + for i, rec := range recs { + byOffset[rec.off] = i + } + var sst []string + var bounds []boundSheet + for i, rec := range recs { + if i > 0 && rec.id == recBOF { + break // началась первая страница: общая часть книги закончилась + } + switch rec.id { + case recSST: + if sst, err = parseSST(recs, i); err != nil { + return nil, err + } + case recBoundSheet: + b, err := parseBoundSheet(rec.data) + if err != nil { + return nil, err + } + bounds = append(bounds, b) + } + } + sheets := make([]biffSheet, 0, len(bounds)) + for _, b := range bounds { + start, ok := byOffset[b.offset] + if !ok || recs[start].id != recBOF { + return nil, fmt.Errorf("лист %q: не найдено начало данных", b.name) + } + rows, err := readSheet(recs, start, sst) + if err != nil { + return nil, fmt.Errorf("лист %q: %w", b.name, err) + } + sheets = append(sheets, biffSheet{name: b.name, rows: rows}) + } + return sheets, nil +} + +type cellValue struct { + row, col int + text string +} + +func readSheet(recs []record, start int, sst []string) ([][]string, error) { + var cells []cellValue + maxRow, maxCol := -1, -1 + put := func(row, col int, text string) error { + if row >= maxSheetRows || col >= maxSheetCols { + return fmt.Errorf("лист слишком велик (строка %d, столбец %d)", row+1, col+1) + } + cells = append(cells, cellValue{row, col, text}) + maxRow, maxCol = max(maxRow, row), max(maxCol, col) + return nil + } + depth := 0 + for i := start; i < len(recs); i++ { + rec := recs[i] + d := rec.data + switch rec.id { + case recBOF: + depth++ + continue + case recEOF: + depth-- + if depth == 0 { + i = len(recs) + } + continue + } + if depth != 1 { + continue + } + switch rec.id { + case recLabelSST: + if len(d) < 10 { + return nil, fmt.Errorf("повреждённая ячейка LABELSST") + } + idx := int(binary.LittleEndian.Uint32(d[6:])) + if idx < 0 || idx >= len(sst) { + return nil, fmt.Errorf("ячейка ссылается на несуществующую строку %d", idx) + } + if err := put(int(binary.LittleEndian.Uint16(d)), int(binary.LittleEndian.Uint16(d[2:])), sst[idx]); err != nil { + return nil, err + } + case recLabel: + if len(d) < 8 { + return nil, fmt.Errorf("повреждённая ячейка LABEL") + } + r := &chunkReader{chunks: [][]byte{d[6:]}} + s, err := r.readString() + if err != nil { + return nil, err + } + if err := put(int(binary.LittleEndian.Uint16(d)), int(binary.LittleEndian.Uint16(d[2:])), s); err != nil { + return nil, err + } + case recNumber: + if len(d) < 14 { + return nil, fmt.Errorf("повреждённая ячейка NUMBER") + } + f := math.Float64frombits(binary.LittleEndian.Uint64(d[6:])) + if err := put(int(binary.LittleEndian.Uint16(d)), int(binary.LittleEndian.Uint16(d[2:])), numberString(f)); err != nil { + return nil, err + } + case recRK: + if len(d) < 10 { + return nil, fmt.Errorf("повреждённая ячейка RK") + } + if err := put(int(binary.LittleEndian.Uint16(d)), int(binary.LittleEndian.Uint16(d[2:])), + numberString(decodeRK(binary.LittleEndian.Uint32(d[6:])))); err != nil { + return nil, err + } + case recMulRK: + if len(d) < 6 { + return nil, fmt.Errorf("повреждённая запись MULRK") + } + row, first := int(binary.LittleEndian.Uint16(d)), int(binary.LittleEndian.Uint16(d[2:])) + n := (len(d) - 6) / 6 + for k := 0; k < n; k++ { + v := binary.LittleEndian.Uint32(d[4+k*6+2:]) + if err := put(row, first+k, numberString(decodeRK(v))); err != nil { + return nil, err + } + } + case recBlank, recMulBlank, recString: + // пустые ячейки значения не несут + case recFormula, recBoolErr, recRString: + return nil, fmt.Errorf("неподдерживаемый тип ячейки 0x%04X: выгрузка не должна содержать формул и ошибок", rec.id) + } + } + rows := make([][]string, maxRow+1) + width := make([]int, maxRow+1) + for _, c := range cells { + width[c.row] = max(width[c.row], c.col+1) + } + for r := range rows { + if width[r] > 0 { + rows[r] = make([]string, width[r]) + } + } + for _, c := range cells { + rows[c.row][c.col] = c.text + } + return rows, nil +} diff --git a/internal/sberhealth/biff_test.go b/internal/sberhealth/biff_test.go new file mode 100644 index 0000000..942c7f5 --- /dev/null +++ b/internal/sberhealth/biff_test.go @@ -0,0 +1,290 @@ +package sberhealth + +import ( + "encoding/binary" + "fmt" + "math" + "math/rand" + "reflect" + "strings" + "testing" + "unicode/utf16" +) + +// ---- сборка синтетической книги BIFF8: без персональных данных ---- + +func biffRec(id uint16, data []byte) []byte { + out := make([]byte, 4, 4+len(data)) + binary.LittleEndian.PutUint16(out, id) + binary.LittleEndian.PutUint16(out[2:], uint16(len(data))) + return append(out, data...) +} + +func le16(v int) []byte { b := make([]byte, 2); binary.LittleEndian.PutUint16(b, uint16(v)); return b } +func le32(v uint32) []byte { b := make([]byte, 4); binary.LittleEndian.PutUint32(b, v); return b } + +func latin1(s string) bool { + for _, r := range s { + if r > 0xFF { + return false + } + } + return true +} + +type testString struct { + text string + runs int // блоки форматирования (по 4 байта) + ext int // байты фонетики +} + +// sstRecords раскладывает строки по записям SST и CONTINUE так же, как это делает Excel: +// строка может прерваться на любой границе, а у продолжения первым байтом идёт флаг кодировки; +// на каждой границе кодировка может поменяться. +func sstRecords(strs []testString, maxData int) []byte { + var chunks [][]byte + cur := append(le32(uint32(len(strs))), le32(uint32(len(strs)))...) + flush := func() { chunks = append(chunks, cur); cur = nil } + space := func() int { return maxData - len(cur) } + for _, s := range strs { + units := utf16.Encode([]rune(s.text)) + headLen := 3 + if s.runs > 0 { + headLen += 2 + } + if s.ext > 0 { + headLen += 4 + } + if space() < headLen+1 { // заголовок не делится между записями + flush() + } + flags := byte(0) + if !latin1(s.text) { + flags |= 1 + } + if s.runs > 0 { + flags |= 0x08 + } + if s.ext > 0 { + flags |= 0x04 + } + cur = append(cur, le16(len(units))...) + cur = append(cur, flags) + if s.runs > 0 { + cur = append(cur, le16(s.runs)...) + } + if s.ext > 0 { + cur = append(cur, le32(uint32(s.ext))...) + } + high := flags&1 != 0 + for done := 0; done < len(units); { + width := 1 + if high { + width = 2 + } + n := min(len(units)-done, space()/width) + if n == 0 { + flush() + rest := units[done:] + high = false + for _, u := range rest { + if u > 0xFF { + high = true + } + } + flag := byte(0) + if high { + flag = 1 + } + cur = append(cur, flag) + continue + } + for _, u := range units[done : done+n] { + if high { + cur = append(cur, byte(u), byte(u>>8)) + } else { + cur = append(cur, byte(u)) + } + } + done += n + } + for left := s.runs*4 + s.ext; left > 0; { + if space() == 0 { + flush() + } + n := min(left, space()) + cur = append(cur, make([]byte, n)...) + left -= n + } + } + flush() + var out []byte + for i, c := range chunks { + id := uint16(recContinue) + if i == 0 { + id = recSST + } + out = append(out, biffRec(id, c)...) + } + return out +} + +func boundSheetRec(name string, offset int) []byte { + d := append(le32(uint32(offset)), 0, 0, byte(len(name)), 0) + return biffRec(recBoundSheet, append(d, []byte(name)...)) +} + +func bofRec(kind int) []byte { + return biffRec(recBOF, append(append(le16(biff8Version), le16(kind)...), make([]byte, 12)...)) +} + +func labelSSTCell(row, col, idx int) []byte { + return biffRec(recLabelSST, append(append(append(le16(row), le16(col)...), le16(0)...), le32(uint32(idx))...)) +} + +func numberCell(row, col int, f float64) []byte { + d := append(append(le16(row), le16(col)...), le16(0)...) + b := make([]byte, 8) + binary.LittleEndian.PutUint64(b, math.Float64bits(f)) + return biffRec(recNumber, append(d, b...)) +} + +func rkCell(row, col int, rk uint32) []byte { + return biffRec(recRK, append(append(append(le16(row), le16(col)...), le16(0)...), le32(rk)...)) +} + +func mulRKCells(row, first int, rks ...uint32) []byte { + d := append(le16(row), le16(first)...) + for _, rk := range rks { + d = append(d, le16(0)...) + d = append(d, le32(rk)...) + } + d = append(d, le16(first+len(rks)-1)...) + return biffRec(recMulRK, d) +} + +// buildBook собирает книгу: общая часть (SST) и один лист с готовыми записями ячеек. +func buildBook(strs []testString, maxData int, name string, cells ...[]byte) []byte { + sst := sstRecords(strs, maxData) + globalsLen := len(bofRec(5)) + len(boundSheetRec(name, 0)) + len(sst) + len(biffRec(recEOF, nil)) + var out []byte + out = append(out, bofRec(5)...) + out = append(out, boundSheetRec(name, globalsLen)...) + out = append(out, sst...) + out = append(out, biffRec(recEOF, nil)...) + out = append(out, bofRec(0x10)...) + for _, c := range cells { + out = append(out, c...) + } + out = append(out, biffRec(recEOF, nil)...) + return out +} + +// Главный регрессионный тест: строки через границы CONTINUE при любых размерах записей. +func TestSSTStringsAcrossContinueBoundaries(t *testing.T) { + rng := rand.New(rand.NewSource(7)) + words := []string{"Врач", "Невролог", "Лабораторная диагностика", "ул. Ляхова, д. 7", "Report", "Дата создания записи", "Привет, мир", "Qwerty"} + var strs []testString + var want []string + for i := 0; i < 220; i++ { + var parts []string + for k := rng.Intn(4); k >= 0; k-- { + parts = append(parts, words[rng.Intn(len(words))]) + } + s := strings.Join(parts, " ") + if i%7 == 0 { + s = fmt.Sprintf("%02d.%02d.2026 %02d:%02d", i%28+1, i%12+1, i%24, i%60) // ASCII: сжатая форма + } + ts := testString{text: s} + if i%11 == 0 { + ts.runs = 2 + } + if i%13 == 0 { + ts.ext = 9 + } + strs = append(strs, ts) + want = append(want, s) + } + for _, maxData := range []int{24, 25, 31, 40, 63, 64, 97, 128, 255, 1000, 8224} { + cells := make([][]byte, len(strs)) + for i := range strs { + cells[i] = labelSSTCell(i, 0, i) + } + sheets, err := parseBIFF8(buildBook(strs, maxData, "Report", cells...)) + if err != nil { + t.Fatalf("maxData=%d: %v", maxData, err) + } + if len(sheets) != 1 || sheets[0].name != "Report" || len(sheets[0].rows) != len(want) { + t.Fatalf("maxData=%d: неверный состав листов", maxData) + } + for i, w := range want { + if got := sheets[0].rows[i][0]; got != w { + t.Fatalf("maxData=%d, строка %d: получили %q, ожидали %q", maxData, i, got, w) + } + } + } +} + +func TestNumbersAndShape(t *testing.T) { + strs := []testString{{text: "a"}, {text: "b"}} + book := buildBook(strs, 100, "Report", + labelSSTCell(0, 1, 1), // ряд 0 начинается с пустого столбца 0 + numberCell(1, 0, 59935608), + numberCell(1, 1, 2500.5), + rkCell(2, 0, uint32(7<<2)|2), // целое 7 + rkCell(2, 1, uint32(12345<<2)|2|1), // 123,45 + mulRKCells(3, 2, uint32(10<<2)|2, uint32(20<<2)|2), + labelSSTCell(4, 0, 0), + ) + sheets, err := parseBIFF8(book) + if err != nil { + t.Fatal(err) + } + want := [][]string{{"", "b"}, {"59935608", "2500.5"}, {"7", "123.45"}, {"", "", "10", "20"}, {"a"}} + if !reflect.DeepEqual(sheets[0].rows, want) { + t.Fatalf("получили %q, ожидали %q", sheets[0].rows, want) + } +} + +func TestUnsupportedAndBrokenInput(t *testing.T) { + strs := []testString{{text: "x"}} + formula := biffRec(recFormula, make([]byte, 20)) + if _, err := parseBIFF8(buildBook(strs, 100, "Report", labelSSTCell(0, 0, 0), formula)); err == nil { + t.Error("формула должна давать явную ошибку, а не пропускаться") + } + if _, err := parseBIFF8(buildBook(strs, 100, "Report", labelSSTCell(0, 0, 5))); err == nil { + t.Error("ссылка на несуществующую строку должна давать ошибку") + } + good := buildBook(strs, 100, "Report", labelSSTCell(0, 0, 0)) + for cut := 1; cut < len(good); cut += 7 { + func() { + defer func() { + if recover() != nil { + t.Fatalf("обрезанная книга (%d байт) вызвала панику", cut) + } + }() + _, _ = parseBIFF8(good[:cut]) + }() + } + bad := append([]byte(nil), good...) + binary.LittleEndian.PutUint16(bad[4:], 0x0500) // BIFF5 + if _, err := parseBIFF8(bad); err == nil { + t.Error("BIFF5 должен отклоняться") + } + huge := buildBook(strs, 100, "Report", labelSSTCell(maxSheetRows, 0, 0)) + if _, err := parseBIFF8(huge); err == nil { + t.Error("слишком большой лист должен отклоняться") + } +} + +func TestReadXLSRejectsNonExcel(t *testing.T) { + for name, data := range map[string][]byte{ + "html": []byte("
x
" + strings.Repeat(" ", 600)), + "пусто": nil, + "zip": append([]byte{'P', 'K', 3, 4}, make([]byte, 600)...), + } { + if _, err := ReadXLS(data); err == nil { + t.Errorf("%s: ожидали ошибку", name) + } + } +} diff --git a/internal/sberhealth/client.go b/internal/sberhealth/client.go new file mode 100644 index 0000000..b59bc78 --- /dev/null +++ b/internal/sberhealth/client.go @@ -0,0 +1,135 @@ +// Package sberhealth downloads the authenticated docdoc.ru clinic export. +// A cabinet session is required; this is not a public API-key integration. +// Downloaded bytes MUST be schema-validated before use in channel reports. +package sberhealth + +import ( + "bytes" + "context" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +const downloadURL = "https://docdoc.ru/lk/requests/listOld" +const maxDownload = 10 << 20 + +type Client struct { + cookie, clinicID string + endpoint string + http *http.Client +} + +// Export is only a download, NOT a parsed/validated report. The XLS export may +// be binary Excel, OOXML, or an HTML table; do not guess its columns/dates. +type Export struct { + Data []byte + Format string // xls, xlsx, html; format detection does not establish schema +} + +func New(cookie, clinicID string) (*Client, error) { + clinicID = strings.TrimSpace(clinicID) + if cookie == "" && clinicID == "" { + return nil, nil + } + if strings.TrimSpace(cookie) == "" || strings.ContainsAny(cookie, "\r\n") || + len(cookie) > 16384 || !strings.Contains(cookie, "=") || + !regexp.MustCompile(`^[1-9][0-9]{0,17}$`).MatchString(clinicID) { + return nil, fmt.Errorf("СберЗдоровье: требуется сессия кабинета и корректный ID клиники") + } + return &Client{cookie: cookie, clinicID: clinicID, endpoint: downloadURL, http: &http.Client{ + Timeout: 45 * time.Second, + // A redirect to login is an error; never forward credentials elsewhere. + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }}, nil +} + +func (c *Client) Configured() bool { return c != nil && c.cookie != "" && c.clinicID != "" } + +// Download reproduces the supplied export URL's date filters, without assuming +// crDateTill is inclusive/exclusive or that page=1 includes every request. +// These provider semantics must be checked against an actual cabinet export +// before adapting a 1C period [from,to) to these filters. +// periodInput is only week/month, and both date arguments are date-only Moscow. +func (c *Client) Download(ctx context.Context, dateFrom, dateTill time.Time, periodInput string) (Export, error) { + if !c.Configured() { + return Export{}, fmt.Errorf("СберЗдоровье не подключено") + } + if periodInput != "week" && periodInput != "month" { + return Export{}, fmt.Errorf("СберЗдоровье: режим выгрузки — week или month") + } + moscow := time.FixedZone("Europe/Moscow", 3*60*60) + dateFrom, dateTill = dateFrom.In(moscow), dateTill.In(moscow) + for _, date := range []time.Time{dateFrom, dateTill} { + if date.Year() < 2000 || date.Year() > 2100 || date.Hour() != 0 || date.Minute() != 0 || date.Second() != 0 || date.Nanosecond() != 0 { + return Export{}, fmt.Errorf("СберЗдоровье: фильтры должны быть датами без времени (Москва)") + } + } + if dateTill.Before(dateFrom) || dateTill.Sub(dateFrom) > 366*24*time.Hour { + return Export{}, fmt.Errorf("СберЗдоровье: некорректный период выгрузки") + } + u, err := url.Parse(c.endpoint) + if err != nil { + return Export{}, fmt.Errorf("СберЗдоровье: некорректный адрес выгрузки") + } + u.RawQuery = url.Values{ + "kind": {"doctor"}, "clinic_id": {c.clinicID}, "periodInput": {periodInput}, + "crDateFrom": {dateFrom.Format("02.01.2006")}, "crDateTill": {dateTill.Format("02.01.2006")}, + "page": {"1"}, "admissionStatus": {"-1"}, "sourceType": {"-1"}, "type": {"xls"}, + }.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return Export{}, fmt.Errorf("СберЗдоровье: не удалось подготовить запрос") + } + req.Header.Set("Cookie", c.cookie) + req.Header.Set("Accept", "application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + res, err := c.http.Do(req) + if err != nil { + if ctx.Err() != nil { + return Export{}, ctx.Err() + } + return Export{}, fmt.Errorf("СберЗдоровье: не удалось получить выгрузку (соединение или таймаут)") + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + if res.StatusCode == 401 || res.StatusCode == 403 || res.StatusCode >= 300 && res.StatusCode < 400 { + return Export{}, fmt.Errorf("СберЗдоровье: доступ к выгрузке не получен (HTTP %d); проверьте сессию кабинета и защиту сайта", res.StatusCode) + } + return Export{}, fmt.Errorf("СберЗдоровье: ошибка выгрузки (HTTP %d)", res.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(res.Body, maxDownload+1)) + if err != nil { + return Export{}, fmt.Errorf("СберЗдоровье: файл не дочитался") + } + if len(data) > maxDownload { + return Export{}, fmt.Errorf("СберЗдоровье: файл больше 10 МБ") + } + format := "" + switch { + case bytes.HasPrefix(data, []byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1}): + format = "xls" + case bytes.HasPrefix(data, []byte{'P', 'K', 3, 4}): + format = "xlsx" + default: + // Legacy exports sometimes contain HTML under an .xls filename. Only + // recognise it when explicitly offered as an Excel download, not an + // arbitrary HTML page with a login/error table. Still needs a parser. + disposition, params, _ := mime.ParseMediaType(res.Header.Get("Content-Disposition")) + ct, _, _ := mime.ParseMediaType(res.Header.Get("Content-Type")) + filename := strings.ToLower(params["filename"]) + excelAttachment := disposition == "attachment" && strings.HasSuffix(filename, ".xls") + if (excelAttachment || ct == "application/vnd.ms-excel") && bytes.Contains(bytes.ToLower(data), []byte("
synthetic
", "text/html", `attachment; filename="requests.xls"`, "html"}, + {"html-excel-mime", "
synthetic
", "application/vnd.ms-excel", "", "html"}, + {"login-table", "private-secret Login
", "text/html", "", ""}, + {"login-attachment", "private-secret Login", "text/html", `attachment; filename="requests.xls"`, ""}, + {"json-error", `{"error":"private-secret"}`, "application/json", "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", tc.ct) + w.Header().Set("Content-Disposition", tc.disposition) + _, _ = io.WriteString(w, tc.body) + })) + defer s.Close() + c, _ := New("session=synthetic", "294888") + c.endpoint = s.URL + export, err := c.Download(context.Background(), from, till, "week") + if tc.format == "" { + if err == nil || strings.Contains(err.Error(), "private-secret") { + t.Fatal("unknown/login response accepted or leaked") + } + } else if err != nil || export.Format != tc.format { + t.Fatal("format detection incorrect", err) + } + }) + } +} + +func TestErrorsAndRedirects(t *testing.T) { + from, till := sampleDates() + redirected := 0 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { redirected++ })) + defer target.Close() + for _, status := range []int{302, 401, 403, 429, 500} { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", target.URL) + w.WriteHeader(status) + _, _ = io.WriteString(w, "private-secret") + })) + c, _ := New("session=synthetic", "294888") + c.endpoint = s.URL + export, err := c.Download(context.Background(), from, till, "week") + s.Close() + if err == nil || len(export.Data) != 0 || strings.Contains(err.Error(), "private-secret") { + t.Fatal("missing or leaking error") + } + } + if redirected != 0 { + t.Fatal("redirect forwarded session") + } +} + +func TestMonthlyFiltersAndInvalidDates(t *testing.T) { + zone := time.FixedZone("Europe/Moscow", 10800) + from, till := time.Date(2026, 10, 1, 0, 0, 0, 0, zone), time.Date(2026, 10, 31, 0, 0, 0, 0, zone) + requests := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + q := r.URL.Query() + if q.Get("periodInput") != "month" || q.Get("crDateFrom") != "01.10.2026" || q.Get("crDateTill") != "31.10.2026" { + t.Error("monthly date filters incorrect") + } + _, _ = w.Write([]byte("PK\x03\x04placeholder")) + })) + defer s.Close() + c, _ := New("session=synthetic", "294888") + c.endpoint = s.URL + if _, err := c.Download(context.Background(), from, till, "month"); err != nil { + t.Fatal(err) + } + for _, args := range []struct { + from, till time.Time + mode string + }{{till, from, "week"}, {from.Add(time.Hour), till, "week"}, {from, till, "auto"}, {from, from.AddDate(2, 0, 0), "month"}} { + if _, err := c.Download(context.Background(), args.from, args.till, args.mode); err == nil { + t.Fatal("invalid filters accepted") + } + } + if requests != 1 { + t.Fatal("invalid filters reached server") + } +} + +func TestSizeLimitAndCancellation(t *testing.T) { + from, till := sampleDates() + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(append([]byte("PK\x03\x04"), bytes.Repeat([]byte{'x'}, maxDownload)...)) + })) + defer s.Close() + c, _ := New("session=synthetic", "294888") + c.endpoint = s.URL + if _, err := c.Download(context.Background(), from, till, "week"); err == nil { + t.Fatal("oversized download accepted") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := c.Download(ctx, from, till, "week"); err != context.Canceled { + t.Fatal("cancellation lost", err) + } +} diff --git a/internal/sberhealth/xls.go b/internal/sberhealth/xls.go new file mode 100644 index 0000000..eabb158 --- /dev/null +++ b/internal/sberhealth/xls.go @@ -0,0 +1,34 @@ +package sberhealth + +import ( + "bytes" + "fmt" +) + +// ReadXLS reads only the confirmed legacy Report sheet. Unexpected formats or +// sparse/oversized files fail explicitly, rather than becoming an empty channel. +// Parsing is done by biff.go (own BIFF8 reader): the third-party library used before +// shifted values in large exports because it mishandled strings split across CONTINUE +// records. No Excel/Python process is needed on the VPS. +func ReadXLS(data []byte) (rows [][]string, err error) { + defer func() { + if recover() != nil { + rows, err = nil, fmt.Errorf("СберЗдоровье: повреждённая книга XLS") + } + }() + if len(data) > maxDownload || len(data) < 512 || !bytes.HasPrefix(data, []byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1}) { + return nil, fmt.Errorf("СберЗдоровье: нужен бинарный XLS из кабинета") + } + stream, err := workbookStream(data) + if err != nil { + return nil, fmt.Errorf("СберЗдоровье: не удалось прочитать XLS (%v)", err) + } + sheets, err := parseBIFF8(stream) + if err != nil { + return nil, fmt.Errorf("СберЗдоровье: не удалось прочитать XLS (%v)", err) + } + if len(sheets) != 1 || sheets[0].name != "Report" { + return nil, fmt.Errorf("СберЗдоровье: неизвестный лист или слишком много листов XLS") + } + return sheets[0].rows, nil +} diff --git a/internal/storage/admin.go b/internal/storage/admin.go new file mode 100644 index 0000000..ec2751e --- /dev/null +++ b/internal/storage/admin.go @@ -0,0 +1,120 @@ +package storage + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "golang.org/x/crypto/bcrypt" +) + +var ErrBadCredentials = errors.New("bad credentials") + +const sessionTTL = 12 * time.Hour + +type AdminUser struct { + ID int64 `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` +} + +// EnsureAdmin создаёт первого администратора, если таблица пуста. +func (s *Storage) EnsureAdmin(ctx context.Context, email, password string) error { + if email == "" || password == "" { + return nil + } + var n int + if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM admin_users`).Scan(&n); err != nil { + return err + } + if n > 0 { + return nil + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, + `INSERT INTO admin_users (email, password_hash, name, role) VALUES ($1,$2,'Администратор','owner')`, + email, string(hash)) + return err +} + +// Login проверяет пароль и заводит сессию, возвращая её токен. +func (s *Storage) Login(ctx context.Context, email, password, ip, ua string) (string, *AdminUser, error) { + var ( + u AdminUser + hash string + ) + err := s.pool.QueryRow(ctx, + `SELECT id, email, name, role, password_hash FROM admin_users WHERE email = $1 AND active`, + email).Scan(&u.ID, &u.Email, &u.Name, &u.Role, &hash) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil, ErrBadCredentials + } + if err != nil { + return "", nil, err + } + if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { + return "", nil, ErrBadCredentials + } + + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", nil, err + } + token := base64.RawURLEncoding.EncodeToString(raw) + + var ipVal any + if ip != "" { + ipVal = ip + } + if _, err := s.pool.Exec(ctx, + `INSERT INTO admin_sessions (token, user_id, expires_at, ip, user_agent) VALUES ($1,$2,$3,$4,$5)`, + token, u.ID, time.Now().Add(sessionTTL), ipVal, ua); err != nil { + return "", nil, err + } + _, _ = s.pool.Exec(ctx, `UPDATE admin_users SET last_login_at = now() WHERE id = $1`, u.ID) + s.Audit(ctx, u.ID, "login", "admin_user", u.ID, ip) + return token, &u, nil +} + +func (s *Storage) Logout(ctx context.Context, token string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM admin_sessions WHERE token = $1`, token) + return err +} + +// UserByToken возвращает пользователя действующей сессии. +func (s *Storage) UserByToken(ctx context.Context, token string) (*AdminUser, error) { + var u AdminUser + err := s.pool.QueryRow(ctx, ` + SELECT u.id, u.email, u.name, u.role + FROM admin_sessions s JOIN admin_users u ON u.id = s.user_id + WHERE s.token = $1 AND s.expires_at > now() AND u.active`, token). + Scan(&u.ID, &u.Email, &u.Name, &u.Role) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &u, nil +} + +// Audit пишет действие в журнал; ошибки журнала не должны ломать запрос. +func (s *Storage) Audit(ctx context.Context, userID int64, action, entity string, entityID int64, ip string) { + var ipVal, idVal any + if ip != "" { + ipVal = ip + } + if entityID != 0 { + idVal = entityID + } + _, _ = s.pool.Exec(ctx, + `INSERT INTO audit_log (user_id, action, entity, entity_id, ip) VALUES ($1,$2,$3,$4,$5)`, + userID, action, entity, idVal, ipVal) +} diff --git a/internal/storage/admin_checkups.go b/internal/storage/admin_checkups.go new file mode 100644 index 0000000..7bec937 --- /dev/null +++ b/internal/storage/admin_checkups.go @@ -0,0 +1,159 @@ +package storage + +import ( + "context" + "encoding/json" + + "github.com/jackc/pgx/v5" +) + +type AdminCheckup struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Audience string `json:"audience"` + Name string `json:"name"` + Lead string `json:"lead"` + Blocks json.RawMessage `json:"blocks"` + Price *float64 `json:"price"` + OldPrice *float64 `json:"old_price"` + ImageID *int64 `json:"image_id"` + Image string `json:"image,omitempty"` + Items []string `json:"items"` + Sort int `json:"sort"` + Published bool `json:"published"` + SEOTitle string `json:"seo_title"` + SEODesc string `json:"seo_description"` + SEOH1 string `json:"seo_h1"` +} + +// AdminCheckups — все программы со составом. Blocks отдаём, чтобы админка могла +// предложить состав из старого описания, пока его не сохранили списком. +func (s *Storage) AdminCheckups(ctx context.Context) ([]AdminCheckup, error) { + rows, err := s.pool.Query(ctx, ` + SELECT c.id, c.slug, c.audience, c.name, c.lead, c.blocks, c.price, c.old_price, + c.image_id, COALESCE(m.path, ''), c.sort, c.published, + c.seo_title, c.seo_description, c.seo_h1 + FROM checkups c + LEFT JOIN media m ON m.id = c.image_id + WHERE c.deleted_at IS NULL + ORDER BY c.audience, c.sort, c.name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AdminCheckup + index := map[int64]int{} + for rows.Next() { + var a AdminCheckup + if err := rows.Scan(&a.ID, &a.Slug, &a.Audience, &a.Name, &a.Lead, &a.Blocks, + &a.Price, &a.OldPrice, &a.ImageID, &a.Image, &a.Sort, &a.Published, + &a.SEOTitle, &a.SEODesc, &a.SEOH1); err != nil { + return nil, err + } + a.Items = []string{} + index[a.ID] = len(out) + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, err + } + + itemRows, err := s.pool.Query(ctx, `SELECT checkup_id, name FROM checkup_items ORDER BY checkup_id, sort, id`) + if err != nil { + return nil, err + } + defer itemRows.Close() + for itemRows.Next() { + var checkupID int64 + var name string + if err := itemRows.Scan(&checkupID, &name); err != nil { + return nil, err + } + if i, found := index[checkupID]; found { + out[i].Items = append(out[i].Items, name) + } + } + return out, itemRows.Err() +} + +type CheckupInput struct { + Slug string + Audience string + Name string + Lead string + Price *float64 + OldPrice *float64 + ImageID *int64 + Items []string + Sort int + Published bool + SEOTitle string + SEODesc string + SEOH1 string +} + +func (s *Storage) AdminCreateCheckup(ctx context.Context, in CheckupInput) (int64, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck // после Commit откат ничего не делает + + var id int64 + err = tx.QueryRow(ctx, ` + INSERT INTO checkups (slug, audience, name, lead, price, old_price, image_id, sort, published, + seo_title, seo_description, seo_h1) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + RETURNING id`, + in.Slug, in.Audience, in.Name, in.Lead, in.Price, in.OldPrice, in.ImageID, in.Sort, in.Published, + in.SEOTitle, in.SEODesc, in.SEOH1).Scan(&id) + if err != nil { + return 0, wrapConflict(err) + } + if err := replaceCheckupItems(ctx, tx, id, in.Items); err != nil { + return 0, err + } + return id, tx.Commit(ctx) +} + +func (s *Storage) AdminUpdateCheckup(ctx context.Context, id int64, in CheckupInput) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck // после Commit откат ничего не делает + + tag, err := tx.Exec(ctx, ` + UPDATE checkups SET slug=$2, audience=$3, name=$4, lead=$5, price=$6, old_price=$7, + image_id=$8, sort=$9, published=$10, + seo_title=$11, seo_description=$12, seo_h1=$13, updated_at=now() + WHERE id=$1 AND deleted_at IS NULL`, + id, in.Slug, in.Audience, in.Name, in.Lead, in.Price, in.OldPrice, + in.ImageID, in.Sort, in.Published, in.SEOTitle, in.SEODesc, in.SEOH1) + if err != nil { + return wrapConflict(err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if err := replaceCheckupItems(ctx, tx, id, in.Items); err != nil { + return err + } + return tx.Commit(ctx) +} + +// replaceCheckupItems перезаписывает состав целиком: порядок строк в админке — порядок на сайте. +func replaceCheckupItems(ctx context.Context, tx pgx.Tx, checkupID int64, items []string) error { + if _, err := tx.Exec(ctx, `DELETE FROM checkup_items WHERE checkup_id = $1`, checkupID); err != nil { + return err + } + for i, name := range items { + if _, err := tx.Exec(ctx, + `INSERT INTO checkup_items (checkup_id, name, sort) VALUES ($1, $2, $3)`, + checkupID, name, (i+1)*10); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/admin_content.go b/internal/storage/admin_content.go new file mode 100644 index 0000000..ed8d24e --- /dev/null +++ b/internal/storage/admin_content.go @@ -0,0 +1,221 @@ +package storage + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" +) + +// ---------- Услуги ---------- + +type AdminService struct { + ID int64 `json:"id"` + ParentID *int64 `json:"parent_id"` + ParentName string `json:"parent_name,omitempty"` + Slug string `json:"slug"` + Path string `json:"path"` + Name string `json:"name"` + ShortName string `json:"short_name"` + Lead string `json:"lead"` + Sort int `json:"sort"` + Published bool `json:"published"` + SEOTitle string `json:"seo_title"` + SEODesc string `json:"seo_description"` + SEOH1 string `json:"seo_h1"` + PriceFrom *float64 `json:"price_from,omitempty"` + PriceCount int `json:"price_count"` +} + +// AdminServices возвращает плоский список услуг, включая скрытые. +func (s *Storage) AdminServices(ctx context.Context, q string) ([]AdminService, error) { + rows, err := s.pool.Query(ctx, ` + SELECT s.id, s.parent_id, COALESCE(p.name, ''), s.slug, s.path, s.name, + s.short_name, s.lead, s.sort, s.published, + s.seo_title, s.seo_description, s.seo_h1, + (SELECT min(pi.price) FROM price_items pi + WHERE pi.service_id = s.id AND pi.published AND NOT pi.exclude_from_min), + (SELECT count(*) FROM price_items pi WHERE pi.service_id = s.id) + FROM services s + LEFT JOIN services p ON p.id = s.parent_id + WHERE s.deleted_at IS NULL + AND ($1 = '' OR s.name ILIKE '%' || $1 || '%' OR s.path ILIKE '%' || $1 || '%') + ORDER BY COALESCE(p.sort, s.sort), COALESCE(p.name, s.name), s.parent_id NULLS FIRST, s.sort, s.name`, q) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AdminService + for rows.Next() { + var a AdminService + if err := rows.Scan(&a.ID, &a.ParentID, &a.ParentName, &a.Slug, &a.Path, &a.Name, + &a.ShortName, &a.Lead, &a.Sort, &a.Published, + &a.SEOTitle, &a.SEODesc, &a.SEOH1, &a.PriceFrom, &a.PriceCount); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +type ServiceUpdate struct { + Name string `json:"name"` + ShortName string `json:"short_name"` + Lead string `json:"lead"` + Sort int `json:"sort"` + Published bool `json:"published"` + SEOTitle string `json:"seo_title"` + SEODesc string `json:"seo_description"` + SEOH1 string `json:"seo_h1"` +} + +func (s *Storage) AdminUpdateService(ctx context.Context, id int64, u ServiceUpdate) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE services SET name=$2, short_name=$3, lead=$4, sort=$5, published=$6, + seo_title=$7, seo_description=$8, seo_h1=$9, updated_at=now() + WHERE id=$1 AND deleted_at IS NULL`, + id, u.Name, u.ShortName, u.Lead, u.Sort, u.Published, u.SEOTitle, u.SEODesc, u.SEOH1) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// ---------- Прайс ---------- + +type AdminPriceSection struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Count int `json:"count"` +} + +func (s *Storage) AdminPriceSections(ctx context.Context) ([]AdminPriceSection, error) { + rows, err := s.pool.Query(ctx, ` + SELECT ps.id, ps.slug, ps.name, (SELECT count(*) FROM price_items pi WHERE pi.section_id = ps.id) + FROM price_sections ps + ORDER BY ps.sort, ps.name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AdminPriceSection + for rows.Next() { + var a AdminPriceSection + if err := rows.Scan(&a.ID, &a.Slug, &a.Name, &a.Count); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +type AdminPriceItem struct { + ID int64 `json:"id"` + SectionID int64 `json:"section_id"` + SectionName string `json:"section_name"` + ServiceID *int64 `json:"service_id"` + ServiceName string `json:"service_name,omitempty"` + Name string `json:"name"` + Price float64 `json:"price"` + Unit string `json:"unit"` + PriceFrom bool `json:"price_from"` + Sort int `json:"sort"` + Published bool `json:"published"` +} + +// AdminPriceItems: постранично, с поиском и фильтром по разделу. +func (s *Storage) AdminPriceItems(ctx context.Context, sectionID int64, q string, limit, offset int) ([]AdminPriceItem, int, error) { + var total int + if err := s.pool.QueryRow(ctx, ` + SELECT count(*) FROM price_items pi + WHERE ($1 = 0 OR pi.section_id = $1) + AND ($2 = '' OR pi.name ILIKE '%' || $2 || '%')`, sectionID, q).Scan(&total); err != nil { + return nil, 0, err + } + + rows, err := s.pool.Query(ctx, ` + SELECT pi.id, pi.section_id, ps.name, pi.service_id, COALESCE(sv.name, ''), + pi.name, pi.price, pi.unit, pi.price_from, pi.sort, pi.published + FROM price_items pi + JOIN price_sections ps ON ps.id = pi.section_id + LEFT JOIN services sv ON sv.id = pi.service_id + WHERE ($1 = 0 OR pi.section_id = $1) + AND ($2 = '' OR pi.name ILIKE '%' || $2 || '%') + ORDER BY ps.sort, pi.sort, pi.name + LIMIT $3 OFFSET $4`, sectionID, q, limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var out []AdminPriceItem + for rows.Next() { + var a AdminPriceItem + if err := rows.Scan(&a.ID, &a.SectionID, &a.SectionName, &a.ServiceID, &a.ServiceName, + &a.Name, &a.Price, &a.Unit, &a.PriceFrom, &a.Sort, &a.Published); err != nil { + return nil, 0, err + } + out = append(out, a) + } + return out, total, rows.Err() +} + +type PriceItemInput struct { + SectionID int64 `json:"section_id"` + ServiceID *int64 `json:"service_id"` + Name string `json:"name"` + Price float64 `json:"price"` + Unit string `json:"unit"` + PriceFrom bool `json:"price_from"` + Sort int `json:"sort"` + Published bool `json:"published"` +} + +func (s *Storage) AdminUpdatePriceItem(ctx context.Context, id int64, in PriceItemInput) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE price_items SET section_id=$2, service_id=$3, name=$4, price=$5, + unit=$6, price_from=$7, sort=$8, published=$9, updated_at=now() + WHERE id=$1`, + id, in.SectionID, in.ServiceID, in.Name, in.Price, in.Unit, in.PriceFrom, in.Sort, in.Published) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *Storage) AdminCreatePriceItem(ctx context.Context, in PriceItemInput) (int64, error) { + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO price_items (section_id, service_id, name, price, unit, price_from, sort, published) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`, + in.SectionID, in.ServiceID, in.Name, in.Price, in.Unit, in.PriceFrom, in.Sort, in.Published).Scan(&id) + return id, err +} + +func (s *Storage) AdminDeletePriceItem(ctx context.Context, id int64) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM price_items WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *Storage) priceItemExists(ctx context.Context, id int64) error { + var x int + err := s.pool.QueryRow(ctx, `SELECT 1 FROM price_items WHERE id=$1`, id).Scan(&x) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err +} diff --git a/internal/storage/admin_doctors.go b/internal/storage/admin_doctors.go new file mode 100644 index 0000000..dfebc52 --- /dev/null +++ b/internal/storage/admin_doctors.go @@ -0,0 +1,248 @@ +package storage + +import "context" + +type AdminDoctor struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + LastName string `json:"last_name"` + FirstName string `json:"first_name"` + MiddleName string `json:"middle_name"` + Position string `json:"position"` + Category string `json:"category"` + Degree string `json:"degree"` + PracticeYear *int `json:"practice_start_year"` + Rating *float64 `json:"rating"` + PhotoID *int64 `json:"photo_id"` + Photo string `json:"photo,omitempty"` + Quote string `json:"quote"` + Bio string `json:"bio"` + Specialties string `json:"specialties,omitempty"` // через запятую, для списка + // Основная специальность — раздел в адресе страницы: /company/staff/<раздел>//. + SpecialtyID *int64 `json:"specialty_id"` + SpecialtySlug string `json:"specialty_slug"` + Booking string `json:"booking_provider"` + MedflexUser string `json:"medflex_user"` + Sort int `json:"sort"` + Published bool `json:"published"` + Absent bool `json:"absent"` + ShowOnHome bool `json:"show_on_home"` + BioCount int `json:"bio_count"` + SEOTitle string `json:"seo_title"` + SEODesc string `json:"seo_description"` + SEOH1 string `json:"seo_h1"` + // Связи для вкладки «Врачи» в админке: какие услуги ведёт и в каких филиалах принимает. + ServiceIDs []int64 `json:"service_ids"` + BranchIDs []int64 `json:"branch_ids"` +} + +func (s *Storage) AdminDoctors(ctx context.Context, q string) ([]AdminDoctor, error) { + rows, err := s.pool.Query(ctx, ` + SELECT d.id, d.slug, d.last_name, d.first_name, d.middle_name, + d.position, d.category, d.degree, d.practice_start_year, d.rating_override::float8, + d.photo_id, COALESCE(m.path, ''), d.quote, d.bio, + COALESCE(( + SELECT string_agg(sp.name, ', ' ORDER BY sp.name) + FROM doctor_specialties ds + JOIN specialties sp ON sp.id = ds.specialty_id + WHERE ds.doctor_id = d.id + ), ''), + ps.id, COALESCE(ps.slug, ''), + d.booking_provider, d.medflex_user, d.sort, d.published, + d.absent, d.show_on_home, + (SELECT count(*) FROM doctor_bio_entries e WHERE e.doctor_id = d.id), + d.seo_title, d.seo_description, d.seo_h1, + ARRAY(SELECT service_id FROM doctor_services WHERE doctor_id = d.id ORDER BY service_id), + ARRAY(SELECT branch_id FROM doctor_branches WHERE doctor_id = d.id ORDER BY branch_id) + FROM doctors d + LEFT JOIN media m ON m.id = d.photo_id + LEFT JOIN LATERAL ( + SELECT sp.id, sp.slug FROM doctor_specialties ds + JOIN specialties sp ON sp.id = ds.specialty_id + WHERE ds.doctor_id = d.id + ORDER BY ds.is_primary DESC, sp.sort, sp.id LIMIT 1 + ) ps ON true + WHERE d.deleted_at IS NULL + AND ($1 = '' OR d.last_name ILIKE '%' || $1 || '%' + OR d.first_name ILIKE '%' || $1 || '%' + OR d.position ILIKE '%' || $1 || '%') + ORDER BY d.sort, d.last_name, d.first_name`, q) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AdminDoctor + for rows.Next() { + var a AdminDoctor + if err := rows.Scan(&a.ID, &a.Slug, &a.LastName, &a.FirstName, &a.MiddleName, + &a.Position, &a.Category, &a.Degree, &a.PracticeYear, &a.Rating, + &a.PhotoID, &a.Photo, &a.Quote, &a.Bio, &a.Specialties, + &a.SpecialtyID, &a.SpecialtySlug, + &a.Booking, &a.MedflexUser, &a.Sort, &a.Published, + &a.Absent, &a.ShowOnHome, &a.BioCount, + &a.SEOTitle, &a.SEODesc, &a.SEOH1, &a.ServiceIDs, &a.BranchIDs); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +type DoctorUpdate struct { + Slug string `json:"slug"` + LastName string `json:"last_name"` + FirstName string `json:"first_name"` + MiddleName string `json:"middle_name"` + Position string `json:"position"` + Category string `json:"category"` + Degree string `json:"degree"` + PracticeYear *int `json:"practice_start_year"` + Rating *float64 `json:"rating"` + PhotoID *int64 `json:"photo_id"` + Quote string `json:"quote"` + Bio string `json:"bio"` + Booking string `json:"booking_provider"` + MedflexUser string `json:"medflex_user"` + Sort int `json:"sort"` + Published bool `json:"published"` + Absent bool `json:"absent"` + ShowOnHome bool `json:"show_on_home"` + SEOTitle string `json:"seo_title"` + SEODesc string `json:"seo_description"` + SEOH1 string `json:"seo_h1"` +} + +func (s *Storage) AdminUpdateDoctor(ctx context.Context, id int64, in DoctorUpdate) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE doctors SET slug=$2, last_name=$3, first_name=$4, middle_name=$5, + position=$6, category=$7, degree=$8, practice_start_year=$9, rating_override=$10, + quote=$11, bio=$12, booking_provider=$13, medflex_user=$14, + sort=$15, published=$16, absent=$17, show_on_home=$18, + seo_title=$19, seo_description=$20, seo_h1=$21, photo_id=$22, updated_at=now() + WHERE id=$1 AND deleted_at IS NULL`, + id, in.Slug, in.LastName, in.FirstName, in.MiddleName, + in.Position, in.Category, in.Degree, in.PracticeYear, in.Rating, + in.Quote, in.Bio, in.Booking, in.MedflexUser, + in.Sort, in.Published, in.Absent, in.ShowOnHome, + in.SEOTitle, in.SEODesc, in.SEOH1, in.PhotoID) + if err != nil { + return wrapConflict(err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// SetDoctorLinks заменяет услуги и филиалы врача целиком, в одной транзакции. +func (s *Storage) SetDoctorLinks(ctx context.Context, doctorID int64, serviceIDs, branchIDs []int64) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck // после Commit откат ничего не делает + var exists bool + if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM doctors WHERE id=$1 AND deleted_at IS NULL)`, doctorID).Scan(&exists); err != nil { + return err + } + if !exists { + return ErrNotFound + } + if _, err := tx.Exec(ctx, `DELETE FROM doctor_services WHERE doctor_id=$1`, doctorID); err != nil { + return err + } + if _, err := tx.Exec(ctx, ` + INSERT INTO doctor_services (doctor_id, service_id) + SELECT $1, unnest($2::bigint[]) ON CONFLICT DO NOTHING`, doctorID, serviceIDs); err != nil { + return wrapConflict(err) + } + if _, err := tx.Exec(ctx, `DELETE FROM doctor_branches WHERE doctor_id=$1`, doctorID); err != nil { + return err + } + if _, err := tx.Exec(ctx, ` + INSERT INTO doctor_branches (doctor_id, branch_id) + SELECT $1, unnest($2::bigint[]) ON CONFLICT DO NOTHING`, doctorID, branchIDs); err != nil { + return wrapConflict(err) + } + return tx.Commit(ctx) +} + +// ---------- Записи биографии врача ---------- + +type AdminBioEntry struct { + ID int64 `json:"id"` + DoctorID int64 `json:"doctor_id"` + Kind string `json:"kind"` + YearFrom *int `json:"year_from"` + YearTo *int `json:"year_to"` + Title string `json:"title"` + Description string `json:"description"` + Sort int `json:"sort"` +} + +func (s *Storage) AdminBioEntries(ctx context.Context, doctorID int64) ([]AdminBioEntry, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, doctor_id, kind, year_from, year_to, title, description, sort + FROM doctor_bio_entries WHERE doctor_id=$1 + ORDER BY kind, sort, id`, doctorID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AdminBioEntry + for rows.Next() { + var a AdminBioEntry + if err := rows.Scan(&a.ID, &a.DoctorID, &a.Kind, &a.YearFrom, &a.YearTo, + &a.Title, &a.Description, &a.Sort); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +type BioEntryInput struct { + DoctorID int64 `json:"doctor_id"` + Kind string `json:"kind"` + YearFrom *int `json:"year_from"` + YearTo *int `json:"year_to"` + Title string `json:"title"` + Description string `json:"description"` + Sort int `json:"sort"` +} + +func (s *Storage) AdminCreateBioEntry(ctx context.Context, in BioEntryInput) (int64, error) { + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO doctor_bio_entries (doctor_id, kind, year_from, year_to, title, description, sort) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`, + in.DoctorID, in.Kind, in.YearFrom, in.YearTo, in.Title, in.Description, in.Sort).Scan(&id) + return id, err +} + +func (s *Storage) AdminUpdateBioEntry(ctx context.Context, id int64, in BioEntryInput) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE doctor_bio_entries SET kind=$2, year_from=$3, year_to=$4, + title=$5, description=$6, sort=$7 + WHERE id=$1`, + id, in.Kind, in.YearFrom, in.YearTo, in.Title, in.Description, in.Sort) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *Storage) AdminDeleteBioEntry(ctx context.Context, id int64) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM doctor_bio_entries WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/storage/admin_documents.go b/internal/storage/admin_documents.go new file mode 100644 index 0000000..04ddbca --- /dev/null +++ b/internal/storage/admin_documents.go @@ -0,0 +1,94 @@ +package storage + +import ( + "context" + "time" +) + +type AdminDocument struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + FileID *int64 `json:"file_id"` + File string `json:"file,omitempty"` + FileMime string `json:"file_mime,omitempty"` + PreviewID *int64 `json:"preview_id"` + Preview string `json:"preview,omitempty"` + IssuedAt *time.Time `json:"issued_at"` + Sort int `json:"sort"` + Published bool `json:"published"` +} + +func (s *Storage) AdminDocuments(ctx context.Context) ([]AdminDocument, error) { + rows, err := s.pool.Query(ctx, ` + SELECT d.id, d.kind, d.title, d.body, d.file_id, COALESCE(f.path, ''), COALESCE(f.mime, ''), + d.preview_id, COALESCE(p.path, ''), d.issued_at, d.sort, d.published + FROM documents d + LEFT JOIN media f ON f.id = d.file_id + LEFT JOIN media p ON p.id = d.preview_id + ORDER BY d.kind, d.sort, d.id`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AdminDocument + for rows.Next() { + var d AdminDocument + if err := rows.Scan(&d.ID, &d.Kind, &d.Title, &d.Body, &d.FileID, &d.File, &d.FileMime, + &d.PreviewID, &d.Preview, &d.IssuedAt, &d.Sort, &d.Published); err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + +type DocumentInput struct { + Kind string + Title string + Body string + FileID *int64 + PreviewID *int64 + IssuedAt *time.Time + Sort int + Published bool +} + +func (s *Storage) AdminCreateDocument(ctx context.Context, in DocumentInput) (int64, error) { + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO documents (kind, title, body, file_id, preview_id, issued_at, sort, published) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + RETURNING id`, + in.Kind, in.Title, in.Body, in.FileID, in.PreviewID, in.IssuedAt, in.Sort, in.Published).Scan(&id) + return id, wrapConflict(err) +} + +func (s *Storage) AdminUpdateDocument(ctx context.Context, id int64, in DocumentInput) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE documents SET kind=$2, title=$3, body=$4, file_id=$5, preview_id=$6, issued_at=$7, + sort=$8, published=$9 + WHERE id=$1`, + id, in.Kind, in.Title, in.Body, in.FileID, in.PreviewID, in.IssuedAt, in.Sort, in.Published) + if err != nil { + return wrapConflict(err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// AdminDeleteDocument удаляет запись; файлы остаются в медиатеке. +func (s *Storage) AdminDeleteDocument(ctx context.Context, id int64) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM documents WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/storage/admin_faq.go b/internal/storage/admin_faq.go new file mode 100644 index 0000000..269c15f --- /dev/null +++ b/internal/storage/admin_faq.go @@ -0,0 +1,79 @@ +package storage + +import "context" + +type AdminFAQ struct { + ID int64 `json:"id"` + Question string `json:"question"` + Answer string `json:"answer"` + ServiceID *int64 `json:"service_id"` + ServiceName string `json:"service_name,omitempty"` + Sort int `json:"sort"` + Published bool `json:"published"` +} + +func (s *Storage) AdminFAQ(ctx context.Context) ([]AdminFAQ, error) { + rows, err := s.pool.Query(ctx, ` + SELECT f.id, f.question, f.answer, f.service_id, COALESCE(s.name, ''), f.sort, f.published + FROM faq f + LEFT JOIN services s ON s.id = f.service_id + ORDER BY f.sort, f.id`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AdminFAQ + for rows.Next() { + var f AdminFAQ + if err := rows.Scan(&f.ID, &f.Question, &f.Answer, &f.ServiceID, &f.ServiceName, + &f.Sort, &f.Published); err != nil { + return nil, err + } + out = append(out, f) + } + return out, rows.Err() +} + +type FAQInput struct { + Question string + Answer string + ServiceID *int64 + Sort int + Published bool +} + +func (s *Storage) AdminCreateFAQ(ctx context.Context, in FAQInput) (int64, error) { + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO faq (question, answer, service_id, sort, published) + VALUES ($1,$2,$3,$4,$5) + RETURNING id`, + in.Question, in.Answer, in.ServiceID, in.Sort, in.Published).Scan(&id) + return id, wrapConflict(err) +} + +func (s *Storage) AdminUpdateFAQ(ctx context.Context, id int64, in FAQInput) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE faq SET question=$2, answer=$3, service_id=$4, sort=$5, published=$6 + WHERE id=$1`, + id, in.Question, in.Answer, in.ServiceID, in.Sort, in.Published) + if err != nil { + return wrapConflict(err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *Storage) AdminDeleteFAQ(ctx context.Context, id int64) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM faq WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/storage/admin_inbox.go b/internal/storage/admin_inbox.go new file mode 100644 index 0000000..52285ec --- /dev/null +++ b/internal/storage/admin_inbox.go @@ -0,0 +1,240 @@ +package storage + +import ( + "context" + "encoding/json" + "time" +) + +// ---------- Заявки ---------- + +type AdminLead struct { + ID int64 `json:"id"` + FormCode string `json:"form_code,omitempty"` + FormTitle string `json:"form_title,omitempty"` + Name string `json:"name"` + Phone string `json:"phone"` + Email string `json:"email,omitempty"` + ServiceName string `json:"service_name,omitempty"` + DoctorName string `json:"doctor_name,omitempty"` + Message string `json:"message,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + Status string `json:"status"` + Comment string `json:"comment,omitempty"` + PageURL string `json:"page_url,omitempty"` + UTM json.RawMessage `json:"utm,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// AdminLeads: постранично, с фильтрами по статусу, тексту и календарным датам. +func (s *Storage) AdminLeads(ctx context.Context, status, q string, from, to *time.Time, limit, offset int) ([]AdminLead, int, error) { + var total int + if err := s.pool.QueryRow(ctx, ` + SELECT count(*) FROM leads l + WHERE ($1 = '' OR l.status = $1) + AND ($2 = '' OR l.name ILIKE '%' || $2 || '%' OR l.phone ILIKE '%' || $2 || '%' OR l.message ILIKE '%' || $2 || '%') + AND ($3::timestamptz IS NULL OR l.created_at >= $3) + AND ($4::timestamptz IS NULL OR l.created_at < $4)`, + status, q, from, to).Scan(&total); err != nil { + return nil, 0, err + } + + rows, err := s.pool.Query(ctx, ` + SELECT l.id, COALESCE(f.code, ''), COALESCE(f.title, ''), + l.name, l.phone, l.email, + COALESCE(sv.name, ''), COALESCE(d.last_name || ' ' || d.first_name, ''), + l.message, l.payload, l.status, l.comment, l.page_url, l.utm, l.created_at + FROM leads l + LEFT JOIN forms f ON f.id = l.form_id + LEFT JOIN services sv ON sv.id = l.service_id + LEFT JOIN doctors d ON d.id = l.doctor_id + WHERE ($1 = '' OR l.status = $1) + AND ($2 = '' OR l.name ILIKE '%' || $2 || '%' OR l.phone ILIKE '%' || $2 || '%' OR l.message ILIKE '%' || $2 || '%') + AND ($3::timestamptz IS NULL OR l.created_at >= $3) + AND ($4::timestamptz IS NULL OR l.created_at < $4) + ORDER BY l.created_at DESC + LIMIT $5 OFFSET $6`, status, q, from, to, limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var out []AdminLead + for rows.Next() { + var a AdminLead + if err := rows.Scan(&a.ID, &a.FormCode, &a.FormTitle, &a.Name, &a.Phone, &a.Email, + &a.ServiceName, &a.DoctorName, &a.Message, &a.Payload, + &a.Status, &a.Comment, &a.PageURL, &a.UTM, &a.CreatedAt); err != nil { + return nil, 0, err + } + out = append(out, a) + } + return out, total, rows.Err() +} + +// LeadCounts — сколько заявок в каждом статусе, для бейджей в интерфейсе. +func (s *Storage) LeadCounts(ctx context.Context) (map[string]int, error) { + rows, err := s.pool.Query(ctx, `SELECT status, count(*) FROM leads GROUP BY status`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]int{} + for rows.Next() { + var st string + var n int + if err := rows.Scan(&st, &n); err != nil { + return nil, err + } + out[st] = n + } + return out, rows.Err() +} + +func (s *Storage) LeadTodayCount(ctx context.Context) (int, error) { + var count int + now := time.Now().In(time.Local) + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + err := s.pool.QueryRow(ctx, `SELECT count(*) FROM leads WHERE created_at >= $1 AND created_at < $2`, start, start.AddDate(0, 0, 1)).Scan(&count) + return count, err +} + +type LeadUpdate struct { + Status string `json:"status"` + Comment string `json:"comment"` +} + +func (s *Storage) AdminUpdateLead(ctx context.Context, id int64, in LeadUpdate) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE leads SET status=$2, comment=$3, updated_at=now() WHERE id=$1`, + id, in.Status, in.Comment) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// ---------- Отзывы (включая непроверенные) ---------- + +type AdminReview struct { + ID int64 `json:"id"` + AuthorName string `json:"author_name"` + Rating *int `json:"rating"` + Text string `json:"text"` + VideoURL string `json:"video_url"` + DoctorID *int64 `json:"doctor_id"` + DoctorName string `json:"doctor_name,omitempty"` + Source string `json:"source"` + Status string `json:"status"` + Reply string `json:"reply"` + CreatedAt time.Time `json:"created_at"` +} + +func (s *Storage) AdminReviews(ctx context.Context, status, q string, limit, offset int) ([]AdminReview, int, error) { + var total int + if err := s.pool.QueryRow(ctx, ` + SELECT count(*) FROM reviews r + WHERE ($1 = '' OR r.status = $1) + AND ($2 = '' OR r.author_name ILIKE '%' || $2 || '%' OR r.text ILIKE '%' || $2 || '%')`, + status, q).Scan(&total); err != nil { + return nil, 0, err + } + + rows, err := s.pool.Query(ctx, ` + SELECT r.id, r.author_name, r.rating, r.text, r.video_url, r.doctor_id, + COALESCE(d.last_name || ' ' || d.first_name, ''), + r.source, r.status, r.reply, r.created_at + FROM reviews r + LEFT JOIN doctors d ON d.id = r.doctor_id + WHERE ($1 = '' OR r.status = $1) + AND ($2 = '' OR r.author_name ILIKE '%' || $2 || '%' OR r.text ILIKE '%' || $2 || '%') + ORDER BY + CASE r.status WHEN 'pending' THEN 0 ELSE 1 END, -- непроверенные сверху + r.created_at DESC + LIMIT $3 OFFSET $4`, status, q, limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var out []AdminReview + for rows.Next() { + var a AdminReview + if err := rows.Scan(&a.ID, &a.AuthorName, &a.Rating, &a.Text, &a.VideoURL, &a.DoctorID, + &a.DoctorName, &a.Source, &a.Status, &a.Reply, &a.CreatedAt); err != nil { + return nil, 0, err + } + out = append(out, a) + } + return out, total, rows.Err() +} + +func (s *Storage) ReviewCounts(ctx context.Context) (map[string]int, error) { + rows, err := s.pool.Query(ctx, `SELECT status, count(*) FROM reviews GROUP BY status`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]int{} + for rows.Next() { + var st string + var n int + if err := rows.Scan(&st, &n); err != nil { + return nil, err + } + out[st] = n + } + return out, rows.Err() +} + +type ReviewUpdate struct { + AuthorName string `json:"author_name"` + Rating *int `json:"rating"` + Text string `json:"text"` + VideoURL string `json:"video_url"` + DoctorID *int64 `json:"doctor_id"` + Status string `json:"status"` + Reply string `json:"reply"` +} + +func (s *Storage) AdminUpdateReview(ctx context.Context, id int64, in ReviewUpdate) error { + // moderated_at проставляется, когда отзыв уходит из ожидания проверки + tag, err := s.pool.Exec(ctx, ` + UPDATE reviews SET author_name=$2, rating=$3, text=$4, video_url=$5, doctor_id=$6, + status=$7, reply=$8, + moderated_at = CASE WHEN $7 <> 'pending' THEN now() ELSE moderated_at END, + updated_at = now() + WHERE id=$1`, + id, in.AuthorName, in.Rating, in.Text, in.VideoURL, in.DoctorID, in.Status, in.Reply) + if err != nil { + return wrapConflict(err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *Storage) AdminCreateReview(ctx context.Context, in ReviewUpdate) (int64, error) { + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO reviews (author_name, rating, text, video_url, doctor_id, source, status, reply, moderated_at) + VALUES ($1,$2,$3,$4,$5,'manual',$6,$7, CASE WHEN $6 <> 'pending' THEN now() END) + RETURNING id`, + in.AuthorName, in.Rating, in.Text, in.VideoURL, in.DoctorID, in.Status, in.Reply).Scan(&id) + return id, wrapConflict(err) +} + +func (s *Storage) AdminDeleteReview(ctx context.Context, id int64) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM reviews WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/storage/admin_partners.go b/internal/storage/admin_partners.go new file mode 100644 index 0000000..8c4b2d0 --- /dev/null +++ b/internal/storage/admin_partners.go @@ -0,0 +1,78 @@ +package storage + +import "context" + +type AdminPartner struct { + ID int64 `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + LogoID *int64 `json:"logo_id"` + Logo string `json:"logo,omitempty"` + Sort int `json:"sort"` + Published bool `json:"published"` +} + +func (s *Storage) AdminPartners(ctx context.Context) ([]AdminPartner, error) { + rows, err := s.pool.Query(ctx, ` + SELECT p.id, p.name, p.url, p.logo_id, COALESCE(m.path, ''), p.sort, p.published + FROM partners p + LEFT JOIN media m ON m.id = p.logo_id + ORDER BY p.sort, p.name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AdminPartner + for rows.Next() { + var p AdminPartner + if err := rows.Scan(&p.ID, &p.Name, &p.URL, &p.LogoID, &p.Logo, &p.Sort, &p.Published); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +type PartnerInput struct { + Name string + URL string + LogoID *int64 + Sort int + Published bool +} + +func (s *Storage) AdminCreatePartner(ctx context.Context, in PartnerInput) (int64, error) { + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO partners (name, url, logo_id, sort, published) + VALUES ($1,$2,$3,$4,$5) + RETURNING id`, + in.Name, in.URL, in.LogoID, in.Sort, in.Published).Scan(&id) + return id, wrapConflict(err) +} + +func (s *Storage) AdminUpdatePartner(ctx context.Context, id int64, in PartnerInput) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE partners SET name=$2, url=$3, logo_id=$4, sort=$5, published=$6 + WHERE id=$1`, + id, in.Name, in.URL, in.LogoID, in.Sort, in.Published) + if err != nil { + return wrapConflict(err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *Storage) AdminDeletePartner(ctx context.Context, id int64) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM partners WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/storage/admin_posts.go b/internal/storage/admin_posts.go new file mode 100644 index 0000000..3821e08 --- /dev/null +++ b/internal/storage/admin_posts.go @@ -0,0 +1,107 @@ +package storage + +import ( + "context" + "encoding/json" + "time" +) + +// AdminPost — новость или статья для админки, включая скрытые. +type AdminPost struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + Slug string `json:"slug"` + Title string `json:"title"` + Lead string `json:"lead"` + Blocks json.RawMessage `json:"blocks"` + ImageID *int64 `json:"image_id"` + Image string `json:"image,omitempty"` + Published bool `json:"published"` + PublishedAt time.Time `json:"published_at"` + SEOTitle string `json:"seo_title"` + SEODesc string `json:"seo_description"` + SEOH1 string `json:"seo_h1"` +} + +// AdminPosts — публикации одного вида (news | article), новые сверху. +func (s *Storage) AdminPosts(ctx context.Context, kind string) ([]AdminPost, error) { + rows, err := s.pool.Query(ctx, ` + SELECT p.id, p.kind, p.slug, p.title, p.lead, p.blocks, p.image_id, COALESCE(m.path, ''), + p.published, p.published_at, p.seo_title, p.seo_description, p.seo_h1 + FROM posts p + LEFT JOIN media m ON m.id = p.image_id + WHERE p.kind = $1 AND p.deleted_at IS NULL + ORDER BY p.published_at DESC, p.id DESC`, kind) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AdminPost + for rows.Next() { + var a AdminPost + if err := rows.Scan(&a.ID, &a.Kind, &a.Slug, &a.Title, &a.Lead, &a.Blocks, &a.ImageID, &a.Image, + &a.Published, &a.PublishedAt, &a.SEOTitle, &a.SEODesc, &a.SEOH1); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +type PostInput struct { + Kind string + Slug string + Title string + Lead string + Blocks json.RawMessage + ImageID *int64 + Published bool + PublishedAt time.Time + SEOTitle string + SEODesc string + SEOH1 string +} + +func (s *Storage) AdminCreatePost(ctx context.Context, in PostInput) (int64, error) { + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO posts (kind, slug, title, lead, blocks, image_id, published, published_at, + seo_title, seo_description, seo_h1) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + RETURNING id`, + in.Kind, in.Slug, in.Title, in.Lead, in.Blocks, in.ImageID, in.Published, in.PublishedAt, + in.SEOTitle, in.SEODesc, in.SEOH1).Scan(&id) + return id, wrapConflict(err) +} + +// AdminUpdatePost меняет публикацию; вид (новость или статья) после создания не меняется. +func (s *Storage) AdminUpdatePost(ctx context.Context, id int64, in PostInput) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE posts SET slug=$3, title=$4, lead=$5, blocks=$6, image_id=$7, published=$8, + published_at=$9, seo_title=$10, seo_description=$11, seo_h1=$12, updated_at=now() + WHERE id=$1 AND kind=$2 AND deleted_at IS NULL`, + id, in.Kind, in.Slug, in.Title, in.Lead, in.Blocks, in.ImageID, in.Published, + in.PublishedAt, in.SEOTitle, in.SEODesc, in.SEOH1) + if err != nil { + return wrapConflict(err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// AdminDeletePost — мягкое удаление, как у акций. +func (s *Storage) AdminDeletePost(ctx context.Context, kind string, id int64) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE posts SET deleted_at=now(), published=false, updated_at=now() + WHERE id=$1 AND kind=$2 AND deleted_at IS NULL`, id, kind) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/storage/admin_promos.go b/internal/storage/admin_promos.go new file mode 100644 index 0000000..d268d0a --- /dev/null +++ b/internal/storage/admin_promos.go @@ -0,0 +1,116 @@ +package storage + +import ( + "context" + "time" +) + +type AdminPromo struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Lead string `json:"lead"` + ImageID *int64 `json:"image_id"` + Image string `json:"image,omitempty"` + DiscountPercent *int `json:"discount_percent"` + Terms string `json:"terms"` + CheckupID *int64 `json:"checkup_id"` + CheckupName string `json:"checkup_name,omitempty"` + StartsAt *time.Time `json:"starts_at"` + EndsAt *time.Time `json:"ends_at"` + Published bool `json:"published"` + PublishedAt time.Time `json:"published_at"` + SEOTitle string `json:"seo_title"` + SEODesc string `json:"seo_description"` + SEOH1 string `json:"seo_h1"` +} + +// AdminPromos — все акции, включая скрытые и завершённые. +func (s *Storage) AdminPromos(ctx context.Context) ([]AdminPromo, error) { + rows, err := s.pool.Query(ctx, ` + SELECT p.id, p.slug, p.title, p.lead, p.image_id, COALESCE(m.path, ''), + p.discount_percent, p.terms, p.checkup_id, COALESCE(c.name, ''), + p.starts_at, p.ends_at, p.published, p.published_at, + p.seo_title, p.seo_description, p.seo_h1 + FROM posts p + LEFT JOIN media m ON m.id = p.image_id + LEFT JOIN checkups c ON c.id = p.checkup_id + WHERE p.kind = 'promo' AND p.deleted_at IS NULL + ORDER BY p.published_at DESC, p.id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AdminPromo + for rows.Next() { + var a AdminPromo + if err := rows.Scan(&a.ID, &a.Slug, &a.Title, &a.Lead, &a.ImageID, &a.Image, + &a.DiscountPercent, &a.Terms, &a.CheckupID, &a.CheckupName, + &a.StartsAt, &a.EndsAt, &a.Published, &a.PublishedAt, + &a.SEOTitle, &a.SEODesc, &a.SEOH1); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +type PromoInput struct { + Slug string + Title string + Lead string + ImageID *int64 + DiscountPercent *int + Terms string + CheckupID *int64 + StartsAt *time.Time + EndsAt *time.Time + Published bool + SEOTitle string + SEODesc string + SEOH1 string +} + +func (s *Storage) AdminCreatePromo(ctx context.Context, in PromoInput) (int64, error) { + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO posts (kind, slug, title, lead, image_id, discount_percent, terms, checkup_id, + starts_at, ends_at, published, seo_title, seo_description, seo_h1) + VALUES ('promo', $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING id`, + in.Slug, in.Title, in.Lead, in.ImageID, in.DiscountPercent, in.Terms, in.CheckupID, + in.StartsAt, in.EndsAt, in.Published, in.SEOTitle, in.SEODesc, in.SEOH1).Scan(&id) + return id, wrapConflict(err) +} + +func (s *Storage) AdminUpdatePromo(ctx context.Context, id int64, in PromoInput) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE posts SET slug=$2, title=$3, lead=$4, image_id=$5, discount_percent=$6, terms=$7, + checkup_id=$8, starts_at=$9, ends_at=$10, published=$11, + seo_title=$12, seo_description=$13, seo_h1=$14, updated_at=now() + WHERE id=$1 AND kind='promo' AND deleted_at IS NULL`, + id, in.Slug, in.Title, in.Lead, in.ImageID, in.DiscountPercent, in.Terms, + in.CheckupID, in.StartsAt, in.EndsAt, in.Published, in.SEOTitle, in.SEODesc, in.SEOH1) + if err != nil { + return wrapConflict(err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// AdminDeletePromo скрывает акцию мягким удалением: запись остаётся для истории и аудита. +func (s *Storage) AdminDeletePromo(ctx context.Context, id int64) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE posts SET deleted_at=now(), published=false, updated_at=now() + WHERE id=$1 AND kind='promo' AND deleted_at IS NULL`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/storage/admin_services.go b/internal/storage/admin_services.go new file mode 100644 index 0000000..58e86d9 --- /dev/null +++ b/internal/storage/admin_services.go @@ -0,0 +1,45 @@ +package storage + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" +) + +type NewService struct { + ParentID int64 + Slug string + Name string + Lead string +} + +// AdminCreateService заводит услугу внутри направления, скрытой. Адрес — адрес направления +// плюс slug: /services/<направление>//. Вложенность — только один уровень. +func (s *Storage) AdminCreateService(ctx context.Context, in NewService) (int64, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck // после Commit откат ничего не делает + var parentPath string + if err := tx.QueryRow(ctx, ` + SELECT path FROM services WHERE id=$1 AND parent_id IS NULL AND deleted_at IS NULL`, + in.ParentID).Scan(&parentPath); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, ErrBadReference + } + return 0, err + } + var id int64 + if err := tx.QueryRow(ctx, ` + INSERT INTO services (parent_id, slug, path, name, lead, published, + sort) + VALUES ($1, $2, $3, $4, $5, false, + COALESCE((SELECT max(sort) + 10 FROM services WHERE parent_id = $1 AND deleted_at IS NULL), 100)) + RETURNING id`, + in.ParentID, in.Slug, parentPath+"/"+in.Slug, in.Name, in.Lead).Scan(&id); err != nil { + return 0, wrapConflict(err) + } + return id, tx.Commit(ctx) +} diff --git a/internal/storage/admin_site.go b/internal/storage/admin_site.go new file mode 100644 index 0000000..a1b2bbe --- /dev/null +++ b/internal/storage/admin_site.go @@ -0,0 +1,181 @@ +package storage + +import ( + "context" + "encoding/json" + "time" +) + +// ---------- Страницы ---------- + +type AdminPage struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Blocks json.RawMessage `json:"blocks"` + Published bool `json:"published"` + SEOTitle string `json:"seo_title"` + SEODesc string `json:"seo_description"` + SEOH1 string `json:"seo_h1"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (s *Storage) AdminPages(ctx context.Context) ([]AdminPage, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, slug, title, blocks, published, seo_title, seo_description, seo_h1, updated_at + FROM pages ORDER BY slug`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AdminPage + for rows.Next() { + var p AdminPage + if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.Blocks, &p.Published, + &p.SEOTitle, &p.SEODesc, &p.SEOH1, &p.UpdatedAt); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (s *Storage) AdminPage(ctx context.Context, id int64) (*AdminPage, error) { + var p AdminPage + err := s.pool.QueryRow(ctx, ` + SELECT id, slug, title, blocks, published, seo_title, seo_description, seo_h1, updated_at + FROM pages WHERE id=$1`, id).Scan(&p.ID, &p.Slug, &p.Title, &p.Blocks, &p.Published, + &p.SEOTitle, &p.SEODesc, &p.SEOH1, &p.UpdatedAt) + if err != nil { + return nil, wrapNotFound(err) + } + return &p, nil +} + +type PageInput struct { + Title string + Blocks json.RawMessage + Published bool + SEOTitle string + SEODesc string + SEOH1 string +} + +// AdminUpdatePage меняет содержимое страницы. Адрес (slug) не меняется: страницы +// привязаны к маршрутам сайта. Новых страниц админка не создаёт по той же причине. +func (s *Storage) AdminUpdatePage(ctx context.Context, id int64, in PageInput) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE pages SET title=$2, blocks=$3, published=$4, seo_title=$5, seo_description=$6, + seo_h1=$7, updated_at=now() + WHERE id=$1`, + id, in.Title, in.Blocks, in.Published, in.SEOTitle, in.SEODesc, in.SEOH1) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// ---------- Настройки ---------- + +// AdminSetSettings записывает несколько ключей за раз в одной транзакции. +func (s *Storage) AdminSetSettings(ctx context.Context, values map[string]any) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck // после Commit откат ничего не делает + for key, value := range values { + raw, err := json.Marshal(value) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, ` + INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, now()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, + key, raw); err != nil { + return err + } + } + return tx.Commit(ctx) +} + +// ---------- Филиалы ---------- + +type AdminBranch struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Address string `json:"address"` + Phones []string `json:"phones"` + WorkHours []string `json:"work_hours"` + Lat *float64 `json:"lat"` + Lon *float64 `json:"lon"` + Sort int `json:"sort"` + Published bool `json:"published"` +} + +func (s *Storage) AdminBranches(ctx context.Context) ([]AdminBranch, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, slug, name, address, phones, work_hours, lat, lon, sort, published + FROM branches ORDER BY sort, name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AdminBranch + for rows.Next() { + var b AdminBranch + if err := rows.Scan(&b.ID, &b.Slug, &b.Name, &b.Address, &b.Phones, &b.WorkHours, + &b.Lat, &b.Lon, &b.Sort, &b.Published); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +type BranchInput struct { + Slug string + Name string + Address string + Phones []string + WorkHours []string + Lat *float64 + Lon *float64 + Sort int + Published bool +} + +func (s *Storage) AdminCreateBranch(ctx context.Context, in BranchInput) (int64, error) { + phones, _ := json.Marshal(in.Phones) + hours, _ := json.Marshal(in.WorkHours) + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO branches (slug, name, address, phones, work_hours, lat, lon, sort, published) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING id`, + in.Slug, in.Name, in.Address, phones, hours, in.Lat, in.Lon, in.Sort, in.Published).Scan(&id) + return id, wrapConflict(err) +} + +func (s *Storage) AdminUpdateBranch(ctx context.Context, id int64, in BranchInput) error { + phones, _ := json.Marshal(in.Phones) + hours, _ := json.Marshal(in.WorkHours) + tag, err := s.pool.Exec(ctx, ` + UPDATE branches SET slug=$2, name=$3, address=$4, phones=$5, work_hours=$6, lat=$7, lon=$8, + sort=$9, published=$10, updated_at=now() + WHERE id=$1`, + id, in.Slug, in.Name, in.Address, phones, hours, in.Lat, in.Lon, in.Sort, in.Published) + if err != nil { + return wrapConflict(err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/storage/admin_specialties.go b/internal/storage/admin_specialties.go new file mode 100644 index 0000000..7ab7ae1 --- /dev/null +++ b/internal/storage/admin_specialties.go @@ -0,0 +1,124 @@ +package storage + +import ( + "context" + "errors" +) + +// ErrInUse — запись нельзя удалить, пока на неё ссылаются (например, специальность с врачами). +var ErrInUse = errors.New("storage: in use") + +type AdminSpecialty struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + NamePlural string `json:"name_plural"` + Sort int `json:"sort"` + Published bool `json:"published"` + Doctors int `json:"doctors"` // все врачи раздела, включая скрытых +} + +type SpecialtyInput struct { + Slug string + Name string + NamePlural string + Sort int +} + +func (s *Storage) AdminSpecialties(ctx context.Context) ([]AdminSpecialty, error) { + rows, err := s.pool.Query(ctx, ` + SELECT sp.id, sp.slug, sp.name, sp.name_plural, sp.sort, sp.published, + (SELECT count(*) FROM doctor_specialties ds + JOIN doctors d ON d.id = ds.doctor_id AND d.deleted_at IS NULL + WHERE ds.specialty_id = sp.id) + FROM specialties sp + ORDER BY sp.sort, sp.name_plural, sp.id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AdminSpecialty + for rows.Next() { + var sp AdminSpecialty + if err := rows.Scan(&sp.ID, &sp.Slug, &sp.Name, &sp.NamePlural, &sp.Sort, &sp.Published, &sp.Doctors); err != nil { + return nil, err + } + out = append(out, sp) + } + return out, rows.Err() +} + +func (s *Storage) AdminCreateSpecialty(ctx context.Context, in SpecialtyInput) (int64, error) { + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO specialties (slug, name, name_plural, sort) VALUES ($1, $2, $3, $4) RETURNING id`, + in.Slug, in.Name, in.NamePlural, in.Sort).Scan(&id) + return id, wrapConflict(err) +} + +// AdminUpdateSpecialty меняет название и порядок. Адрес раздела не меняется: +// он входит в адреса страниц всех врачей раздела. +func (s *Storage) AdminUpdateSpecialty(ctx context.Context, id int64, in SpecialtyInput) error { + tag, err := s.pool.Exec(ctx, `UPDATE specialties SET name=$2, name_plural=$3, sort=$4 WHERE id=$1`, + id, in.Name, in.NamePlural, in.Sort) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// AdminDeleteSpecialty удаляет только пустой раздел: иначе у врачей пропала бы специальность +// (связи удаляются каскадом) и сменились бы адреса их страниц. +func (s *Storage) AdminDeleteSpecialty(ctx context.Context, id int64) error { + tag, err := s.pool.Exec(ctx, ` + DELETE FROM specialties sp WHERE sp.id = $1 + AND NOT EXISTS (SELECT 1 FROM doctor_specialties ds WHERE ds.specialty_id = sp.id)`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + var exists bool + if err := s.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM specialties WHERE id=$1)`, id).Scan(&exists); err != nil { + return err + } + if exists { + return ErrInUse + } + return ErrNotFound + } + return nil +} + +type NewDoctor struct { + Slug string + LastName string + FirstName string + MiddleName string + Position string + SpecialtyID int64 +} + +// AdminCreateDoctor заводит врача скрытым: на сайт он попадёт, когда карточку заполнят и включат. +func (s *Storage) AdminCreateDoctor(ctx context.Context, in NewDoctor) (int64, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck // после Commit откат ничего не делает + var id int64 + if err := tx.QueryRow(ctx, ` + INSERT INTO doctors (slug, last_name, first_name, middle_name, position, published) + VALUES ($1, $2, $3, $4, $5, false) RETURNING id`, + in.Slug, in.LastName, in.FirstName, in.MiddleName, in.Position).Scan(&id); err != nil { + return 0, wrapConflict(err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO doctor_specialties (doctor_id, specialty_id, is_primary) VALUES ($1, $2, true)`, + id, in.SpecialtyID); err != nil { + return 0, wrapConflict(err) + } + return id, tx.Commit(ctx) +} diff --git a/internal/storage/admin_vacancies.go b/internal/storage/admin_vacancies.go new file mode 100644 index 0000000..3655b8a --- /dev/null +++ b/internal/storage/admin_vacancies.go @@ -0,0 +1,90 @@ +package storage + +import "context" + +type AdminVacancy struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description"` + Duties string `json:"duties"` + Requirements string `json:"requirements"` + Conditions string `json:"conditions"` + Salary string `json:"salary"` + Sort int `json:"sort"` + Published bool `json:"published"` +} + +func (s *Storage) AdminVacancies(ctx context.Context) ([]AdminVacancy, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, slug, title, description, duties, requirements, conditions, salary, sort, published + FROM vacancies + ORDER BY sort, title`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AdminVacancy + for rows.Next() { + var v AdminVacancy + if err := rows.Scan(&v.ID, &v.Slug, &v.Title, &v.Description, &v.Duties, &v.Requirements, + &v.Conditions, &v.Salary, &v.Sort, &v.Published); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +type VacancyInput struct { + Slug string + Title string + Description string + Duties string + Requirements string + Conditions string + Salary string + Sort int + Published bool +} + +func (s *Storage) AdminCreateVacancy(ctx context.Context, in VacancyInput) (int64, error) { + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO vacancies (slug, title, description, duties, requirements, conditions, salary, sort, published) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING id`, + in.Slug, in.Title, in.Description, in.Duties, in.Requirements, in.Conditions, in.Salary, + in.Sort, in.Published).Scan(&id) + return id, wrapConflict(err) +} + +func (s *Storage) AdminUpdateVacancy(ctx context.Context, id int64, in VacancyInput) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE vacancies SET slug=$2, title=$3, description=$4, duties=$5, requirements=$6, + conditions=$7, salary=$8, sort=$9, published=$10, updated_at=now() + WHERE id=$1`, + id, in.Slug, in.Title, in.Description, in.Duties, in.Requirements, in.Conditions, in.Salary, + in.Sort, in.Published) + if err != nil { + return wrapConflict(err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// AdminDeleteVacancy удаляет вакансию насовсем: история по ним не нужна, +// а закрытую на время вакансию достаточно скрыть галочкой. +func (s *Storage) AdminDeleteVacancy(ctx context.Context, id int64) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM vacancies WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/storage/banners.go b/internal/storage/banners.go new file mode 100644 index 0000000..e61387b --- /dev/null +++ b/internal/storage/banners.go @@ -0,0 +1,152 @@ +package storage + +import ( + "context" + "time" + + "dana/internal/domain" +) + +// Banners отдаёт действующие баннеры: опубликованные и попадающие в период показа. +func (s *Storage) Banners(ctx context.Context, position string) ([]domain.Banner, error) { + rows, err := s.pool.Query(ctx, ` + SELECT b.id, b.position, b.layout, b.theme, b.title, b.subtitle, b.badge, b.chips, + COALESCE(mi.path, ''), COALESCE(mm.path, ''), + b.link_url, b.button_text, b.disclaimer + FROM banners b + LEFT JOIN media mi ON mi.id = b.image_id + LEFT JOIN media mm ON mm.id = b.mobile_image_id + WHERE b.published + AND (b.starts_at IS NULL OR b.starts_at <= now()) + AND (b.ends_at IS NULL OR b.ends_at > now()) + AND ($1 = '' OR b.position = $1) + ORDER BY b.sort, b.id`, position) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []domain.Banner{} + for rows.Next() { + var b domain.Banner + if err := rows.Scan(&b.ID, &b.Position, &b.Layout, &b.Theme, &b.Title, &b.Subtitle, &b.Badge, &b.Chips, + &b.Image, &b.MobileImage, &b.LinkURL, &b.ButtonText, &b.Disclaimer); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +// ---------- Админка ---------- + +type AdminBanner struct { + ID int64 `json:"id"` + Position string `json:"position"` + Layout string `json:"layout"` + Theme string `json:"theme"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Badge string `json:"badge"` + Chips []string `json:"chips"` + ImageID *int64 `json:"image_id"` + Image string `json:"image,omitempty"` + MobileImageID *int64 `json:"mobile_image_id"` + MobileImage string `json:"mobile_image,omitempty"` + LinkURL string `json:"link_url"` + ButtonText string `json:"button_text"` + Disclaimer string `json:"disclaimer"` + StartsAt *time.Time `json:"starts_at"` + EndsAt *time.Time `json:"ends_at"` + Sort int `json:"sort"` + Published bool `json:"published"` +} + +func (s *Storage) AdminBanners(ctx context.Context, position string) ([]AdminBanner, error) { + rows, err := s.pool.Query(ctx, ` + SELECT b.id, b.position, b.layout, b.theme, b.title, b.subtitle, b.badge, b.chips, + b.image_id, COALESCE(mi.path, ''), b.mobile_image_id, COALESCE(mm.path, ''), + b.link_url, b.button_text, b.disclaimer, + b.starts_at, b.ends_at, b.sort, b.published + FROM banners b + LEFT JOIN media mi ON mi.id = b.image_id + LEFT JOIN media mm ON mm.id = b.mobile_image_id + WHERE ($1 = '' OR b.position = $1) + ORDER BY b.position, b.sort, b.id`, position) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []AdminBanner{} + for rows.Next() { + var a AdminBanner + if err := rows.Scan(&a.ID, &a.Position, &a.Layout, &a.Theme, &a.Title, &a.Subtitle, &a.Badge, &a.Chips, + &a.ImageID, &a.Image, &a.MobileImageID, &a.MobileImage, + &a.LinkURL, &a.ButtonText, &a.Disclaimer, + &a.StartsAt, &a.EndsAt, &a.Sort, &a.Published); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +type BannerInput struct { + Position string `json:"position"` + Layout string `json:"layout"` + Theme string `json:"theme"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Badge string `json:"badge"` + Chips []string `json:"chips"` + ImageID *int64 `json:"image_id"` + MobileImageID *int64 `json:"mobile_image_id"` + LinkURL string `json:"link_url"` + ButtonText string `json:"button_text"` + Disclaimer string `json:"disclaimer"` + StartsAt *time.Time `json:"starts_at"` + EndsAt *time.Time `json:"ends_at"` + Sort int `json:"sort"` + Published bool `json:"published"` +} + +func (s *Storage) AdminCreateBanner(ctx context.Context, in BannerInput) (int64, error) { + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO banners (position, layout, theme, title, subtitle, badge, chips, image_id, mobile_image_id, + link_url, button_text, disclaimer, starts_at, ends_at, sort, published) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) RETURNING id`, + in.Position, in.Layout, in.Theme, in.Title, in.Subtitle, in.Badge, in.Chips, in.ImageID, in.MobileImageID, + in.LinkURL, in.ButtonText, in.Disclaimer, in.StartsAt, in.EndsAt, in.Sort, in.Published).Scan(&id) + return id, err +} + +func (s *Storage) AdminUpdateBanner(ctx context.Context, id int64, in BannerInput) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE banners SET position=$2, layout=$3, theme=$4, title=$5, subtitle=$6, badge=$7, chips=$8, + image_id=$9, mobile_image_id=$10, link_url=$11, button_text=$12, disclaimer=$13, + starts_at=$14, ends_at=$15, sort=$16, published=$17, updated_at=now() + WHERE id=$1`, + id, in.Position, in.Layout, in.Theme, in.Title, in.Subtitle, in.Badge, in.Chips, + in.ImageID, in.MobileImageID, in.LinkURL, in.ButtonText, in.Disclaimer, + in.StartsAt, in.EndsAt, in.Sort, in.Published) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *Storage) AdminDeleteBanner(ctx context.Context, id int64) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM banners WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/storage/booking_confirm.go b/internal/storage/booking_confirm.go new file mode 100644 index 0000000..b416973 --- /dev/null +++ b/internal/storage/booking_confirm.go @@ -0,0 +1,179 @@ +package storage + +import ( + "context" + "encoding/json" + "time" +) + +// BookingSettings — правила онлайн-записи, которые меняет администратор (ключ "booking" в settings). +type BookingSettings struct { + // PhoneCheck — требовать проверочный звонок от пациента перед записью. По умолчанию выключен: + // запись подтверждает оператор в Битрикс24. + PhoneCheck bool `json:"phone_check"` + // AutoCancelHours — через сколько часов после записи отменить её, если оператор не подтвердил. + AutoCancelHours int `json:"autocancel_hours"` + // CancelBeforeHours — не позже чем за сколько часов до приёма отменить неподтверждённую запись. + CancelBeforeHours int `json:"cancel_before_hours"` + + // Защита расписания от ботов (0 — правило выключено). Сверх лимита сайт просит проверочный + // звонок: настоящий пациент записывается дальше, бот останавливается. Без SMSC — заявка оператору. + MaxPerDoctorDay int `json:"max_per_doctor_day"` // неподтверждённых записей к врачу на один день + MaxPerHour int `json:"max_per_hour"` // онлайн-записей с сайта за последний час + MaxActivePerPhone int `json:"max_active_per_phone"` // будущих записей на один номер +} + +// DefaultBookingSettings — то, что действует, пока администратор ничего не менял. +// Поля, которых нет в сохранённых настройках, тоже берутся отсюда. +var DefaultBookingSettings = BookingSettings{ + AutoCancelHours: 24, CancelBeforeHours: 2, + MaxPerDoctorDay: 3, MaxPerHour: 10, MaxActivePerPhone: 3, +} + +func (s *Storage) BookingSettings(ctx context.Context) (BookingSettings, error) { + out := DefaultBookingSettings + var raw []byte + err := s.pool.QueryRow(ctx, `SELECT value FROM settings WHERE key = 'booking'`).Scan(&raw) + if wrapNotFound(err) == ErrNotFound { + return out, nil + } + if err != nil { + return out, err + } + if err := json.Unmarshal(raw, &out); err != nil { + return DefaultBookingSettings, err + } + return out, nil +} + +func (s *Storage) SetBookingSettings(ctx context.Context, b BookingSettings) error { + return s.AdminSetSettings(ctx, map[string]any{"booking": b}) +} + +// ConfirmItem — запись с сайта, ждущая решения оператора, со всем, что нужно для лида в Битриксе. +type ConfirmItem struct { + ID int64 + Start time.Time + CreatedAt time.Time + Patient string + Phone string + DoctorOnec string + DoctorName string + Branch string + ServiceName string + OnecID string + BitrixLeadID string + Error string // почему запись не прошла (для заявок без окна в 1С) +} + +const confirmItemSelect = ` + SELECT a.id, a.start_at, a.created_at, a.patient_name, a.patient_phone, a.doctor_onec_id, + COALESCE(NULLIF(o.full_name, ''), a.doctor_onec_id), COALESCE(NULLIF(b.address, ''), b.name, ''), + COALESCE(sv.name, ''), a.onec_appointment_id, a.bitrix_lead_id, a.error + FROM onec_appointments a + LEFT JOIN onec_doctors o ON o.onec_id = a.doctor_onec_id + LEFT JOIN onec_branches b ON b.onec_id = a.branch_onec_id + LEFT JOIN onec_services sv ON sv.doctor_onec_id = a.doctor_onec_id AND sv.onec_id = a.service_onec_id` + +// ConfirmQueue — записи, которые занимают окно в 1С и ещё ждут подтверждения. +func (s *Storage) ConfirmQueue(ctx context.Context, limit int) ([]ConfirmItem, error) { + return s.confirmItems(ctx, confirmItemSelect+` + WHERE a.confirm_state = 'waiting' AND a.status = 'booked' + ORDER BY a.id LIMIT $1`, limit) +} + +// LeadOnlyQueue — заявки, которые не удалось записать в 1С: их надо передать оператору лидом, +// чтобы он записал пациента вручную. confirm_state: lead_only — ждёт отправки, lead_sent — лид создан. +func (s *Storage) LeadOnlyQueue(ctx context.Context, limit int) ([]ConfirmItem, error) { + return s.confirmItems(ctx, confirmItemSelect+` + WHERE a.confirm_state = 'lead_only' AND a.status = 'failed' + ORDER BY a.id LIMIT $1`, limit) +} + +// MarkLeadOnly ставит несостоявшуюся запись в очередь на отправку в Битрикс24. +func (s *Storage) MarkLeadOnly(ctx context.Context, id int64) error { + _, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET confirm_state = 'lead_only', updated_at = now() + WHERE id = $1 AND status = 'failed' AND confirm_state = ''`, id) + return err +} + +// LeadOnlySent — лид для несостоявшейся записи создан, дальше заявку ведёт оператор. +func (s *Storage) LeadOnlySent(ctx context.Context, id int64, leadID string) error { + _, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET bitrix_lead_id = $2, confirm_state = 'lead_sent', + confirm_error = '', updated_at = now() WHERE id = $1 AND confirm_state = 'lead_only'`, id, leadID) + return err +} + +func (s *Storage) confirmItems(ctx context.Context, query string, limit int) ([]ConfirmItem, error) { + rows, err := s.pool.Query(ctx, query, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []ConfirmItem{} + for rows.Next() { + var c ConfirmItem + if err := rows.Scan(&c.ID, &c.Start, &c.CreatedAt, &c.Patient, &c.Phone, &c.DoctorOnec, &c.DoctorName, + &c.Branch, &c.ServiceName, &c.OnecID, &c.BitrixLeadID, &c.Error); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// StartBookingConfirm ставит успешную запись в очередь на подтверждение оператором. +func (s *Storage) StartBookingConfirm(ctx context.Context, id int64) error { + _, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET confirm_state = 'waiting', updated_at = now() + WHERE id = $1 AND status = 'booked' AND confirm_state = ''`, id) + return err +} + +func (s *Storage) SetBitrixLead(ctx context.Context, id int64, leadID string) error { + _, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET bitrix_lead_id = $2, confirm_error = '', updated_at = now() + WHERE id = $1`, id, leadID) + return err +} + +// SetConfirmError запоминает последнюю неудачу (Битрикс или 1С недоступны): попытка повторится. +func (s *Storage) SetConfirmError(ctx context.Context, id int64, text string) error { + _, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET confirm_error = $2, updated_at = now() WHERE id = $1`, id, text) + return err +} + +// FinishBookingConfirm закрывает ожидание. Для отмен (cancelled, expired) запись помечается отменённой. +// Условие confirm_state = 'waiting' защищает от гонки с вебхуком 1С и повторной обработки. +func (s *Storage) FinishBookingConfirm(ctx context.Context, id int64, state string) error { + _, err := s.pool.Exec(ctx, ` + UPDATE onec_appointments SET confirm_state = $2::text, confirm_error = '', + confirmed_at = CASE WHEN $2::text = 'confirmed' THEN now() ELSE confirmed_at END, + status = CASE WHEN $2::text IN ('cancelled', 'expired') THEN 'cancelled' ELSE status END, + updated_at = now() + WHERE id = $1 AND confirm_state = 'waiting'`, id, state) + return err +} + +// WaitingForDoctor — неподтверждённые записи к врачу с приёмом в [from, to). +func (s *Storage) WaitingForDoctor(ctx context.Context, doctorOnecID string, from, to time.Time) (int, error) { + var n int + err := s.pool.QueryRow(ctx, `SELECT count(*) FROM onec_appointments + WHERE doctor_onec_id = $1 AND confirm_state = 'waiting' AND status = 'booked' + AND start_at >= $2 AND start_at < $3`, doctorOnecID, from, to).Scan(&n) + return n, err +} + +// SiteBookingsSince — попытки онлайн-записи с сайта (любой исход) начиная с since: всплеск — признак бота. +func (s *Storage) SiteBookingsSince(ctx context.Context, since time.Time) (int, error) { + var n int + err := s.pool.QueryRow(ctx, `SELECT count(*) FROM onec_appointments WHERE created_at >= $1`, since).Scan(&n) + return n, err +} + +// ActiveBookingsForPhone — будущие записи с сайта на номер (сравнение по последним 10 цифрам). +func (s *Storage) ActiveBookingsForPhone(ctx context.Context, phone10 string) (int, error) { + var n int + err := s.pool.QueryRow(ctx, `SELECT count(*) FROM onec_appointments + WHERE status = 'booked' AND start_at > now() + AND right(regexp_replace(patient_phone, '[^0-9]', '', 'g'), 10) = $1`, phone10).Scan(&n) + return n, err +} diff --git a/internal/storage/booking_confirm_test.go b/internal/storage/booking_confirm_test.go new file mode 100644 index 0000000..79e3579 --- /dev/null +++ b/internal/storage/booking_confirm_test.go @@ -0,0 +1,22 @@ +package storage + +import ( + "encoding/json" + "testing" +) + +// Настройки, сохранённые до появления лимитов, получают значения лимитов по умолчанию. +func TestOldBookingSettingsKeepDefaultLimits(t *testing.T) { + out := DefaultBookingSettings + if err := json.Unmarshal([]byte(`{"phone_check":false,"autocancel_hours":12,"cancel_before_hours":2}`), &out); err != nil { + t.Fatal(err) + } + if out.AutoCancelHours != 12 || out.MaxPerDoctorDay != 3 || out.MaxPerHour != 10 || out.MaxActivePerPhone != 3 { + t.Errorf("слияние с умолчаниями: %+v", out) + } + out = DefaultBookingSettings + _ = json.Unmarshal([]byte(`{"max_per_doctor_day":0}`), &out) + if out.MaxPerDoctorDay != 0 { + t.Error("явный 0 должен выключать правило") + } +} diff --git a/internal/storage/conflict.go b/internal/storage/conflict.go new file mode 100644 index 0000000..28f70a8 --- /dev/null +++ b/internal/storage/conflict.go @@ -0,0 +1,27 @@ +package storage + +import ( + "errors" + "fmt" + + "github.com/jackc/pgx/v5/pgconn" +) + +// ErrConflict — нарушена уникальность: например, адрес (slug) уже занят другой записью. +var ErrConflict = errors.New("storage: conflict") + +// ErrBadReference — ссылка на несуществующую запись: удалённую услугу, файл и т. п. +var ErrBadReference = errors.New("storage: bad reference") + +func wrapConflict(err error) error { + var pg *pgconn.PgError + if errors.As(err, &pg) { + switch pg.Code { + case "23505": + return fmt.Errorf("%w: %s", ErrConflict, pg.ConstraintName) + case "23503": + return fmt.Errorf("%w: %s", ErrBadReference, pg.ConstraintName) + } + } + return err +} diff --git a/internal/storage/content.go b/internal/storage/content.go new file mode 100644 index 0000000..05d3dd2 --- /dev/null +++ b/internal/storage/content.go @@ -0,0 +1,367 @@ +package storage + +import ( + "context" + + "dana/internal/domain" +) + +// ---------- Публикации: новости, статьи, акции ---------- + +// Posts возвращает публикации заданного вида. Для акций отсеиваются просроченные. +func (s *Storage) Posts(ctx context.Context, kind string, limit int) ([]domain.Post, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + rows, err := s.pool.Query(ctx, ` + SELECT p.id, p.kind, p.slug, p.title, p.lead, COALESCE(m.path, ''), + p.starts_at, p.ends_at, p.published_at, + p.seo_title, p.seo_description, p.seo_h1, + p.discount_percent, p.terms, COALESCE(c.slug, '') + FROM posts p + LEFT JOIN media m ON m.id = p.image_id + LEFT JOIN checkups c ON c.id = p.checkup_id AND c.published AND c.deleted_at IS NULL + WHERE p.published AND p.deleted_at IS NULL + AND ($1 = '' OR p.kind = $1) + AND (p.kind <> 'promo' OR p.ends_at IS NULL OR p.ends_at >= current_date) + ORDER BY p.published_at DESC + LIMIT $2`, kind, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []domain.Post + for rows.Next() { + var p domain.Post + if err := rows.Scan(&p.ID, &p.Kind, &p.Slug, &p.Title, &p.Lead, &p.Image, + &p.StartsAt, &p.EndsAt, &p.PublishedAt, + &p.SEO.Title, &p.SEO.Description, &p.SEO.H1, + &p.DiscountPercent, &p.Terms, &p.CheckupSlug); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (s *Storage) PostBySlug(ctx context.Context, kind, slug string) (*domain.Post, error) { + var p domain.Post + err := s.pool.QueryRow(ctx, ` + SELECT p.id, p.kind, p.slug, p.title, p.lead, p.blocks, COALESCE(m.path, ''), + p.starts_at, p.ends_at, p.published_at, + p.seo_title, p.seo_description, p.seo_h1, + p.discount_percent, p.terms, COALESCE(c.slug, '') + FROM posts p + LEFT JOIN media m ON m.id = p.image_id + LEFT JOIN checkups c ON c.id = p.checkup_id AND c.published AND c.deleted_at IS NULL + WHERE p.kind = $1 AND p.slug = $2 AND p.published AND p.deleted_at IS NULL`, + kind, slug).Scan(&p.ID, &p.Kind, &p.Slug, &p.Title, &p.Lead, &p.Blocks, &p.Image, + &p.StartsAt, &p.EndsAt, &p.PublishedAt, + &p.SEO.Title, &p.SEO.Description, &p.SEO.H1, + &p.DiscountPercent, &p.Terms, &p.CheckupSlug) + if err != nil { + return nil, wrapNotFound(err) + } + return &p, nil +} + +// ---------- Отзывы ---------- + +// Reviews отдаёт только опубликованные (прошедшие модерацию) отзывы. +func (s *Storage) Reviews(ctx context.Context, doctorSlug string, limit int) ([]domain.Review, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.pool.Query(ctx, ` + SELECT r.id, r.author_name, COALESCE(m.path, ''), r.rating, r.text, r.video_url, + COALESCE(d.slug, ''), COALESCE(d.last_name || ' ' || d.first_name, ''), + r.source, r.reply, r.created_at + FROM reviews r + LEFT JOIN media m ON m.id = r.author_photo_id + LEFT JOIN doctors d ON d.id = r.doctor_id + WHERE r.status = 'published' + AND ($1 = '' OR d.slug = $1) + ORDER BY r.created_at DESC, r.id DESC + LIMIT $2`, doctorSlug, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []domain.Review + for rows.Next() { + var r domain.Review + if err := rows.Scan(&r.ID, &r.AuthorName, &r.AuthorPhoto, &r.Rating, &r.Text, &r.VideoURL, + &r.DoctorSlug, &r.DoctorName, &r.Source, &r.Reply, &r.CreatedAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// ---------- Вопрос-ответ ---------- + +func (s *Storage) FAQ(ctx context.Context, servicePath string) ([]domain.FAQItem, error) { + rows, err := s.pool.Query(ctx, ` + SELECT f.id, f.question, f.answer, f.service_id + FROM faq f + LEFT JOIN services s ON s.id = f.service_id + WHERE f.published + AND ($1 = '' OR s.path = $1) + ORDER BY f.sort, f.id`, servicePath) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []domain.FAQItem + for rows.Next() { + var f domain.FAQItem + if err := rows.Scan(&f.ID, &f.Question, &f.Answer, &f.ServiceID); err != nil { + return nil, err + } + out = append(out, f) + } + return out, rows.Err() +} + +// ---------- Документы: лицензии, сертификаты, надзорные органы ---------- + +func (s *Storage) Documents(ctx context.Context, kind string) ([]domain.Document, error) { + rows, err := s.pool.Query(ctx, ` + SELECT d.id, d.kind, d.title, d.body, + COALESCE(f.path, ''), COALESCE(p.path, ''), d.issued_at + FROM documents d + LEFT JOIN media f ON f.id = d.file_id + LEFT JOIN media p ON p.id = d.preview_id + WHERE d.published AND ($1 = '' OR d.kind = $1) + ORDER BY d.sort, d.id`, kind) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []domain.Document + for rows.Next() { + var d domain.Document + if err := rows.Scan(&d.ID, &d.Kind, &d.Title, &d.Body, + &d.File, &d.Preview, &d.IssuedAt); err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + +// ---------- Партнёры ---------- + +func (s *Storage) Partners(ctx context.Context) ([]domain.Partner, error) { + rows, err := s.pool.Query(ctx, ` + SELECT p.id, p.name, p.url, COALESCE(m.path, '') + FROM partners p + LEFT JOIN media m ON m.id = p.logo_id + WHERE p.published + ORDER BY p.sort, p.name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []domain.Partner + for rows.Next() { + var p domain.Partner + if err := rows.Scan(&p.ID, &p.Name, &p.URL, &p.Logo); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// ---------- Вакансии ---------- + +func (s *Storage) Vacancies(ctx context.Context) ([]domain.Vacancy, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, slug, title, description, duties, requirements, conditions, salary, branch_id + FROM vacancies + WHERE published + ORDER BY sort, title`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []domain.Vacancy + for rows.Next() { + var v domain.Vacancy + if err := rows.Scan(&v.ID, &v.Slug, &v.Title, &v.Description, &v.Duties, + &v.Requirements, &v.Conditions, &v.Salary, &v.BranchID); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +// ---------- Филиалы ---------- + +func (s *Storage) Branches(ctx context.Context) ([]domain.Branch, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, slug, name, address, phones, work_hours, lat, lon + FROM branches + WHERE published + ORDER BY sort, name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []domain.Branch + for rows.Next() { + var b domain.Branch + if err := rows.Scan(&b.ID, &b.Slug, &b.Name, &b.Address, + &b.Phones, &b.WorkHours, &b.Lat, &b.Lon); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +// ---------- Чек-апы ---------- + +func (s *Storage) Checkups(ctx context.Context, audience string) ([]domain.Checkup, error) { + rows, err := s.pool.Query(ctx, ` + SELECT c.id, c.slug, c.audience, c.name, c.lead, c.price, c.old_price, + COALESCE(m.path, ''), c.featured, + c.seo_title, c.seo_description, c.seo_h1 + FROM checkups c + LEFT JOIN media m ON m.id = c.image_id + WHERE c.published AND c.deleted_at IS NULL + AND ($1 = '' OR c.audience = $1) + ORDER BY c.sort, c.name`, audience) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []domain.Checkup + for rows.Next() { + var c domain.Checkup + if err := rows.Scan(&c.ID, &c.Slug, &c.Audience, &c.Name, &c.Lead, + &c.Price, &c.OldPrice, &c.Image, &c.Featured, + &c.SEO.Title, &c.SEO.Description, &c.SEO.H1); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +func (s *Storage) CheckupBySlug(ctx context.Context, slug string) (*domain.Checkup, error) { + var c domain.Checkup + err := s.pool.QueryRow(ctx, ` + SELECT c.id, c.slug, c.audience, c.name, c.lead, c.blocks, c.price, c.old_price, + COALESCE(m.path, ''), c.featured, + c.seo_title, c.seo_description, c.seo_h1 + FROM checkups c + LEFT JOIN media m ON m.id = c.image_id + WHERE c.slug = $1 AND c.published AND c.deleted_at IS NULL`, slug). + Scan(&c.ID, &c.Slug, &c.Audience, &c.Name, &c.Lead, &c.Blocks, + &c.Price, &c.OldPrice, &c.Image, &c.Featured, + &c.SEO.Title, &c.SEO.Description, &c.SEO.H1) + if err != nil { + return nil, wrapNotFound(err) + } + + itemRows, err := s.pool.Query(ctx, ` + SELECT name FROM checkup_items WHERE checkup_id = $1 ORDER BY sort, id`, c.ID) + if err != nil { + return nil, err + } + defer itemRows.Close() + for itemRows.Next() { + var name string + if err := itemRows.Scan(&name); err != nil { + return nil, err + } + c.Items = append(c.Items, name) + } + return &c, itemRows.Err() +} + +// ---------- Произвольные страницы ---------- + +func (s *Storage) PageBySlug(ctx context.Context, slug string) (*domain.Page, error) { + var p domain.Page + err := s.pool.QueryRow(ctx, ` + SELECT id, slug, title, blocks, seo_title, seo_description, seo_h1 + FROM pages WHERE slug = $1 AND published`, slug). + Scan(&p.ID, &p.Slug, &p.Title, &p.Blocks, + &p.SEO.Title, &p.SEO.Description, &p.SEO.H1) + if err != nil { + return nil, wrapNotFound(err) + } + return &p, nil +} + +// ---------- Галерея ---------- + +func (s *Storage) Gallery(ctx context.Context) ([]domain.GalleryAlbum, error) { + rows, err := s.pool.Query(ctx, ` + SELECT a.id, a.slug, a.title, + COALESCE(p.id, 0), COALESCE(m.path, ''), COALESCE(p.caption, '') + FROM gallery_albums a + LEFT JOIN gallery_photos p ON p.album_id = a.id + LEFT JOIN media m ON m.id = p.media_id + WHERE a.published + ORDER BY a.sort, a.title, p.sort, p.id`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []domain.GalleryAlbum + byID := map[int64]int{} // album id -> индекс в out + for rows.Next() { + var a domain.GalleryAlbum + var ph domain.GalleryPhoto + if err := rows.Scan(&a.ID, &a.Slug, &a.Title, &ph.ID, &ph.Path, &ph.Caption); err != nil { + return nil, err + } + idx, seen := byID[a.ID] + if !seen { + out = append(out, a) + idx = len(out) - 1 + byID[a.ID] = idx + } + if ph.ID != 0 { + out[idx].Photos = append(out[idx].Photos, ph) + } + } + return out, rows.Err() +} + +// ---------- Настройки сайта ---------- + +// Settings отдаёт публичные настройки как плоскую карту key -> value. +func (s *Storage) Settings(ctx context.Context) (map[string]any, error) { + rows, err := s.pool.Query(ctx, `SELECT key, value FROM settings ORDER BY key`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := map[string]any{} + for rows.Next() { + var k string + var v any + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + out[k] = v + } + return out, rows.Err() +} diff --git a/internal/storage/dashboard.go b/internal/storage/dashboard.go new file mode 100644 index 0000000..81ee907 --- /dev/null +++ b/internal/storage/dashboard.go @@ -0,0 +1,142 @@ +package storage + +import ( + "context" + "time" +) + +// Dashboard — сводка для главного экрана админки. Только счётчики и даты, без персональных данных: +// заявки с именами и телефонами главная страница берёт отдельным запросом (он пишется в журнал). +type Dashboard struct { + Doctors int `json:"doctors"` + Services int `json:"services"` + Branches int `json:"branches"` + NewLeads int `json:"new_leads"` + LeadsToday int `json:"leads_today"` + PendingReviews int `json:"pending_reviews"` + ActivePromos int `json:"active_promos"` + Days []DashboardDay `json:"days"` + Upcoming []DashboardDate `json:"upcoming"` + Todo []DashboardTodo `json:"todo"` +} + +// DashboardDay — заявки за день: все и только записи на приём. +type DashboardDay struct { + Date string `json:"date"` + Leads int `json:"leads"` + Appointments int `json:"appointments"` +} + +// DashboardDate — что скоро начнётся или закончится: акции и слайды на главной. +type DashboardDate struct { + Kind string `json:"kind"` // promo | banner + Title string `json:"title"` + Event string `json:"event"` // starts | ends + At time.Time `json:"at"` +} + +// DashboardTodo — незакрытые дела для колокольчика в шапке. +type DashboardTodo struct { + Key string `json:"key"` + Count int `json:"count"` +} + +func (s *Storage) Dashboard(ctx context.Context, days int) (*Dashboard, error) { + var d Dashboard + err := s.pool.QueryRow(ctx, ` + SELECT + (SELECT count(*) FROM doctors WHERE published AND deleted_at IS NULL), + (SELECT count(*) FROM services WHERE published AND deleted_at IS NULL AND parent_id IS NOT NULL), + (SELECT count(*) FROM branches WHERE published), + (SELECT count(*) FROM leads WHERE status = 'new'), + (SELECT count(*) FROM leads WHERE created_at >= date_trunc('day', now() AT TIME ZONE 'Europe/Moscow') AT TIME ZONE 'Europe/Moscow'), + (SELECT count(*) FROM reviews WHERE status = 'pending'), + (SELECT count(*) FROM posts WHERE kind = 'promo' AND published AND deleted_at IS NULL + AND (starts_at IS NULL OR starts_at <= current_date) AND (ends_at IS NULL OR ends_at >= current_date))`). + Scan(&d.Doctors, &d.Services, &d.Branches, &d.NewLeads, &d.LeadsToday, &d.PendingReviews, &d.ActivePromos) + if err != nil { + return nil, err + } + + rows, err := s.pool.Query(ctx, ` + WITH days AS ( + SELECT generate_series((now() AT TIME ZONE 'Europe/Moscow')::date - ($1::int - 1), (now() AT TIME ZONE 'Europe/Moscow')::date, '1 day')::date AS day + ) + SELECT to_char(d.day, 'YYYY-MM-DD'), + count(l.id), + count(l.id) FILTER (WHERE f.code = 'appointment') + FROM days d + LEFT JOIN leads l ON (l.created_at AT TIME ZONE 'Europe/Moscow')::date = d.day + LEFT JOIN forms f ON f.id = l.form_id + GROUP BY d.day ORDER BY d.day`, days) + if err != nil { + return nil, err + } + for rows.Next() { + var day DashboardDay + if err := rows.Scan(&day.Date, &day.Leads, &day.Appointments); err != nil { + rows.Close() + return nil, err + } + d.Days = append(d.Days, day) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + rows, err = s.pool.Query(ctx, ` + SELECT kind, title, event, at FROM ( + SELECT 'promo' AS kind, title, 'starts' AS event, starts_at::timestamptz AS at FROM posts + WHERE kind = 'promo' AND published AND deleted_at IS NULL AND starts_at > current_date + UNION ALL + SELECT 'promo', title, 'ends', ends_at::timestamptz FROM posts + WHERE kind = 'promo' AND published AND deleted_at IS NULL AND ends_at >= current_date + UNION ALL + SELECT 'banner', COALESCE(NULLIF(title, ''), 'Слайд без заголовка'), 'starts', starts_at FROM banners + WHERE published AND starts_at > now() + UNION ALL + SELECT 'banner', COALESCE(NULLIF(title, ''), 'Слайд без заголовка'), 'ends', ends_at FROM banners + WHERE published AND ends_at > now() + ) x + WHERE at <= now() + interval '60 days' + ORDER BY at LIMIT 12`) + if err != nil { + return nil, err + } + for rows.Next() { + var u DashboardDate + if err := rows.Scan(&u.Kind, &u.Title, &u.Event, &u.At); err != nil { + rows.Close() + return nil, err + } + d.Upcoming = append(d.Upcoming, u) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + var noServices, noBranches, noPhoto, hiddenPages int + if err := s.pool.QueryRow(ctx, ` + SELECT + (SELECT count(*) FROM doctors d WHERE d.published AND d.deleted_at IS NULL + AND NOT EXISTS (SELECT 1 FROM doctor_services s WHERE s.doctor_id = d.id)), + (SELECT count(*) FROM doctors d WHERE d.published AND d.deleted_at IS NULL + AND NOT EXISTS (SELECT 1 FROM doctor_branches b WHERE b.doctor_id = d.id)), + (SELECT count(*) FROM doctors WHERE published AND deleted_at IS NULL AND photo_id IS NULL), + (SELECT count(*) FROM pages WHERE slug = 'patients' AND NOT published)`). + Scan(&noServices, &noBranches, &noPhoto, &hiddenPages); err != nil { + return nil, err + } + for _, t := range []DashboardTodo{ + {"new_leads", d.NewLeads}, {"pending_reviews", d.PendingReviews}, + {"doctors_without_services", noServices}, {"doctors_without_branches", noBranches}, + {"doctors_without_photo", noPhoto}, {"patients_page_hidden", hiddenPages}, + } { + if t.Count > 0 { + d.Todo = append(d.Todo, t) + } + } + return &d, nil +} diff --git a/internal/storage/doctor_quality.go b/internal/storage/doctor_quality.go new file mode 100644 index 0000000..af69e2f --- /dev/null +++ b/internal/storage/doctor_quality.go @@ -0,0 +1,79 @@ +package storage + +import "context" + +// DoctorQuality — то, чего нет в списке врачей админки: записи биографии по видам, +// отзывы и заявки по каждому врачу. Полноту карточки считает страница «Дашборд врачей». +type DoctorQuality struct { + ID int64 `json:"id"` + Education int `json:"education"` + Experience int `json:"experience"` + Training int `json:"training"` + Awards int `json:"awards"` + Conferences int `json:"conferences"` + ReviewsPending int `json:"reviews_pending"` + ReviewsLive int `json:"reviews_published"` + RatingSum int `json:"rating_sum"` // сумма оценок всех отзывов с оценкой + RatingCount int `json:"rating_count"` // сколько отзывов с оценкой + Leads int `json:"leads"` // заявки к врачу или со страницы врача, без спама + KidsFrom bool `json:"kids_from"` + Languages bool `json:"languages"` + PrimarySpecialty *string `json:"primary_specialty"` +} + +type DoctorQualityReport struct { + Doctors []DoctorQuality `json:"doctors"` + ReviewsPendingTotal int `json:"reviews_pending_total"` + ReviewsPendingNoDoc int `json:"reviews_pending_clinic"` // отзывы о клинике в целом + ReviewsLiveTotal int `json:"reviews_published_total"` + LeadsTotal int `json:"leads_total"` +} + +func (s *Storage) DoctorQuality(ctx context.Context) (*DoctorQualityReport, error) { + rep := &DoctorQualityReport{Doctors: []DoctorQuality{}} + rows, err := s.pool.Query(ctx, ` + SELECT d.id, + count(*) FILTER (WHERE e.kind = 'education'), + count(*) FILTER (WHERE e.kind = 'experience'), + count(*) FILTER (WHERE e.kind = 'training'), + count(*) FILTER (WHERE e.kind = 'award'), + count(*) FILTER (WHERE e.kind = 'conference'), + (SELECT count(*) FROM reviews r WHERE r.doctor_id = d.id AND r.status = 'pending'), + (SELECT count(*) FROM reviews r WHERE r.doctor_id = d.id AND r.status = 'published'), + (SELECT COALESCE(sum(r.rating), 0) FROM reviews r WHERE r.doctor_id = d.id AND r.rating IS NOT NULL AND r.status <> 'rejected'), + (SELECT count(r.rating) FROM reviews r WHERE r.doctor_id = d.id AND r.status <> 'rejected'), + (SELECT count(*) FROM leads l WHERE l.status <> 'spam' AND (l.doctor_id = d.id OR + (l.doctor_id IS NULL AND l.page_url LIKE '%/company/staff/%/' || d.slug || '/%'))), + d.accepts_kids_from IS NOT NULL, + cardinality(d.languages) > 0, + (SELECT sp.name_plural FROM doctor_specialties ds JOIN specialties sp ON sp.id = ds.specialty_id + WHERE ds.doctor_id = d.id ORDER BY ds.is_primary DESC, sp.sort, sp.id LIMIT 1) + FROM doctors d + LEFT JOIN doctor_bio_entries e ON e.doctor_id = d.id + WHERE d.deleted_at IS NULL + GROUP BY d.id + ORDER BY d.id`) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var q DoctorQuality + if err := rows.Scan(&q.ID, &q.Education, &q.Experience, &q.Training, &q.Awards, &q.Conferences, + &q.ReviewsPending, &q.ReviewsLive, &q.RatingSum, &q.RatingCount, &q.Leads, + &q.KidsFrom, &q.Languages, &q.PrimarySpecialty); err != nil { + return nil, err + } + rep.Doctors = append(rep.Doctors, q) + } + if err := rows.Err(); err != nil { + return nil, err + } + err = s.pool.QueryRow(ctx, ` + SELECT count(*) FILTER (WHERE status = 'pending'), + count(*) FILTER (WHERE status = 'pending' AND doctor_id IS NULL), + count(*) FILTER (WHERE status = 'published'), + (SELECT count(*) FROM leads WHERE status <> 'spam') + FROM reviews`).Scan(&rep.ReviewsPendingTotal, &rep.ReviewsPendingNoDoc, &rep.ReviewsLiveTotal, &rep.LeadsTotal) + return rep, err +} diff --git a/internal/storage/doctors.go b/internal/storage/doctors.go new file mode 100644 index 0000000..8f05630 --- /dev/null +++ b/internal/storage/doctors.go @@ -0,0 +1,322 @@ +package storage + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + "dana/internal/domain" +) + +// Число отзывов считается по опубликованным отзывам. Рейтинг из админки имеет +// приоритет, а при пустом значении используется средняя оценка отзывов. +const doctorColumns = `d.id, d.slug, d.last_name, d.first_name, d.middle_name, d.position, + d.category, d.degree, d.practice_start_year, COALESCE(m.path, ''), d.quote, + d.languages, d.accepts_kids_from, d.booking_provider, d.medflex_user, d.absent, + d.seo_title, d.seo_description, d.seo_h1, + COALESCE(d.rating_override::float8, + (SELECT round(avg(r.rating)::numeric, 1)::float8 FROM reviews r + WHERE r.doctor_id = d.id AND r.status = 'published' AND r.rating IS NOT NULL)), + (SELECT count(*) FROM reviews r + WHERE r.doctor_id = d.id AND r.status = 'published')` + +const medflexBase = "https://booking.medflex.ru/?user=" + +func scanDoctor(row pgx.Row) (domain.Doctor, error) { + var d domain.Doctor + var startYear *int + var medflexUser string + err := row.Scan(&d.ID, &d.Slug, &d.LastName, &d.FirstName, &d.MiddleName, &d.Position, + &d.Category, &d.Degree, &startYear, &d.Photo, &d.Quote, + &d.Languages, &d.KidsFrom, &d.Booking.Provider, &medflexUser, &d.Absent, + &d.SEO.Title, &d.SEO.Description, &d.SEO.H1, + &d.Rating, &d.ReviewCount) + if err != nil { + return d, err + } + d.FullName = strings.TrimSpace(strings.Join([]string{d.LastName, d.FirstName, d.MiddleName}, " ")) + if startYear != nil && *startYear > 0 { + // Стаж считается, а не хранится текстом — иначе он устаревает. + years := time.Now().Year() - *startYear + if years >= 0 { + d.Experience = &years + } + } + if d.Booking.Provider == "medflex" && medflexUser != "" { + // При переносе со старого сайта в поле попала ссылка целиком; + // через админку можно ввести и только идентификатор. + if strings.HasPrefix(medflexUser, "http://") || strings.HasPrefix(medflexUser, "https://") { + d.Booking.URL = medflexUser + } else { + d.Booking.URL = medflexBase + medflexUser + } + } + return d, nil +} + +func (s *Storage) collectDoctors(ctx context.Context, sql string, args ...any) ([]domain.Doctor, error) { + rows, err := s.pool.Query(ctx, sql, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []domain.Doctor + for rows.Next() { + d, err := scanDoctor(rows) + if err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + +// DoctorFilter — необязательные фильтры списка врачей. Пустое поле не фильтрует. +type DoctorFilter struct { + Specialty string // slug специальности + Service string // path услуги + Branch string // slug филиала + Query string // поиск по ФИО и должности +} + +func (s *Storage) Doctors(ctx context.Context, f DoctorFilter) ([]domain.Doctor, error) { + sql := ` + SELECT ` + doctorColumns + ` + FROM doctors d + LEFT JOIN media m ON m.id = d.photo_id + WHERE d.published AND d.deleted_at IS NULL` + var args []any + arg := func(v any) string { + args = append(args, v) + return "$" + strconv.Itoa(len(args)) + } + + if f.Specialty != "" { + sql += ` + AND EXISTS ( + SELECT 1 FROM doctor_specialties ds + JOIN specialties sp ON sp.id = ds.specialty_id + WHERE ds.doctor_id = d.id AND sp.slug = ` + arg(f.Specialty) + `)` + } + if f.Service != "" { + sql += ` + AND EXISTS ( + SELECT 1 FROM doctor_services dsv + JOIN services sv ON sv.id = dsv.service_id + WHERE dsv.doctor_id = d.id AND sv.path = ` + arg(f.Service) + `)` + } + if f.Branch != "" { + sql += ` + AND EXISTS ( + SELECT 1 FROM doctor_branches db + JOIN branches b ON b.id = db.branch_id + WHERE db.doctor_id = d.id AND b.slug = ` + arg(f.Branch) + `)` + } + if f.Query != "" { + p := arg(likePattern(f.Query)) + sql += ` + AND (concat_ws(' ', d.last_name, d.first_name, d.middle_name) ILIKE ` + p + ` ESCAPE '\' + OR d.position ILIKE ` + p + ` ESCAPE '\')` + } + sql += ` + ORDER BY d.sort, d.last_name, d.id` + + doctors, err := s.collectDoctors(ctx, sql, args...) + if err != nil { + return nil, err + } + return s.attachSpecialties(ctx, doctors) +} + +func (s *Storage) DoctorsByService(ctx context.Context, serviceID int64) ([]domain.Doctor, error) { + doctors, err := s.collectDoctors(ctx, ` + SELECT `+doctorColumns+` + FROM doctors d + JOIN doctor_services ds ON ds.doctor_id = d.id + LEFT JOIN media m ON m.id = d.photo_id + WHERE ds.service_id = $1 AND d.published AND d.deleted_at IS NULL + ORDER BY d.sort, d.last_name, d.id`, serviceID) + if err != nil { + return nil, err + } + // Cards and JSON-LD must use the same primary-specialty URL as the directory. + return s.attachSpecialties(ctx, doctors) +} + +func (s *Storage) DoctorBySlug(ctx context.Context, slug string) (*domain.Doctor, error) { + row := s.pool.QueryRow(ctx, ` + SELECT `+doctorColumns+` + FROM doctors d + LEFT JOIN media m ON m.id = d.photo_id + WHERE d.slug = $1 AND d.published AND d.deleted_at IS NULL`, slug) + + d, err := scanDoctor(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + if err := s.pool.QueryRow(ctx, `SELECT bio FROM doctors WHERE id = $1`, d.ID).Scan(&d.Bio); err != nil { + return nil, err + } + + entries, err := s.doctorBio(ctx, d.ID) + if err != nil { + return nil, err + } + d.BioEntries = entries + + services, err := s.doctorServices(ctx, d.ID) + if err != nil { + return nil, err + } + d.Services = services + + branches, err := s.doctorBranches(ctx, d.ID) + if err != nil { + return nil, err + } + d.Branches = branches + + withSpec, err := s.attachSpecialties(ctx, []domain.Doctor{d}) + if err != nil { + return nil, err + } + return &withSpec[0], nil +} + +func (s *Storage) doctorBio(ctx context.Context, doctorID int64) ([]domain.BioEntry, error) { + rows, err := s.pool.Query(ctx, ` + SELECT kind, year_from, year_to, title, description + FROM doctor_bio_entries + WHERE doctor_id = $1 + ORDER BY sort, year_from DESC NULLS LAST, id`, doctorID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []domain.BioEntry + for rows.Next() { + var e domain.BioEntry + if err := rows.Scan(&e.Kind, &e.YearFrom, &e.YearTo, &e.Title, &e.Description); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +func (s *Storage) doctorServices(ctx context.Context, doctorID int64) ([]domain.DoctorService, error) { + rows, err := s.pool.Query(ctx, ` + SELECT sv.name, sv.path, sv.lead + FROM doctor_services ds + JOIN services sv ON sv.id = ds.service_id + WHERE ds.doctor_id = $1 AND sv.published AND sv.deleted_at IS NULL + ORDER BY sv.sort, sv.name, sv.id`, doctorID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []domain.DoctorService + for rows.Next() { + var ds domain.DoctorService + if err := rows.Scan(&ds.Name, &ds.Path, &ds.Lead); err != nil { + return nil, err + } + out = append(out, ds) + } + return out, rows.Err() +} + +func (s *Storage) doctorBranches(ctx context.Context, doctorID int64) ([]domain.DoctorBranch, error) { + rows, err := s.pool.Query(ctx, ` + SELECT b.slug, b.name, b.address + FROM doctor_branches db + JOIN branches b ON b.id = db.branch_id + WHERE db.doctor_id = $1 AND b.published + ORDER BY b.sort, b.name`, doctorID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []domain.DoctorBranch + for rows.Next() { + var b domain.DoctorBranch + if err := rows.Scan(&b.Slug, &b.Name, &b.Address); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +// attachSpecialties догружает специальности одним запросом, без N+1. +func (s *Storage) attachSpecialties(ctx context.Context, doctors []domain.Doctor) ([]domain.Doctor, error) { + if len(doctors) == 0 { + return doctors, nil + } + ids := make([]int64, len(doctors)) + index := make(map[int64]int, len(doctors)) + for i, d := range doctors { + ids[i] = d.ID + index[d.ID] = i + } + + rows, err := s.pool.Query(ctx, ` + SELECT ds.doctor_id, sp.id, sp.slug, sp.name, sp.name_plural + FROM doctor_specialties ds + JOIN specialties sp ON sp.id = ds.specialty_id + WHERE ds.doctor_id = ANY($1) AND sp.published + ORDER BY ds.doctor_id, ds.is_primary DESC, sp.sort, sp.id`, ids) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var doctorID int64 + var sp domain.Specialty + if err := rows.Scan(&doctorID, &sp.ID, &sp.Slug, &sp.Name, &sp.NamePlural); err != nil { + return nil, err + } + if i, ok := index[doctorID]; ok { + doctors[i].Specialties = append(doctors[i].Specialties, sp) + } + } + return doctors, rows.Err() +} + +func (s *Storage) Specialties(ctx context.Context) ([]domain.Specialty, error) { + rows, err := s.pool.Query(ctx, ` + SELECT sp.id, sp.slug, sp.name, sp.name_plural, COUNT(d.id) + FROM specialties sp + LEFT JOIN doctor_specialties ds ON ds.specialty_id = sp.id + LEFT JOIN doctors d ON d.id = ds.doctor_id AND d.published AND d.deleted_at IS NULL + WHERE sp.published + GROUP BY sp.id + ORDER BY sp.sort, sp.name_plural, sp.id`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []domain.Specialty + for rows.Next() { + var sp domain.Specialty + if err := rows.Scan(&sp.ID, &sp.Slug, &sp.Name, &sp.NamePlural, &sp.Count); err != nil { + return nil, err + } + out = append(out, sp) + } + return out, rows.Err() +} diff --git a/internal/storage/lead_notice.go b/internal/storage/lead_notice.go new file mode 100644 index 0000000..1a88dfd --- /dev/null +++ b/internal/storage/lead_notice.go @@ -0,0 +1,43 @@ +package storage + +import ( + "context" + "encoding/json" + + "dana/internal/domain" +) + +// LeadNotice собирает заявку для уведомления: одним запросом вместе с названиями +// формы, услуги, врача и филиала, чтобы администратору не пришлось искать их по id. +func (s *Storage) LeadNotice(ctx context.Context, id int64) (domain.LeadNotice, error) { + var ( + n domain.LeadNotice + recipients []byte + payload []byte + ) + err := s.pool.QueryRow(ctx, ` + SELECT l.id, + COALESCE(f.code, ''), COALESCE(f.title, ''), COALESCE(f.recipients, '[]'::jsonb), + l.name, l.phone, l.email, + COALESCE(sv.name, ''), + concat_ws(' ', d.last_name, d.first_name, NULLIF(d.middle_name, '')), + COALESCE(b.address, ''), + l.message, l.payload, l.page_url, l.consent_at + FROM leads l + LEFT JOIN forms f ON f.id = l.form_id + LEFT JOIN services sv ON sv.id = l.service_id + LEFT JOIN doctors d ON d.id = l.doctor_id + LEFT JOIN branches b ON b.id = l.branch_id + WHERE l.id = $1`, id). + Scan(&n.ID, &n.FormCode, &n.FormTitle, &recipients, + &n.Name, &n.Phone, &n.Email, + &n.Service, &n.Doctor, &n.Branch, + &n.Message, &payload, &n.PageURL, &n.CreatedAt) + if err != nil { + return n, wrapNotFound(err) + } + // Битые JSON-поля не должны мешать уведомлению: заявка уже сохранена, отправим без них. + _ = json.Unmarshal(recipients, &n.Recipients) + _ = json.Unmarshal(payload, &n.Extra) + return n, nil +} diff --git a/internal/storage/leads.go b/internal/storage/leads.go new file mode 100644 index 0000000..0d79f93 --- /dev/null +++ b/internal/storage/leads.go @@ -0,0 +1,106 @@ +package storage + +import ( + "context" + "encoding/json" + "errors" + "net/netip" + + "dana/internal/domain" +) + +// ErrFormNotFound — формы с таким кодом нет или она выключена. +var ErrFormNotFound = errors.New("form not found") + +// FormByCode отдаёт описание формы вместе с полями — фронт по нему строит форму, +// а сервер по нему же проверяет присланное. +func (s *Storage) FormByCode(ctx context.Context, code string) (*domain.Form, error) { + var f domain.Form + err := s.pool.QueryRow(ctx, ` + SELECT id, code, title, success_text + FROM forms WHERE code = $1 AND published`, code). + Scan(&f.ID, &f.Code, &f.Title, &f.SuccessText) + if errors.Is(wrapNotFound(err), ErrNotFound) { + return nil, ErrFormNotFound + } + if err != nil { + return nil, err + } + + rows, err := s.pool.Query(ctx, ` + SELECT code, label, type, required, options + FROM form_fields WHERE form_id = $1 ORDER BY sort, id`, f.ID) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var ff domain.FormField + if err := rows.Scan(&ff.Code, &ff.Label, &ff.Type, &ff.Required, &ff.Options); err != nil { + return nil, err + } + f.Fields = append(f.Fields, ff) + } + return &f, rows.Err() +} + +// Forms отдаёт все опубликованные формы (для админки и для фронта разом). +func (s *Storage) Forms(ctx context.Context) ([]domain.Form, error) { + rows, err := s.pool.Query(ctx, ` + SELECT code FROM forms WHERE published ORDER BY id`) + if err != nil { + return nil, err + } + var codes []string + for rows.Next() { + var c string + if err := rows.Scan(&c); err != nil { + rows.Close() + return nil, err + } + codes = append(codes, c) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + out := make([]domain.Form, 0, len(codes)) + for _, c := range codes { + f, err := s.FormByCode(ctx, c) + if err != nil { + return nil, err + } + out = append(out, *f) + } + return out, nil +} + +// CreateLead сохраняет заявку с сайта. Согласие на обработку персональных данных +// фиксируется временем записи — без него заявку принимать нельзя (152-ФЗ). +func (s *Storage) CreateLead(ctx context.Context, formID *int64, in domain.LeadInput, ip, userAgent string) (int64, error) { + extra, err := json.Marshal(in.Extra) + if err != nil { + return 0, err + } + utm, err := json.Marshal(in.UTM) + if err != nil { + return 0, err + } + + // inet не принимает пустую строку и мусор — кладём NULL, если адрес не разобрался. + var addr any + if parsed, err := netip.ParseAddr(ip); err == nil { + addr = parsed.String() + } + + var id int64 + err = s.pool.QueryRow(ctx, ` + INSERT INTO leads (form_id, name, phone, email, service_id, doctor_id, branch_id, + message, payload, page_url, utm, ip, user_agent, consent_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13, now()) + RETURNING id`, + formID, in.Name, in.Phone, in.Email, in.ServiceID, in.DoctorID, in.BranchID, + in.Message, extra, in.PageURL, utm, addr, userAgent).Scan(&id) + return id, err +} diff --git a/internal/storage/media.go b/internal/storage/media.go new file mode 100644 index 0000000..c3e95f8 --- /dev/null +++ b/internal/storage/media.go @@ -0,0 +1,33 @@ +package storage + +import "context" + +// CreateMedia регистрирует уже сохранённый на диск файл. width/height могут быть +// nil, если размер картинки определить не удалось. +func (s *Storage) CreateMedia(ctx context.Context, path, mime string, size int64, + width, height *int, alt string) (int64, error) { + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO media (path, mime, size, width, height, alt) + VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, + path, mime, size, width, height, alt).Scan(&id) + return id, err +} + +// CreateSiteReview кладёт отзыв, оставленный на сайте, в очередь модерации. +// На сайте он появится только после публикации в админке. +func (s *Storage) CreateSiteReview(ctx context.Context, author, text string, + rating *int, doctorID *int64) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO reviews (author_name, rating, text, doctor_id, source, status) + VALUES ($1, $2, $3, $4, 'site', 'pending')`, + author, rating, text, doctorID) + return err +} + +// MediaMime отдаёт тип файла из медиатеки: админка проверяет, что в поле картинки не попал PDF. +func (s *Storage) MediaMime(ctx context.Context, id int64) (string, error) { + var mime string + err := s.pool.QueryRow(ctx, `SELECT mime FROM media WHERE id=$1`, id).Scan(&mime) + return mime, wrapNotFound(err) +} diff --git a/internal/storage/migrations/0001_core.sql b/internal/storage/migrations/0001_core.sql new file mode 100644 index 0000000..c511620 --- /dev/null +++ b/internal/storage/migrations/0001_core.sql @@ -0,0 +1,203 @@ +-- Ядро: настройки, медиа, филиалы, услуги, врачи, цены, чек-апы. + +CREATE TABLE settings ( + key text PRIMARY KEY, + value jsonb NOT NULL DEFAULT '{}'::jsonb, + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE media ( + id bigserial PRIMARY KEY, + path text NOT NULL UNIQUE, + mime text NOT NULL, + size bigint NOT NULL, + width int, + height int, + alt text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE branches ( + id bigserial PRIMARY KEY, + slug text NOT NULL UNIQUE, + name text NOT NULL, + address text NOT NULL DEFAULT '', + phones jsonb NOT NULL DEFAULT '[]'::jsonb, + work_hours jsonb NOT NULL DEFAULT '[]'::jsonb, + lat numeric(9, 6), + lon numeric(9, 6), + sort int NOT NULL DEFAULT 100, + published boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Направления и услуги. path — полный путь без ведущего слэша, +-- например "ginekologiya/kolposkopiya"; он же основа URL. +CREATE TABLE services ( + id bigserial PRIMARY KEY, + parent_id bigint REFERENCES services (id) ON DELETE RESTRICT, + slug text NOT NULL, + path text NOT NULL UNIQUE, + name text NOT NULL, + short_name text NOT NULL DEFAULT '', + lead text NOT NULL DEFAULT '', + blocks jsonb NOT NULL DEFAULT '[]'::jsonb, + image_id bigint REFERENCES media (id) ON DELETE SET NULL, + icon text NOT NULL DEFAULT '', + sort int NOT NULL DEFAULT 100, + published boolean NOT NULL DEFAULT true, + show_in_menu boolean NOT NULL DEFAULT true, + seo_title text NOT NULL DEFAULT '', + seo_description text NOT NULL DEFAULT '', + seo_h1 text NOT NULL DEFAULT '', + og_image_id bigint REFERENCES media (id) ON DELETE SET NULL, + legacy_id bigint, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); + +CREATE UNIQUE INDEX services_parent_slug_idx + ON services (COALESCE(parent_id, 0), slug) WHERE deleted_at IS NULL; +CREATE INDEX services_parent_idx ON services (parent_id) WHERE deleted_at IS NULL; + +CREATE TABLE specialties ( + id bigserial PRIMARY KEY, + slug text NOT NULL UNIQUE, + name text NOT NULL, -- «Гинеколог» + name_plural text NOT NULL, -- «Гинекологи» + sort int NOT NULL DEFAULT 100, + published boolean NOT NULL DEFAULT true +); + +CREATE TABLE doctors ( + id bigserial PRIMARY KEY, + slug text NOT NULL UNIQUE, + last_name text NOT NULL, + first_name text NOT NULL, + middle_name text NOT NULL DEFAULT '', + position text NOT NULL DEFAULT '', + category text NOT NULL DEFAULT '', -- нет / вторая / первая / высшая + degree text NOT NULL DEFAULT '', + practice_start_year int, + photo_id bigint REFERENCES media (id) ON DELETE SET NULL, + quote text NOT NULL DEFAULT '', + bio text NOT NULL DEFAULT '', + languages text[] NOT NULL DEFAULT '{}', + accepts_kids_from int, -- возраст в годах, NULL = не принимает детей + booking_provider text NOT NULL DEFAULT 'none' -- none | native | medflex + CHECK (booking_provider IN ('none', 'native', 'medflex')), + medflex_user text NOT NULL DEFAULT '', + published boolean NOT NULL DEFAULT true, + show_on_home boolean NOT NULL DEFAULT false, + absent boolean NOT NULL DEFAULT false, + absent_until date, + sort int NOT NULL DEFAULT 100, + seo_title text NOT NULL DEFAULT '', + seo_description text NOT NULL DEFAULT '', + seo_h1 text NOT NULL DEFAULT '', + legacy_id bigint, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); + +CREATE INDEX doctors_published_idx ON doctors (published) WHERE deleted_at IS NULL; + +CREATE TABLE doctor_specialties ( + doctor_id bigint NOT NULL REFERENCES doctors (id) ON DELETE CASCADE, + specialty_id bigint NOT NULL REFERENCES specialties (id) ON DELETE CASCADE, + is_primary boolean NOT NULL DEFAULT false, + PRIMARY KEY (doctor_id, specialty_id) +); + +CREATE TABLE doctor_services ( + doctor_id bigint NOT NULL REFERENCES doctors (id) ON DELETE CASCADE, + service_id bigint NOT NULL REFERENCES services (id) ON DELETE CASCADE, + PRIMARY KEY (doctor_id, service_id) +); + +CREATE TABLE doctor_branches ( + doctor_id bigint NOT NULL REFERENCES doctors (id) ON DELETE CASCADE, + branch_id bigint NOT NULL REFERENCES branches (id) ON DELETE CASCADE, + PRIMARY KEY (doctor_id, branch_id) +); + +-- Биография врача повторяемыми записями вместо простыни текста. +CREATE TABLE doctor_bio_entries ( + id bigserial PRIMARY KEY, + doctor_id bigint NOT NULL REFERENCES doctors (id) ON DELETE CASCADE, + kind text NOT NULL + CHECK (kind IN ('education', 'experience', 'training', 'award', 'conference')), + year_from int, + year_to int, + title text NOT NULL, + description text NOT NULL DEFAULT '', + sort int NOT NULL DEFAULT 100 +); + +CREATE INDEX doctor_bio_entries_doctor_idx ON doctor_bio_entries (doctor_id, kind); + +CREATE TABLE price_sections ( + id bigserial PRIMARY KEY, + parent_id bigint REFERENCES price_sections (id) ON DELETE RESTRICT, + slug text NOT NULL UNIQUE, + name text NOT NULL, + sort int NOT NULL DEFAULT 100, + published boolean NOT NULL DEFAULT true, + legacy_id bigint +); + +CREATE TABLE price_items ( + id bigserial PRIMARY KEY, + section_id bigint NOT NULL REFERENCES price_sections (id) ON DELETE CASCADE, + service_id bigint REFERENCES services (id) ON DELETE SET NULL, + branch_id bigint REFERENCES branches (id) ON DELETE SET NULL, -- NULL = во всех филиалах + name text NOT NULL, + price numeric(12, 2) NOT NULL, + unit text NOT NULL DEFAULT '', + price_from boolean NOT NULL DEFAULT false, -- «от» + -- позиция-выброс, не участвует в расчёте «от N ₽» на карточке услуги + exclude_from_min boolean NOT NULL DEFAULT false, + sort int NOT NULL DEFAULT 100, + published boolean NOT NULL DEFAULT true, + legacy_id bigint, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX price_items_section_idx ON price_items (section_id); +CREATE INDEX price_items_service_idx ON price_items (service_id) WHERE service_id IS NOT NULL; + +CREATE TABLE checkups ( + id bigserial PRIMARY KEY, + slug text NOT NULL UNIQUE, + audience text NOT NULL DEFAULT 'adults' + CHECK (audience IN ('adults', 'kids')), + name text NOT NULL, + lead text NOT NULL DEFAULT '', + blocks jsonb NOT NULL DEFAULT '[]'::jsonb, + price numeric(12, 2), + old_price numeric(12, 2), + image_id bigint REFERENCES media (id) ON DELETE SET NULL, + featured boolean NOT NULL DEFAULT false, + sort int NOT NULL DEFAULT 100, + published boolean NOT NULL DEFAULT true, + seo_title text NOT NULL DEFAULT '', + seo_description text NOT NULL DEFAULT '', + seo_h1 text NOT NULL DEFAULT '', + legacy_id bigint, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); + +CREATE TABLE checkup_items ( + id bigserial PRIMARY KEY, + checkup_id bigint NOT NULL REFERENCES checkups (id) ON DELETE CASCADE, + name text NOT NULL, + sort int NOT NULL DEFAULT 100 +); + +CREATE INDEX checkup_items_checkup_idx ON checkup_items (checkup_id); diff --git a/internal/storage/migrations/0002_content.sql b/internal/storage/migrations/0002_content.sql new file mode 100644 index 0000000..7d6f95b --- /dev/null +++ b/internal/storage/migrations/0002_content.sql @@ -0,0 +1,150 @@ +-- Контентные разделы: публикации, отзывы, документы, галерея, меню, страницы. + +-- Новости, статьи и акции устроены одинаково, различаются только kind. +-- У акций дополнительно работают starts_at / ends_at. +CREATE TABLE posts ( + id bigserial PRIMARY KEY, + kind text NOT NULL + CHECK (kind IN ('news', 'article', 'promo')), + slug text NOT NULL, + title text NOT NULL, + lead text NOT NULL DEFAULT '', + blocks jsonb NOT NULL DEFAULT '[]'::jsonb, + image_id bigint REFERENCES media (id) ON DELETE SET NULL, + starts_at date, + ends_at date, + published boolean NOT NULL DEFAULT true, + published_at timestamptz NOT NULL DEFAULT now(), + seo_title text NOT NULL DEFAULT '', + seo_description text NOT NULL DEFAULT '', + seo_h1 text NOT NULL DEFAULT '', + legacy_id bigint, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); + +CREATE UNIQUE INDEX posts_kind_slug_idx ON posts (kind, slug) WHERE deleted_at IS NULL; +CREATE INDEX posts_kind_published_idx ON posts (kind, published, published_at DESC); + +CREATE TABLE reviews ( + id bigserial PRIMARY KEY, + author_name text NOT NULL, + author_photo_id bigint REFERENCES media (id) ON DELETE SET NULL, + rating int CHECK (rating BETWEEN 1 AND 5), + text text NOT NULL, + doctor_id bigint REFERENCES doctors (id) ON DELETE SET NULL, + service_id bigint REFERENCES services (id) ON DELETE SET NULL, + source text NOT NULL DEFAULT 'site' -- site | yandex | prodoctorov | manual + CHECK (source IN ('site', 'yandex', 'prodoctorov', 'manual')), + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'published', 'rejected')), + reply text NOT NULL DEFAULT '', -- ответ клиники + moderated_at timestamptz, + legacy_id bigint, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX reviews_status_idx ON reviews (status, created_at DESC); +CREATE INDEX reviews_doctor_idx ON reviews (doctor_id) WHERE doctor_id IS NOT NULL; + +CREATE TABLE faq ( + id bigserial PRIMARY KEY, + question text NOT NULL, + answer text NOT NULL, + service_id bigint REFERENCES services (id) ON DELETE CASCADE, -- NULL = общий вопрос + sort int NOT NULL DEFAULT 100, + published boolean NOT NULL DEFAULT true, + legacy_id bigint +); + +CREATE TABLE vacancies ( + id bigserial PRIMARY KEY, + slug text NOT NULL UNIQUE, + title text NOT NULL, + description text NOT NULL DEFAULT '', + requirements text NOT NULL DEFAULT '', + conditions text NOT NULL DEFAULT '', + salary text NOT NULL DEFAULT '', + branch_id bigint REFERENCES branches (id) ON DELETE SET NULL, + sort int NOT NULL DEFAULT 100, + published boolean NOT NULL DEFAULT true, + legacy_id bigint, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Лицензии, сертификаты, сведения о надзорных органах и прочие документы. +CREATE TABLE documents ( + id bigserial PRIMARY KEY, + kind text NOT NULL + CHECK (kind IN ('license', 'certificate', 'supervisor', 'other')), + title text NOT NULL, + body text NOT NULL DEFAULT '', + file_id bigint REFERENCES media (id) ON DELETE SET NULL, + preview_id bigint REFERENCES media (id) ON DELETE SET NULL, + issued_at date, + sort int NOT NULL DEFAULT 100, + published boolean NOT NULL DEFAULT true, + legacy_id bigint +); + +CREATE TABLE partners ( + id bigserial PRIMARY KEY, + name text NOT NULL, + url text NOT NULL DEFAULT '', + logo_id bigint REFERENCES media (id) ON DELETE SET NULL, + sort int NOT NULL DEFAULT 100, + published boolean NOT NULL DEFAULT true, + legacy_id bigint +); + +CREATE TABLE gallery_albums ( + id bigserial PRIMARY KEY, + slug text NOT NULL UNIQUE, + title text NOT NULL, + branch_id bigint REFERENCES branches (id) ON DELETE SET NULL, + sort int NOT NULL DEFAULT 100, + published boolean NOT NULL DEFAULT true +); + +CREATE TABLE gallery_photos ( + id bigserial PRIMARY KEY, + album_id bigint NOT NULL REFERENCES gallery_albums (id) ON DELETE CASCADE, + media_id bigint NOT NULL REFERENCES media (id) ON DELETE CASCADE, + caption text NOT NULL DEFAULT '', + sort int NOT NULL DEFAULT 100 +); + +-- Меню редактируется в админке, а не зашито в шаблон. +CREATE TABLE menu_items ( + id bigserial PRIMARY KEY, + menu text NOT NULL DEFAULT 'main' -- main | footer | mobile + CHECK (menu IN ('main', 'footer', 'mobile')), + parent_id bigint REFERENCES menu_items (id) ON DELETE CASCADE, + title text NOT NULL, + url text NOT NULL DEFAULT '', + -- если задан service_id, ссылка строится автоматически по услуге + service_id bigint REFERENCES services (id) ON DELETE CASCADE, + icon text NOT NULL DEFAULT '', + wide boolean NOT NULL DEFAULT false, -- пункт раскрывается мегаменю + sort int NOT NULL DEFAULT 100, + published boolean NOT NULL DEFAULT true +); + +CREATE INDEX menu_items_menu_idx ON menu_items (menu, parent_id, sort); + +-- Произвольные страницы: о клинике, юридические документы, главная. +CREATE TABLE pages ( + id bigserial PRIMARY KEY, + slug text NOT NULL UNIQUE, + title text NOT NULL, + blocks jsonb NOT NULL DEFAULT '[]'::jsonb, + published boolean NOT NULL DEFAULT true, + seo_title text NOT NULL DEFAULT '', + seo_description text NOT NULL DEFAULT '', + seo_h1 text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); diff --git a/internal/storage/migrations/0003_leads_admin.sql b/internal/storage/migrations/0003_leads_admin.sql new file mode 100644 index 0000000..c9eff7e --- /dev/null +++ b/internal/storage/migrations/0003_leads_admin.sql @@ -0,0 +1,108 @@ +-- Формы и заявки, редиректы со старых адресов, пользователи админки. + +CREATE TABLE forms ( + id bigserial PRIMARY KEY, + code text NOT NULL UNIQUE, -- appointment | callback | service_order | review | resume + title text NOT NULL, + success_text text NOT NULL DEFAULT '', + recipients jsonb NOT NULL DEFAULT '[]'::jsonb, -- куда слать уведомления + published boolean NOT NULL DEFAULT true +); + +CREATE TABLE form_fields ( + id bigserial PRIMARY KEY, + form_id bigint NOT NULL REFERENCES forms (id) ON DELETE CASCADE, + code text NOT NULL, + label text NOT NULL, + type text NOT NULL DEFAULT 'text' + CHECK (type IN ('text', 'textarea', 'phone', 'email', 'date', 'select', 'service', 'doctor', 'rating', 'file')), + required boolean NOT NULL DEFAULT false, + options jsonb NOT NULL DEFAULT '[]'::jsonb, + sort int NOT NULL DEFAULT 100, + UNIQUE (form_id, code) +); + +CREATE TABLE leads ( + id bigserial PRIMARY KEY, + form_id bigint REFERENCES forms (id) ON DELETE SET NULL, + -- разобранные поля, по которым строится поиск и статистика + name text NOT NULL DEFAULT '', + phone text NOT NULL DEFAULT '', + email text NOT NULL DEFAULT '', + service_id bigint REFERENCES services (id) ON DELETE SET NULL, + doctor_id bigint REFERENCES doctors (id) ON DELETE SET NULL, + branch_id bigint REFERENCES branches (id) ON DELETE SET NULL, + message text NOT NULL DEFAULT '', + -- остальные поля формы как есть + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + status text NOT NULL DEFAULT 'new' + CHECK (status IN ('new', 'in_progress', 'done', 'spam')), + comment text NOT NULL DEFAULT '', + page_url text NOT NULL DEFAULT '', + utm jsonb NOT NULL DEFAULT '{}'::jsonb, + ip inet, + user_agent text NOT NULL DEFAULT '', + -- согласие на обработку персональных данных фиксируется явно + consent_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX leads_created_idx ON leads (created_at DESC); +CREATE INDEX leads_status_idx ON leads (status, created_at DESC); +CREATE INDEX leads_phone_idx ON leads (phone); + +-- Старые адреса Bitrix, чтобы не потерять позиции в поиске. +CREATE TABLE redirects ( + id bigserial PRIMARY KEY, + from_path text NOT NULL UNIQUE, + to_path text NOT NULL, + code int NOT NULL DEFAULT 301 CHECK (code IN (301, 302)), + hits bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Журнал ненайденных адресов: подсказывает, какие редиректы ещё нужны. +CREATE TABLE not_found_log ( + path text PRIMARY KEY, + hits bigint NOT NULL DEFAULT 1, + last_seen timestamptz NOT NULL DEFAULT now(), + referer text NOT NULL DEFAULT '' +); + +CREATE TABLE admin_users ( + id bigserial PRIMARY KEY, + email text NOT NULL UNIQUE, + password_hash text NOT NULL, + name text NOT NULL DEFAULT '', + role text NOT NULL DEFAULT 'editor' + CHECK (role IN ('owner', 'admin', 'editor', 'registrar')), + active boolean NOT NULL DEFAULT true, + last_login_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE admin_sessions ( + token text PRIMARY KEY, + user_id bigint NOT NULL REFERENCES admin_users (id) ON DELETE CASCADE, + expires_at timestamptz NOT NULL, + ip inet, + user_agent text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX admin_sessions_user_idx ON admin_sessions (user_id); + +-- Кто что менял: нужно и для разбора ошибок, и по требованиям к медданным. +CREATE TABLE audit_log ( + id bigserial PRIMARY KEY, + user_id bigint REFERENCES admin_users (id) ON DELETE SET NULL, + action text NOT NULL, -- create | update | delete | login | view_leads + entity text NOT NULL DEFAULT '', + entity_id bigint, + diff jsonb NOT NULL DEFAULT '{}'::jsonb, + ip inet, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX audit_log_created_idx ON audit_log (created_at DESC); diff --git a/internal/storage/migrations/0004_seed_forms.sql b/internal/storage/migrations/0004_seed_forms.sql new file mode 100644 index 0000000..3516d46 --- /dev/null +++ b/internal/storage/migrations/0004_seed_forms.sql @@ -0,0 +1,51 @@ +-- Формы сайта. На старом сайте их было 11, но заявки приходили только в пять — +-- остальные («Написать главврачу», «Отправить резюме», «Купить в 1 клик», +-- «Заказать продукт», «Оформление заказа») за всю историю не собрали ни одной, +-- поэтому здесь заводим только рабочие. + +INSERT INTO forms (code, title, success_text) VALUES + ('appointment', 'Записаться на приём', + 'Спасибо! Мы свяжемся с вами, чтобы подтвердить запись.'), + ('callback', 'Обратный звонок', + 'Спасибо! Перезвоним вам в ближайшее время.'), + ('service_order', 'Заказать услугу', + 'Спасибо! Мы свяжемся с вами и расскажем подробности.'), + ('review', 'Оставить отзыв', + 'Спасибо за отзыв! Он появится на сайте после проверки.'), + ('resume', 'Отклик на вакансию', + 'Спасибо! Мы рассмотрим ваш отклик и свяжемся с вами.'); + +INSERT INTO form_fields (form_id, code, label, type, required, sort) +SELECT f.id, v.code, v.label, v.type, v.required, v.sort +FROM forms f +JOIN (VALUES + -- запись на приём + ('appointment', 'name', 'Ваше имя', 'text', true, 10), + ('appointment', 'phone', 'Телефон', 'phone', true, 20), + ('appointment', 'service', 'Услуга', 'service', false, 30), + ('appointment', 'doctor', 'Специалист', 'doctor', false, 40), + ('appointment', 'date', 'Удобные дата и время','date', false, 50), + ('appointment', 'message', 'Комментарий', 'textarea', false, 60), + -- обратный звонок + ('callback', 'name', 'Ваше имя', 'text', true, 10), + ('callback', 'phone', 'Телефон', 'phone', true, 20), + -- заказать услугу + ('service_order', 'name', 'Ваше имя', 'text', true, 10), + ('service_order', 'phone', 'Телефон', 'phone', true, 20), + ('service_order', 'email', 'E-mail', 'email', false, 30), + ('service_order', 'service', 'Услуга', 'service', false, 40), + ('service_order', 'message', 'Сообщение', 'textarea', false, 50), + -- отзыв + ('review', 'name', 'Ваше имя', 'text', true, 10), + ('review', 'phone', 'Телефон', 'phone', true, 20), + ('review', 'rating', 'Оценка', 'rating', false, 30), + ('review', 'doctor', 'Специалист', 'doctor', false, 40), + ('review', 'message', 'Текст отзыва', 'textarea', true, 50), + -- отклик на вакансию + ('resume', 'name', 'Ваше имя', 'text', true, 10), + ('resume', 'phone', 'Телефон', 'phone', true, 20), + ('resume', 'email', 'E-mail', 'email', false, 30), + ('resume', 'message', 'О себе', 'textarea', false, 40), + ('resume', 'file', 'Резюме файлом', 'file', false, 50) +) AS v(form_code, code, label, type, required, sort) + ON v.form_code = f.code; diff --git a/internal/storage/migrations/0005_review_video.sql b/internal/storage/migrations/0005_review_video.sql new file mode 100644 index 0000000..bb69f1a --- /dev/null +++ b/internal/storage/migrations/0005_review_video.sql @@ -0,0 +1,12 @@ +-- Видео-отзывы. На старом сайте они лежали как