chore: initial commit of dana-medical production state
74
.deploy/dana-medical.caddy
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
.deploy/remote-build-start-dana.sh
Normal file
@ -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')"
|
||||||
38
.deploy/remote-init-dana.sh
Normal file
@ -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")"
|
||||||
40
.deploy/remote-install-dana-caddy.sh
Normal file
@ -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'
|
||||||
15
.deploy/remote-restore-dana.sh
Normal file
@ -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;"
|
||||||
12
.deploy/remote-secure-dana.sh
Normal file
@ -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")"
|
||||||
49
.deploy/remote-smoke-dana.sh
Normal file
@ -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 '<h1'
|
||||||
|
curl -fsS "$origin/" | grep -q 'https://danamedical.ru/'
|
||||||
|
curl -fsS "$origin/admin/pbx.js" | grep -q 'toggle(b.dataset.end)'
|
||||||
|
|
||||||
|
invalid_code="$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"form":"callback","phone":"+7 900 000-00-01","consent":false}' \
|
||||||
|
"$origin/api/v1/leads")"
|
||||||
|
[ "$invalid_code" = 400 ]
|
||||||
|
|
||||||
|
created_code="$(curl -sS -o "$body" -w '%{http_code}' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"form\":\"callback\",\"name\":\"$marker\",\"phone\":\"+7 900 000-01-23\",\"message\":\"Проверка после развёртывания\",\"page_url\":\"/deploy-check\",\"consent\":true}" \
|
||||||
|
"$origin/api/v1/leads")"
|
||||||
|
[ "$created_code" = 201 ]
|
||||||
|
grep -q '"id"' "$body"
|
||||||
|
|
||||||
|
lead_count="$(docker compose -p dana-medical -f docker-compose.prod.yml exec -T db \
|
||||||
|
psql -U dana -d dana -Atqc "select count(*) from leads where name='$marker'")"
|
||||||
|
[ "$lead_count" = 1 ]
|
||||||
|
printf 'lead_create=%s\n' "$created_code"
|
||||||
|
printf 'lead_persisted=%s\n' "$lead_count"
|
||||||
|
|
||||||
|
cleanup
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
remaining="$(docker compose -p dana-medical -f docker-compose.prod.yml exec -T db \
|
||||||
|
psql -U dana -d dana -Atqc "select count(*) from leads where name='$marker'")"
|
||||||
|
[ "$remaining" = 0 ]
|
||||||
|
printf 'lead_cleanup=ok\n'
|
||||||
21
.deploy/remote-start-dana-db.sh
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd /home/mikail/dana-medical
|
||||||
|
test -s .env
|
||||||
|
grep -q '^POSTGRES_PASSWORD=.' .env
|
||||||
|
docker compose -p dana-medical -f docker-compose.prod.yml config --quiet
|
||||||
|
docker compose -p dana-medical -f docker-compose.prod.yml up -d db
|
||||||
|
|
||||||
|
db_id="$(docker compose -p dana-medical -f docker-compose.prod.yml ps -q db)"
|
||||||
|
test -n "$db_id"
|
||||||
|
i=0
|
||||||
|
status=starting
|
||||||
|
while [ "$i" -lt 30 ]; do
|
||||||
|
status="$(docker inspect --format '{{.State.Health.Status}}' "$db_id")"
|
||||||
|
[ "$status" = healthy ] && break
|
||||||
|
sleep 2
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
[ "$status" = healthy ]
|
||||||
|
printf 'db_health=%s\n' "$status"
|
||||||
35
.deploy/remote-update-dana-caddy.sh
Executable file
@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
source_file=/home/mikail/dana-medical/.deploy/dana-medical.caddy
|
||||||
|
custom_dir=/home/kadir/timechecker/caddy-custom
|
||||||
|
container=timechecker-caddy-1
|
||||||
|
|
||||||
|
test -s "$source_file"
|
||||||
|
docker run --rm \
|
||||||
|
-v /home/mikail/dana-medical/.deploy:/source:ro \
|
||||||
|
-v "$custom_dir:/target" \
|
||||||
|
alpine:3.22 sh -c 'set -eu; cp /source/dana-medical.caddy /target/dana-medical.caddy.next; cp /target/dana-medical.caddy /target/dana-medical.caddy.previous; mv /target/dana-medical.caddy.next /target/dana-medical.caddy'
|
||||||
|
|
||||||
|
rollback() {
|
||||||
|
docker run --rm -v "$custom_dir:/target" alpine:3.22 sh -c 'test ! -f /target/dana-medical.caddy.previous || mv /target/dana-medical.caddy.previous /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 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'
|
||||||
34
.deploy/remote-verify-dana-preview.sh
Normal file
@ -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 '<h1' "$body"
|
||||||
|
grep -q 'DANA' "$body"
|
||||||
|
|
||||||
|
for path in /services/ /company/staff/ /price/ /checkup/ /contacts/ /admin/ /api/v1/health; do
|
||||||
|
code="$(curl -sS --max-time 15 -o /dev/null -w '%{http_code}' "$origin$path")"
|
||||||
|
[ "$code" = 200 ]
|
||||||
|
printf '%s=%s\n' "$path" "$code"
|
||||||
|
done
|
||||||
|
|
||||||
|
ability="$(curl -sS --max-time 15 -o /dev/null -w '%{http_code}' https://abilitycenter.ru/)"
|
||||||
|
[ "$ability" = 200 ]
|
||||||
|
printf 'preview=%s\n' "$status"
|
||||||
|
printf 'noindex=ok\n'
|
||||||
|
printf 'ability=%s\n' "$ability"
|
||||||
|
echo | openssl s_client -connect 127.0.0.1:443 -servername dana.195-95-133-47.sslip.io 2>/dev/null | openssl x509 -noout -subject -issuer -dates
|
||||||
13
.dockerignore
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.work
|
||||||
|
.gocache
|
||||||
|
.gotmp
|
||||||
|
media
|
||||||
|
web/node_modules
|
||||||
|
web/dist
|
||||||
|
web/.ssr
|
||||||
|
*.exe
|
||||||
|
*.log
|
||||||
19
.env.example
Normal file
@ -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 — только снимки сборки, требующие ручной пересборки после правок.
|
||||||
24
.gitignore
vendored
Normal file
@ -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
|
||||||
28
Dockerfile
Normal file
@ -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"]
|
||||||
140
cmd/server/main.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
24
deploy.env.example
Normal file
@ -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=
|
||||||
53
docker-compose.prod.yml
Normal file
@ -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:
|
||||||
44
docker-compose.yml
Normal file
@ -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:
|
||||||
17
go.mod
Normal file
@ -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
|
||||||
|
)
|
||||||
30
go.sum
Normal file
@ -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=
|
||||||
318
internal/api/admin.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
161
internal/api/admin_banners.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
41
internal/api/admin_banners_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
147
internal/api/admin_checkups.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
206
internal/api/admin_doctors.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
230
internal/api/admin_documents.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
65
internal/api/admin_documents_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
198
internal/api/admin_inbox.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
120
internal/api/admin_media.go
Normal file
@ -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,
|
||||||
|
}})
|
||||||
|
}
|
||||||
119
internal/api/admin_partners.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
55
internal/api/admin_partners_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
internal/api/admin_posts.go
Normal file
@ -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, "<img") {
|
||||||
|
fail(w, http.StatusUnprocessableEntity, "validation", "Чтобы опубликовать "+postKindLabels[kind]+", добавьте текст")
|
||||||
|
return in, false
|
||||||
|
}
|
||||||
|
// Разметка хранится как есть: сайт пропускает её через белый список тегов при выводе.
|
||||||
|
blocks, _ := json.Marshal([]map[string]string{{"type": "html", "html": html}})
|
||||||
|
if html == "" {
|
||||||
|
blocks = []byte("[]")
|
||||||
|
}
|
||||||
|
in.Blocks = blocks
|
||||||
|
|
||||||
|
at, err := parsePublishedAt(req.PublishedAt)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, http.StatusUnprocessableEntity, "validation", "Дата публикации в формате ГГГГ-ММ-ДД")
|
||||||
|
return in, false
|
||||||
|
}
|
||||||
|
in.PublishedAt = at
|
||||||
|
return in, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminPosts(kind string) authHandler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
|
||||||
|
list, err := a.store.AdminPosts(r.Context(), kind)
|
||||||
|
if err != nil {
|
||||||
|
handleErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(w, list, map[string]any{"total": len(list)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminCreatePost(kind string) authHandler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
|
||||||
|
in, valid := decodePost(w, r, kind)
|
||||||
|
if !valid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := a.store.AdminCreatePost(r.Context(), in)
|
||||||
|
if err != nil {
|
||||||
|
handleErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.store.Audit(r.Context(), u.ID, "create", kind, id, clientIP(r))
|
||||||
|
writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id, "slug": in.Slug}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminUpdatePost(kind string) authHandler {
|
||||||
|
return func(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 := decodePost(w, r, kind)
|
||||||
|
if !valid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldSlug, err := a.store.PublishedPostSlug(r.Context(), kind, id)
|
||||||
|
if err != nil {
|
||||||
|
handleErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.store.AdminUpdatePost(r.Context(), id, in); err != nil {
|
||||||
|
handleErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if oldSlug != "" && in.Published {
|
||||||
|
section := map[string]string{"news": "/news/", "article": "/articles/"}[kind]
|
||||||
|
a.keepOldPath(r.Context(), section+oldSlug+"/", section+in.Slug+"/")
|
||||||
|
}
|
||||||
|
a.store.Audit(r.Context(), u.ID, "update", kind, id, clientIP(r))
|
||||||
|
ok(w, map[string]any{"id": id, "slug": in.Slug}, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminDeletePost(kind string) authHandler {
|
||||||
|
return func(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.AdminDeletePost(r.Context(), kind, id); err != nil {
|
||||||
|
handleErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.store.Audit(r.Context(), u.ID, "delete", kind, id, clientIP(r))
|
||||||
|
ok(w, map[string]any{"id": id}, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
87
internal/api/admin_posts_test.go
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecodePostRules(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantCode int
|
||||||
|
wantText string
|
||||||
|
}{
|
||||||
|
{"published news", `{"title":"День открытых дверей","html":"<p>Текст</p>","published_at":"2026-09-17","published":true}`, 200, ""},
|
||||||
|
{"draft without text", `{"title":"Черновик","published":false}`, 200, ""},
|
||||||
|
{"picture-only post", `{"title":"Афиша","html":"<p><img src=\"/media/a.jpg\"></p>","published":true}`, 200, ""},
|
||||||
|
{"no title", `{"title":" ","published":false}`, 422, "заголовок"},
|
||||||
|
{"published without text", `{"title":"Пусто","html":"<p> </p>","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":"<h2>Раздел</h2>","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"] != "<h2>Раздел</h2>" {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
205
internal/api/admin_promos.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
92
internal/api/admin_promos_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
143
internal/api/admin_redirects.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
23
internal/api/admin_redirects_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
436
internal/api/admin_reports.go
Normal file
@ -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), ".", ",")
|
||||||
|
}
|
||||||
49
internal/api/admin_services.go
Normal file
@ -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}})
|
||||||
|
}
|
||||||
317
internal/api/admin_site.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
70
internal/api/admin_site_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
153
internal/api/admin_specialties.go
Normal file
@ -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}})
|
||||||
|
}
|
||||||
52
internal/api/admin_specialties_test.go
Normal file
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
internal/api/admin_stats.go
Normal file
@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
27
internal/api/admin_stats_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
internal/api/admin_vacancies.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
281
internal/api/admin_weekly.go
Normal file
@ -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
|
||||||
|
}
|
||||||
18
internal/api/adminui.go
Normal file
@ -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
|
||||||
|
}
|
||||||
1275
internal/api/adminui/admin.css
Normal file
4
internal/api/adminui/assets/README.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
heart.webp — прозрачный рендер сердца для главного экрана админки (1025×1400, альфа-канал).
|
||||||
|
Если файл убрать, на его месте появится пометка «Нужен 3D-рендер сердца».
|
||||||
|
hall.webp — фото холла клиники в шапке раздела «Слайды на главной».
|
||||||
|
lobby.webp — фото ресепшена в шапке раздела «Услуги и цены» → «Услуги».
|
||||||
BIN
internal/api/adminui/assets/channels/2gis.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
19
internal/api/adminui/assets/channels/SOURCES.md
Normal file
@ -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.
|
||||||
BIN
internal/api/adminui/assets/channels/doctu.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
28
internal/api/adminui/assets/channels/doctu.svg
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 25.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 270 69.7" style="enable-background:new 0 0 270 69.7;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#13A546;}
|
||||||
|
.st1{fill:#2C2B67;}
|
||||||
|
</style>
|
||||||
|
<path class="st0" d="M87.6,54c14.9,0,26.9-12.1,26.9-26.9c0-14.9-12.1-26.9-26.9-26.9S60.7,12.2,60.7,27C60.7,41.9,72.7,54,87.6,54z
|
||||||
|
M87.6,41.7c8.1,0,14.6-6.6,14.6-14.6c0-8.1-6.6-14.6-14.6-14.6c-8.1,0-14.6,6.6-14.6,14.6C73,35.1,79.5,41.7,87.6,41.7z"/>
|
||||||
|
<path class="st0" d="M100.6,53.2l12.1,14.1c2.2,2.6,6.1,2.9,8.7,0.7s2.9-6.1,0.7-8.7l-11.8-13.8C107.7,48.7,104.4,51.3,100.6,53.2z"
|
||||||
|
/>
|
||||||
|
<path class="st1" d="M168.3,45c0,1.8-0.6,3.3-1.7,4.4c-1.2,1.2-2.6,1.7-4.3,1.7c-1.9,0-3.5-0.7-4.7-2.1l-13.7-16.4l-5.8,5.4v6.9
|
||||||
|
c0,1.7-0.6,3.1-1.8,4.3S133.6,51,132,51s-3.1-0.6-4.3-1.8s-1.7-2.7-1.7-4.3V9.5c0-1.7,0.6-3.1,1.7-4.3c1.2-1.2,2.7-1.8,4.4-1.8
|
||||||
|
s3.1,0.6,4.2,1.8c1.2,1.2,1.8,2.7,1.8,4.3v15l19.3-19.6c1.1-1.1,2.4-1.6,4-1.6s3,0.6,4,1.7c1.1,1.1,1.6,2.4,1.6,4s-0.5,3-1.6,4
|
||||||
|
l-12.6,12.4l14.3,15.9C167.9,42.1,168.3,43.4,168.3,45z"/>
|
||||||
|
<path class="st1" d="M190.9,45V14.7h-8.3c-1.5,0-2.8-0.5-3.9-1.5c-1-1-1.5-2.3-1.5-3.8s0.5-2.7,1.5-3.8c1.1-1.1,2.4-1.6,3.9-1.6h29
|
||||||
|
c1.5,0,2.7,0.5,3.8,1.6c1.1,1,1.6,2.3,1.6,3.8c0,1.4-0.5,2.7-1.6,3.8c-1,1-2.3,1.5-3.8,1.5h-8.4v30.4c0,1.7-0.6,3.1-1.8,4.3
|
||||||
|
s-2.7,1.8-4.3,1.8c-1.7,0-3.1-0.6-4.3-1.8S190.9,46.7,190.9,45z"/>
|
||||||
|
<path class="st1" d="M231.9,58.6l0.6-0.1c3.2-0.1,5.3-1.2,6.4-3.4l1.7-4l-16-39.6c-0.3-0.5-0.4-1.3-0.4-2.2c0-1.7,0.6-3.1,1.7-4.2
|
||||||
|
c1.2-1.2,2.6-1.8,4.2-1.8c2.9,0,4.8,1.3,5.6,3.8l11.2,29.7L258,7.1c1-2.5,2.9-3.8,5.7-3.8c1.7,0,3.2,0.6,4.3,1.8
|
||||||
|
c1.2,1.2,1.7,2.6,1.7,4.2c0,0.7-0.2,1.5-0.5,2.3l-18.9,46.5c-2.9,7.4-8.5,11.2-16.9,11.3c-0.7,0-1.2,0-1.3-0.1
|
||||||
|
c-1.6-0.1-2.9-0.6-4-1.6s-1.5-2.3-1.5-3.9c0-1.5,0.5-2.7,1.5-3.7C229.3,59.2,230.5,58.7,231.9,58.6z"/>
|
||||||
|
<path class="st1" d="M53.5,46.2v11.2c0,1.5-0.5,2.7-1.5,3.8s-2.3,1.5-3.8,1.5s-2.8-0.5-3.9-1.5c-1-1-1.5-2.3-1.5-3.8v-7H10.9v7
|
||||||
|
c0,1.5-0.5,2.7-1.5,3.8s-2.3,1.5-3.8,1.5s-2.8-0.5-3.9-1.5c-1-1-1.5-2.3-1.5-3.8V48c0-4.6,1.5-7.2,4.4-8c2.6-4.8,4.5-13.1,5.6-24.9
|
||||||
|
l0.4-4.5c0.2-2.1,0.9-3.7,2.2-4.8C14.1,4.6,15.7,4,17.7,4H41c2.3,0,4.1,0.6,5.3,1.9c1.3,1.2,1.9,3,1.9,5.3v28.7
|
||||||
|
C51.8,40.2,53.5,42.3,53.5,46.2z M36.1,39.7V14.6h-15l-0.3,4.1c-0.6,10-2.2,17-4.7,21H36.1z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
BIN
internal/api/adminui/assets/channels/google.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
internal/api/adminui/assets/channels/instagram.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
internal/api/adminui/assets/channels/max.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
internal/api/adminui/assets/channels/napopravku.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
internal/api/adminui/assets/channels/ochevidno-site.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
internal/api/adminui/assets/channels/prodoctorov.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
internal/api/adminui/assets/channels/sber.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
internal/api/adminui/assets/channels/telegram.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
internal/api/adminui/assets/channels/vk.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
11
internal/api/adminui/assets/channels/website.svg
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||||
|
<g stroke="#4D9B83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="4" width="26" height="23" rx="3"/>
|
||||||
|
<path d="M3 11h26M14 7.5h10M7 16h7M7 20h4"/>
|
||||||
|
<path d="M19 16v13l3-3 2.5 4 2.5-1.7-2.5-4 4-.8L19 16Z" fill="#F0F6F2"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#4D9B83">
|
||||||
|
<circle cx="6.5" cy="7.5" r=".7"/>
|
||||||
|
<circle cx="9.5" cy="7.5" r=".7"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 483 B |
BIN
internal/api/adminui/assets/channels/yandex.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
internal/api/adminui/assets/channels/zoon.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
internal/api/adminui/assets/hall.webp
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
internal/api/adminui/assets/heart.webp
Normal file
|
After Width: | Height: | Size: 466 KiB |
BIN
internal/api/adminui/assets/lobby.webp
Normal file
|
After Width: | Height: | Size: 251 KiB |
189
internal/api/adminui/banners.js
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
/* «Слайды на главной»: шапка-витрина, превью слайдов, фильтр по статусу, порядок перетаскиванием.
|
||||||
|
Форма слайда — прежние bannerFields / normalizeBanner / editBanner. */
|
||||||
|
|
||||||
|
const BN_THEME_BG = { navy: '#2f4a68', lilac: '#8f86c9', orange: '#f08a3c' };
|
||||||
|
const BN_ICONS = {
|
||||||
|
grip: '<path d="M5 8h14M5 12h14M5 16h14"/>',
|
||||||
|
pencil: '<path d="M15.2 5.2l3.6 3.6M4.5 19.5l1-4.4L15.9 4.7a1.8 1.8 0 0 1 2.5 0l.9.9a1.8 1.8 0 0 1 0 2.5L8.9 18.5z"/>',
|
||||||
|
dots: '<circle cx="6" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="18" cy="12" r="1.3"/>',
|
||||||
|
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||||
|
};
|
||||||
|
const bnIcon = (n) => `<svg viewBox="0 0 24 24" aria-hidden="true">${BN_ICONS[n]}</svg>`;
|
||||||
|
|
||||||
|
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') + '<span>Изменить</span>';
|
||||||
|
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();
|
||||||
32
internal/api/adminui/clinic.js
Normal file
@ -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();
|
||||||
289
internal/api/adminui/dashboard.js
Normal file
@ -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 = '<div><b>Нужен 3D-рендер сердца</b>Файл <code>assets/heart.webp</code>: прозрачный фон, около 1400 px по высоте.</div>';
|
||||||
|
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 = '<span><i style="background:linear-gradient(90deg,#f4e9b2,#f5c9ac)"></i>все заявки</span>' +
|
||||||
|
'<span><i style="background:#bfe8d6"></i>записи на приём</span><span title="От 25-го до 75-го перцентиля за период">полоса — обычный уровень</span>';
|
||||||
|
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 = '<thead><tr><th>Дата / время</th><th>Пациент</th><th>Телефон</th><th>Откуда</th><th>Врач или услуга</th><th>Статус</th></tr></thead>';
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
236
internal/api/adminui/doctors-dash.js
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
/* «Дашборд врачей»: полнота карточек, онлайн-запись, отзывы, заявки.
|
||||||
|
Всё считается по текущим данным базы при каждом открытии. */
|
||||||
|
|
||||||
|
const DD_ICONS = {
|
||||||
|
id: '<rect x="3.5" y="5" width="17" height="14" rx="3"/><circle cx="9" cy="11" r="2.2"/><path d="M5.8 16.2a3.5 3.5 0 0 1 6.4 0M14 10h4M14 13.5h3"/>',
|
||||||
|
photo: '<rect x="3.5" y="6" width="17" height="13" rx="3"/><circle cx="12" cy="12.5" r="3.3"/><path d="M8.5 6l1.4-2h4.2l1.4 2"/>',
|
||||||
|
clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
|
||||||
|
award: '<circle cx="12" cy="9" r="5"/><path d="m9 13.3-1.5 7 4.5-2.3 4.5 2.3-1.5-7"/>',
|
||||||
|
alert: '<path d="M12 4.5 21 19.5H3z"/><path d="M12 10v4M12 16.8h.01"/>',
|
||||||
|
star: '<path d="m12 4 2.4 5 5.4.6-4 3.7 1.1 5.4L12 16l-4.9 2.7 1.1-5.4-4-3.7 5.4-.6z"/>',
|
||||||
|
link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>',
|
||||||
|
users: '<circle cx="9" cy="8.5" r="3.2"/><path d="M3 19.5a6 6 0 0 1 12 0M16 5.5a3 3 0 0 1 0 6M18 19.5a5.5 5.5 0 0 0-2.5-4.6"/>',
|
||||||
|
check: '<path d="m5 12.5 4.5 4.5L19 7.5"/><circle cx="12" cy="12" r="9"/>',
|
||||||
|
chat: '<path d="M20.5 11.6a8 8 0 0 1-11.7 7.1L4 19.8l1.1-4.4a8 8 0 1 1 15.4-3.8z"/>',
|
||||||
|
calendar: '<rect x="4" y="5.5" width="16" height="14.5" rx="2.5"/><path d="M8 3.5v4M16 3.5v4M4 10h16"/>',
|
||||||
|
download: '<path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19.5h14"/>',
|
||||||
|
};
|
||||||
|
const ddIcon = (n) => `<svg viewBox="0 0 24 24" aria-hidden="true">${DD_ICONS[n]}</svg>`;
|
||||||
|
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 = `<span class="dd-stat__icon" style="background:${bg};color:${ink}">${ddIcon(icon)}</span>
|
||||||
|
<b>${v}</b><span class="dd-stat__of">из ${n}</span><span class="dd-stat__label">${label}</span>`;
|
||||||
|
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 = `<span class="dd-att__icon">${ddIcon(icon)}</span><b>${v}</b><span>${label}</span>`;
|
||||||
|
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 += `<line x1="${L}" x2="${W - R + 4}" y1="${y(t)}" y2="${y(t)}" class="dd-grid"/><text x="${W - R + 10}" y="${y(t) + 4}" class="dd-axis">${t}</text>`;
|
||||||
|
// высокие холмы — дальше, низкие — ближе, чтобы было видно все
|
||||||
|
const order = data.map((d, i) => i).sort((a, b) => data[b].v - data[a].v);
|
||||||
|
const defs = DoctorDash.hills.map((c, i) => `<linearGradient id="dd-h${i}" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="${c}" stop-opacity=".62"/><stop offset="1" stop-color="${c}" stop-opacity=".1"/></linearGradient>`).join('');
|
||||||
|
const hills = order.map((i) => {
|
||||||
|
const h = hill(i, data[i].v), c = i % DoctorDash.hills.length;
|
||||||
|
return `<g class="dd-hill" data-i="${i}"><path d="${h.area}" fill="url(#dd-h${c})"/><path d="${h.line}" fill="none" stroke="${DoctorDash.hills[c]}" stroke-opacity=".75" stroke-width="1.2"/></g>`;
|
||||||
|
}).join('');
|
||||||
|
const labels = data.map((d, i) => `<text x="${cx(i)}" y="${H - 24}" text-anchor="middle" class="dd-cat">${d.name}</text>
|
||||||
|
<text x="${cx(i)}" y="${H - 8}" text-anchor="middle" class="dd-val">${d.v}</text>`).join('');
|
||||||
|
box.innerHTML = `<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img"
|
||||||
|
aria-label="Сколько врачей заполнили разделы карточки из ${n}: ${data.map((d) => d.name + ' ' + d.v).join(', ')}">
|
||||||
|
<defs>${defs}</defs>${grid}${hills}${labels}<line class="dd-guide" y1="${T}" y2="${y(0)}" hidden/></svg><div class="tooltip dd-tip" hidden></div>`;
|
||||||
|
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 = `<b>${data[i].v} из ${n}</b>${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 = `<span class="dd-row__label"></span><span class="dd-row__bar"><i style="width:${Math.max(v ? 3 : 0, p)}%"></i></span>
|
||||||
|
<span class="dd-row__text"></span><span class="dd-row__pct">${p}%</span><span class="dd-row__st">${st}</span>`;
|
||||||
|
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) + `<span>${label}</span>`;
|
||||||
|
b.onclick = fn;
|
||||||
|
bar.append(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
366
internal/api/adminui/doctors.js
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
/* Раздел «Врачи»: таблица с сортировкой и фильтрами, быстрые действия и сводка справа.
|
||||||
|
Карточка врача и «Услуги и филиалы» — прежние диалоги editDoctor / editDoctorLinks. */
|
||||||
|
|
||||||
|
const DR_ICONS = {
|
||||||
|
building: '<path d="M4 20.5h16M6 20.5V5.5a1.5 1.5 0 0 1 1.5-1.5h9A1.5 1.5 0 0 1 18 5.5v15"/><path d="M9.5 8h1.5M13 8h1.5M9.5 11.5h1.5M13 11.5h1.5M10.5 20.5v-4h3v4"/>',
|
||||||
|
pencil: '<path d="M15.2 5.2l3.6 3.6M4.5 19.5l1-4.4L15.9 4.7a1.8 1.8 0 0 1 2.5 0l.9.9a1.8 1.8 0 0 1 0 2.5L8.9 18.5z"/>',
|
||||||
|
dots: '<circle cx="6" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="18" cy="12" r="1.3"/>',
|
||||||
|
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||||
|
download: '<path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19.5h14"/>',
|
||||||
|
list: '<path d="M8 4h8a2 2 0 0 1 2 2v14H6V6a2 2 0 0 1 2-2z"/><path d="M9.5 9h5M9.5 12.5h5M9.5 16h3"/>',
|
||||||
|
people: '<circle cx="12" cy="8.5" r="3.2"/><path d="M5.5 19.5a6.5 6.5 0 0 1 13 0"/><circle cx="5.6" cy="10" r="2.2"/><circle cx="18.4" cy="10" r="2.2"/>',
|
||||||
|
info: '<circle cx="12" cy="12" r="8.5"/><path d="M12 11v5M12 8h.01"/>',
|
||||||
|
close: '<path d="M6 6l12 12M18 6 6 18"/>',
|
||||||
|
sort: '<path d="M8 9.5 12 5.5l4 4M8 14.5l4 4 4-4"/>',
|
||||||
|
search: '<path d="m20 20-4.3-4.3"/><circle cx="11" cy="11" r="6.3"/>',
|
||||||
|
};
|
||||||
|
const drIcon = (n) => `<svg viewBox="0 0 24 24" aria-hidden="true">${DR_ICONS[n]}</svg>`;
|
||||||
|
|
||||||
|
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') + '<span>Услуги и филиалы</span>';
|
||||||
|
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 = `<span><i></i>${label}</span><b>${value}</b>`;
|
||||||
|
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());
|
||||||
2427
internal/api/adminui/index.html
Normal file
151
internal/api/adminui/leads.css
Normal file
@ -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; }
|
||||||
|
}
|
||||||
154
internal/api/adminui/leads.js
Normal file
@ -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 = `<svg class="ld-empty__art" viewBox="0 0 260 150" aria-hidden="true"><path d="M52 122c-25-3-36-20-34-46 16 6 28 20 34 46Zm153 0c26-5 37-20 37-43-18 7-29 20-37 43Z" fill="#dcecff"/><rect x="73" y="20" width="103" height="120" rx="13" fill="#e3efff"/><rect x="84" y="28" width="81" height="104" rx="7" fill="#fff"/><rect x="103" y="13" width="44" height="18" rx="5" fill="#9aacc4"/><path d="M104 49h41M104 66h41M104 83h31M104 100h41" stroke="#a8cbfa" stroke-width="4" stroke-linecap="round"/><circle cx="193" cy="58" r="30" fill="#e6f1ff"/><path d="M182 48c2 12 11 21 21 23" fill="none" stroke="#3184ee" stroke-width="5" stroke-linecap="round"/><path d="M199 79c-4-5-13-6-17 0-6 9 3 18 17 28 14-10 23-19 17-28-4-6-13-5-17 0Z" fill="#9dc9ff"/><path d="M199 84v14m-7-7h14" stroke="#fff" stroke-width="5" stroke-linecap="round"/></svg>`;
|
||||||
|
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 = `<span class="ldd__ticon">${lddIcon(icon)}</span><div><small></small><b></b></div>`;
|
||||||
|
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: '<path d="M7 3.5h7l5 5v12H7a1.5 1.5 0 0 1-1.5-1.5V5A1.5 1.5 0 0 1 7 3.5z"/><path d="M14 3.5v5h5M9.5 13h6M9.5 16.5h6"/>',
|
||||||
|
close: '<path d="M6 6l12 12M18 6 6 18"/>',
|
||||||
|
user: '<circle cx="12" cy="8.5" r="3.6"/><path d="M5 20a7 7 0 0 1 14 0"/>',
|
||||||
|
doctor: '<circle cx="12" cy="7.5" r="3.4"/><path d="M5 20a7 7 0 0 1 14 0"/><path d="M12 14.5v3M10.5 16h3"/>',
|
||||||
|
gear: '<circle cx="12" cy="12" r="3.1"/><path d="M12 3.5v2.2M12 18.3v2.2M20.5 12h-2.2M5.7 12H3.5M18 6l-1.6 1.6M7.6 16.4 6 18M18 18l-1.6-1.6M7.6 7.6 6 6"/>',
|
||||||
|
calendar: '<rect x="4" y="5.5" width="16" height="14.5" rx="2.5"/><path d="M8 3.5v4M16 3.5v4M4 10h16M8.5 14h2M13.5 14h2"/>',
|
||||||
|
phone: '<path d="M5.2 3.8h3.3l1.7 4.3-2.1 1.3a10.5 10.5 0 0 0 5.3 5.3l1.3-2.1 4.3 1.7v3.3a1.9 1.9 0 0 1-2 1.9A15.8 15.8 0 0 1 3.3 5.8a1.9 1.9 0 0 1 1.9-2z"/>',
|
||||||
|
mail: '<rect x="3.5" y="5.5" width="17" height="13" rx="2.5"/><path d="m4.5 7 7.5 6 7.5-6"/>',
|
||||||
|
heart: '<path d="M20.4 5.9a4.9 4.9 0 0 0-6.9 0L12 7.4l-1.5-1.5a4.9 4.9 0 1 0-6.9 6.9l8.4 8.4 8.4-8.4a4.9 4.9 0 0 0 0-6.9z"/>',
|
||||||
|
chat: '<path d="M20.5 11.6a8 8 0 0 1-11.7 7.1L4 19.8l1.1-4.4a8 8 0 1 1 15.4-3.8z"/><path d="M8.5 11.5h.01M12 11.5h.01M15.5 11.5h.01"/>',
|
||||||
|
link: '<path d="M10 14a4.5 4.5 0 0 0 6.4 0l3-3a4.5 4.5 0 0 0-6.4-6.4l-1.2 1.2"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3 3a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2"/>',
|
||||||
|
save: '<path d="M5 3.5h11l3.5 3.5v12a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 19V5A1.5 1.5 0 0 1 5 3.5z"/><path d="M7.5 3.5v5h8v-5M7.5 20.5v-6h9v6"/>',
|
||||||
|
};
|
||||||
|
const lddIcon = (n) => `<svg viewBox="0 0 24 24" aria-hidden="true">${LDD_ICONS[n]}</svg>`;
|
||||||
|
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(); };
|
||||||
27
internal/api/adminui/onec.css
Normal file
@ -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; }
|
||||||
|
}
|
||||||
281
internal/api/adminui/onec.js
Normal file
@ -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();
|
||||||
453
internal/api/adminui/pbx.js
Normal file
@ -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: '<path d="M5.2 3.8h3.3l1.7 4.3-2.1 1.3a10.5 10.5 0 0 0 5.3 5.3l1.3-2.1 4.3 1.7v3.3a1.9 1.9 0 0 1-2 1.9A15.8 15.8 0 0 1 3.3 5.8a1.9 1.9 0 0 1 1.9-2z"/>',
|
||||||
|
phoneIn: '<path d="M5.2 3.8h3.3l1.7 4.3-2.1 1.3a10.5 10.5 0 0 0 5.3 5.3l1.3-2.1 4.3 1.7v3.3a1.9 1.9 0 0 1-2 1.9A15.8 15.8 0 0 1 3.3 5.8a1.9 1.9 0 0 1 1.9-2z"/><path d="M20.5 3.5 15 9M15 5v4h4"/>',
|
||||||
|
phoneOut: '<path d="M5.2 3.8h3.3l1.7 4.3-2.1 1.3a10.5 10.5 0 0 0 5.3 5.3l1.3-2.1 4.3 1.7v3.3a1.9 1.9 0 0 1-2 1.9A15.8 15.8 0 0 1 3.3 5.8a1.9 1.9 0 0 1 1.9-2z"/><path d="M15 9l5.5-5.5M16.5 3.5h4v4"/>',
|
||||||
|
phoneMiss: '<path d="M5.2 3.8h3.3l1.7 4.3-2.1 1.3a10.5 10.5 0 0 0 5.3 5.3l1.3-2.1 4.3 1.7v3.3a1.9 1.9 0 0 1-2 1.9A15.8 15.8 0 0 1 3.3 5.8a1.9 1.9 0 0 1 1.9-2z"/><path d="M15 3.5l5 5M20 3.5l-5 5"/>',
|
||||||
|
people: '<circle cx="9.5" cy="8.5" r="3.2"/><path d="M3.5 19.5a6 6 0 0 1 12 0"/><path d="M16 5.6a3 3 0 0 1 0 5.8M18 14.2a5.6 5.6 0 0 1 2.5 5.3"/>',
|
||||||
|
callback: '<path d="M4.5 12a7.5 7.5 0 1 0 2.3-5.4"/><path d="M4.5 4.5v4h4"/><path d="M12 8v4.3l2.8 1.7"/>',
|
||||||
|
calendar: '<rect x="4" y="5.5" width="16" height="14.5" rx="2.5"/><path d="M8 3.5v4M16 3.5v4M4 10h16"/>',
|
||||||
|
copy: '<rect x="8.5" y="8.5" width="11" height="11" rx="2.2"/><path d="M15.5 8.5V6a1.5 1.5 0 0 0-1.5-1.5H6A1.5 1.5 0 0 0 4.5 6v8A1.5 1.5 0 0 0 6 15.5h2.5"/>',
|
||||||
|
info: '<circle cx="12" cy="12" r="8.5"/><path d="M12 11v5M12 8h.01"/>',
|
||||||
|
left: '<path d="M14.5 6 8.5 12l6 6"/>',
|
||||||
|
right: '<path d="M9.5 6l6 6-6 6"/>',
|
||||||
|
dots: '<circle cx="6" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="18" cy="12" r="1.3"/>',
|
||||||
|
up: '<path d="M12 19V5M6.5 10.5 12 5l5.5 5.5"/>',
|
||||||
|
down: '<path d="M12 5v14M6.5 13.5 12 19l5.5-5.5"/>',
|
||||||
|
};
|
||||||
|
const pxIcon = (name) => `<svg viewBox="0 0 24 24" aria-hidden="true">${PX_ICONS[name]}</svg>`;
|
||||||
|
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 = '<button type="button" class="px-date" data-end="from"></button><span class="px-dash">—</span>' +
|
||||||
|
'<button type="button" class="px-date" data-end="to"></button><div class="dp" hidden role="dialog" aria-label="Выбор периода"></div>';
|
||||||
|
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') + `<span>${pxRu(input.value)}</span>`;
|
||||||
|
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 = `<div class="dp__head"><button type="button" class="dp__nav" data-go="-1" aria-label="Предыдущий месяц">${pxIcon('left')}</button>` +
|
||||||
|
`<b>${PX_MONTHS[m]} ${y}</b><button type="button" class="dp__nav" data-go="1" aria-label="Следующий месяц">${pxIcon('right')}</button></div><div class="dp__grid">`;
|
||||||
|
['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].forEach((w, i) => { html += `<span class="dp__wd${i > 4 ? ' dp__we' : ''}">${w}</span>`; });
|
||||||
|
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 += `<button type="button" class="${cls.join(' ')}" data-d="${iso}" aria-label="${d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })}"` +
|
||||||
|
`${iso === a || iso === b ? ' aria-pressed="true"' : ''}>${d.getDate()}</button>`;
|
||||||
|
}
|
||||||
|
html += '</div><div class="dp__foot"><button type="button" class="dp__link" data-act="clear">Очистить</button>' +
|
||||||
|
'<button type="button" class="dp__today-btn" data-act="today">Сегодня</button></div>';
|
||||||
|
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 `<svg class="px-spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true">` +
|
||||||
|
`<defs><linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${color}" stop-opacity=".28"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></linearGradient></defs>` +
|
||||||
|
`<path d="${line} L ${w} ${h} L 0 ${h} Z" fill="url(#${id})"/><path d="${line}" fill="none" stroke="${color}" stroke-width="1.6" stroke-linejoin="round" vector-effect="non-scaling-stroke"/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<span class="px-trend${good === null ? '' : good ? ' good' : ' bad'}" title="${cmp}">` +
|
||||||
|
`${v ? pxIcon(v > 0 ? 'up' : 'down') : ''}${v > 0 ? '+' : v < 0 ? '−' : ''}${Math.abs(v)}${points ? ' п.п.' : '%'}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.innerHTML = `<div class="px-kpi__top"><span class="px-kpi__icon">${pxIcon(icon)}</span><div class="px-kpi__main">` +
|
||||||
|
`<div class="px-kpi__row"><b class="px-kpi__value"></b>${trend}</div><span class="px-kpi__label"></span></div></div>` +
|
||||||
|
`<small class="px-kpi__hint"></small>${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); }
|
||||||
|
};
|
||||||
134
internal/api/adminui/reports-history.css
Normal file
@ -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; }
|
||||||
|
}
|
||||||
158
internal/api/adminui/reports-history.js
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
/* История: показатели одной обработанной книги, без суммирования разных выгрузок. */
|
||||||
|
const RH_ICONS = {
|
||||||
|
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4.5 4.5"/>',
|
||||||
|
calendar: '<rect x="4" y="5" width="16" height="16" rx="3"/><path d="M8 3v4m8-4v4M4 11h16m-12 4h2m4 0h2m-8 3h2"/>',
|
||||||
|
channels: '<rect x="8" y="3" width="8" height="5" rx="1.5"/><path d="M12 8v5M5 16v-3h14v3"/><rect x="2" y="16" width="6" height="5" rx="1.5"/><rect x="16" y="16" width="6" height="5" rx="1.5"/>',
|
||||||
|
wallet: '<path d="M19 7V4H6a3 3 0 0 0 0 6h14v11H6a3 3 0 0 1-3-3V7"/><path d="M20 13h-5v5h5m-3-2.5h.01"/>',
|
||||||
|
receipt: '<path d="M6 3h12v18l-3-2-3 2-3-2-3 2zM9 7h6M9 11h6m-6 4h3"/>',
|
||||||
|
};
|
||||||
|
const rhIcon = (name) => `<svg viewBox="0 0 24 24" aria-hidden="true">${RH_ICONS[name] || RP_ICONS[name] || ''}</svg>`;
|
||||||
|
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 = `<span class="rh-kpi-icon">${rhIcon(icon)}</span><div><strong></strong><span></span><small></small></div>`;
|
||||||
|
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 = '<span class="rh-rank-number"></span><div class="rh-rank-data"><div class="rh-rank-title"><span></span><strong></strong></div><div class="rh-track"><i></i></div><small></small></div>';
|
||||||
|
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');
|
||||||
515
internal/api/adminui/reports.js
Normal file
@ -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: '<path d="M7 3.5h7l5 5v12H7a1.5 1.5 0 0 1-1.5-1.5V5A1.5 1.5 0 0 1 7 3.5z"/><path d="M14 3.5v5h5M12.2 11.5v6M9.2 14.5h6"/>',
|
||||||
|
file: '<path d="M7 3.5h7l5 5v12H7a1.5 1.5 0 0 1-1.5-1.5V5A1.5 1.5 0 0 1 7 3.5z"/><path d="M14 3.5v5h5"/>',
|
||||||
|
refresh: '<path d="M4 12a8 8 0 0 1 13.7-5.6L20 8.7M20 4v4.7h-4.7M20 12a8 8 0 0 1-13.7 5.6L4 15.3M4 20v-4.7h4.7"/>',
|
||||||
|
play: '<path d="M8 5.5v13l10-6.5z"/>',
|
||||||
|
clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
|
||||||
|
info: '<circle cx="12" cy="12" r="8.5"/><path d="M12 11v5M12 8h.01"/>',
|
||||||
|
x: '<path d="M6 6l12 12M18 6 6 18"/>',
|
||||||
|
down: '<path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19.5h14"/>',
|
||||||
|
dots: '<circle cx="6" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="18" cy="12" r="1.3"/>',
|
||||||
|
check: '<path d="m6.5 12.5 3.5 3.5 7.5-8"/>',
|
||||||
|
};
|
||||||
|
const rpIcon = (n) => `<svg viewBox="0 0 24 24" aria-hidden="true">${RP_ICONS[n]}</svg>`;
|
||||||
|
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 = `<span class="rp-drop__icon">${rpIcon('doc')}</span><b>Файл загружен</b>
|
||||||
|
<span class="rp-drop__name"><span></span><i class="rp-ok">${rpIcon('check')}</i></span>
|
||||||
|
<small>${rpSize(f.size)} · ${f.name.split('.').pop().toUpperCase()}</small>`;
|
||||||
|
box.querySelector('.rp-drop__name span').textContent = f.name;
|
||||||
|
} else {
|
||||||
|
box.innerHTML = `<span class="rp-drop__icon">${rpIcon('doc')}</span><b>Перетащите книгу Excel сюда</b>
|
||||||
|
<small>или нажмите, чтобы выбрать файл · .xlsx или .csv до 10 МБ</small>`;
|
||||||
|
}
|
||||||
|
$('#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 = `<span class="rp-stage__mark">${s === 'done' ? rpIcon('check') : ''}</span>
|
||||||
|
<div><b>${name}</b><small>${s === 'now' ? hint + '…' : s === 'wait' ? 'Ожидание…' : s === 'stop' ? (st === 'failed' ? 'Ошибка на этом этапе' : 'Остановлено') : ''}</small></div>
|
||||||
|
<time>${s === 'done' && times[i] != null ? rpDur(times[i]) : ''}</time>`;
|
||||||
|
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') + '<span>Отменить</span>';
|
||||||
|
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') + `<span>Скачать отчёт · CSV для Excel · ${rpSize(r.result_size)}</span>`;
|
||||||
|
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 = '<thead><tr><th>Канал</th><th>Лиды</th><th>Пациентов</th><th>Конверсия</th><th>Выручка</th><th>Средний чек</th></tr></thead>';
|
||||||
|
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') + '<span>Отменить</span>';
|
||||||
|
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') + '<span>Скачать</span>';
|
||||||
|
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();
|
||||||
278
internal/api/adminui/services.js
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
/* «Услуги и цены» → «Услуги»: направления раскрываются, внутри — услуги направления.
|
||||||
|
Поиск и фильтры работают в браузере: услуг меньше сотни. Форма — прежняя editService. */
|
||||||
|
|
||||||
|
const SV_ICONS = {
|
||||||
|
'detskaya-klinika': '<circle cx="12" cy="12.5" r="7.5"/><path d="M9.3 11.3h.01M14.7 11.3h.01M9.5 15a3.4 3.4 0 0 0 5 0M10.5 5.3c0-1.3 1-2 2-1.6"/>',
|
||||||
|
ginekologiya: '<path d="M12 21v-6.5M12 14.5c-3.5 0-5-2.6-5-5.6V6.5M12 14.5c3.5 0 5-2.6 5-5.6V6.5"/><path d="M7 6.5C7 4.5 5.4 3.5 4 4.5M17 6.5c0-2 1.6-3 3-2M12 14.5V9"/>',
|
||||||
|
oko: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="3.2"/>',
|
||||||
|
estetic: '<path d="M12 3.5l1.8 4.7 4.7 1.8-4.7 1.8L12 16.5l-1.8-4.7L5.5 10l4.7-1.8zM18.5 15.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z"/>',
|
||||||
|
endokrinologiya: '<path d="M12 3.5s6 6.4 6 10.5a6 6 0 0 1-12 0c0-4.1 6-10.5 6-10.5z"/><path d="M9.5 14.5a2.6 2.6 0 0 0 2.5 2.5"/>',
|
||||||
|
urologiya: '<path d="M8.5 4.5c-3 0-4.5 3-4.5 6.5s1.8 7.5 5 7.5c2.2 0 2.4-2.4 1.6-4.5-.6-1.6.4-3 1.9-3M15.5 4.5c3 0 4.5 3 4.5 6.5s-1.8 7.5-5 7.5c-2.2 0-2.4-2.4-1.6-4.5"/>',
|
||||||
|
khirurgiya: '<path d="M4 20l9.5-9.5M13.5 10.5l5.2-5.2a1.8 1.8 0 0 1 2.5 2.5l-5.2 5.2-2.5-2.5zM4 20l2.5-.7L4.7 17.5z"/>',
|
||||||
|
mammologiya: '<path d="M12 21c-3-4.4-6-7.4-6-11a6 6 0 0 1 12 0c0 3.6-3 6.6-6 11z"/><path d="M9 3.8 15 16M15 3.8 9 16"/>',
|
||||||
|
osteo: '<path d="M12 3v18M9 5.5h6M8.5 9.5h7M9 13.5h6M9.5 17.5h5"/>',
|
||||||
|
proktologiya: '<path d="M6 4.5v5.2a4 4 0 0 0 8 0V4.5"/><path d="M10 13.7v1.1a4.8 4.8 0 0 0 9.6 0v-2"/><circle cx="19.6" cy="10.8" r="1.9"/>',
|
||||||
|
onkologiya: '<path d="M12 12c-2.3-2.9-3.5-5.2-3.5-6.8a3.5 3.5 0 0 1 7 0c0 1.6-1.2 3.9-3.5 6.8zM12 12l-4.5 8.5M12 12l4.5 8.5"/>',
|
||||||
|
'meditsinskiy-turizm': '<path d="M3 13.5 21 7l-2.5 5.5L8 15l-2.3 3.8H4l1-4.3zM10 9.2 7 5.5h2.5l5 2.3"/>',
|
||||||
|
_default: '<rect x="4" y="4" width="16" height="16" rx="4"/><path d="M12 8v8M8 12h8"/>',
|
||||||
|
};
|
||||||
|
const SV_TINTS = ['#fdeaec', '#e7f6ee', '#e5f1fb', '#fdf1e3', '#efeafb', '#e8f5f4'];
|
||||||
|
const SV_INK = ['#d9546d', '#2f9a6a', '#3a7bd5', '#d8822c', '#7a5cc9', '#2c8f88'];
|
||||||
|
const svIcon = (d) => `<svg viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
|
||||||
|
|
||||||
|
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 = `Найдено <u>${found}</u> ${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('<path d="m6 9 6 6 6-6"/>');
|
||||||
|
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 = '<thead><tr><th style="width:34px"></th><th>Название</th><th>Адрес</th><th>Цена от</th><th>Позиции</th><th>Порядок</th><th>Статус</th><th>Действия</th></tr></thead>';
|
||||||
|
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) + '<span>Изменить</span>';
|
||||||
|
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);
|
||||||
56
internal/api/adminui/share.html
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<title>Маркетинговая аналитика</title>
|
||||||
|
<link rel="stylesheet" href="admin.css?v=20261014-share">
|
||||||
|
<style>
|
||||||
|
/* Страница по ссылке для маркетолога: только дашборд, без меню админки. */
|
||||||
|
body { margin: 0; background: var(--bg, #f5f7fb); }
|
||||||
|
.sh-wrap { max-width: 100%; padding: 24px 16px 40px; box-sizing: border-box; }
|
||||||
|
.sh-head { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; margin-bottom: 16px; }
|
||||||
|
.sh-head h1 { margin: 0; font-size: 30px; letter-spacing: -0.03em; }
|
||||||
|
.sh-head .rp-tabs { margin: 0; }
|
||||||
|
.sh-error { max-width: 560px; margin: 80px auto; text-align: center; }
|
||||||
|
.sh-error h1 { font-size: 24px; }
|
||||||
|
.sh-wrap .rp-pbx-dates { flex-wrap: wrap; }
|
||||||
|
@media (max-width: 600px) { .sh-head h1 { font-size: 22px; } .sh-wrap .rp-pbx-dates input { min-width: 0; flex: 1 1 130px; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="sh-wrap" id="sh-main" hidden>
|
||||||
|
<div class="sh-head">
|
||||||
|
<h1 id="sh-title">Маркетинговая аналитика</h1>
|
||||||
|
<div class="rp-tabs" role="tablist" aria-label="Периодичность">
|
||||||
|
<button type="button" role="tab" data-mode="weeks" aria-selected="true">По неделям</button>
|
||||||
|
<button type="button" role="tab" data-mode="months" aria-selected="false">По месяцам</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card rp-progress" id="wk-status" data-state="idle">
|
||||||
|
<div class="rp-progress__head">
|
||||||
|
<div><h2 id="wk-title">Загрузка…</h2><p id="wk-sub"></p></div>
|
||||||
|
</div>
|
||||||
|
<div class="rp-pbx-dates" id="wk-filter">
|
||||||
|
<input type="date" id="wk-filter-from" aria-label="Показать с"><span>—</span><input type="date" id="wk-filter-to" aria-label="Показать по">
|
||||||
|
<button type="button" class="rp-link" id="wk-filter-reset">Показать все</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card wk-board" id="wk-board" hidden>
|
||||||
|
<div class="dr-scroll">
|
||||||
|
<table class="rp-table wk-table" id="wk-table"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<div class="sh-error" id="sh-error" hidden>
|
||||||
|
<h1>Ссылка недействительна</h1>
|
||||||
|
<p class="muted" id="sh-error-text">Возможно, её отключили или выпустили новую. Попросите актуальную ссылку.</p>
|
||||||
|
</div>
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="share.js?v=20261014-share"></script>
|
||||||
|
<script src="weekly.js?v=20261014-insta"></script>
|
||||||
|
<script>Share.start();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
60
internal/api/adminui/share.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
316
internal/api/adminui/shell.js
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
/* Оболочка админки: Sidebar (органическая лента навигации), Topbar (поиск, календарь,
|
||||||
|
уведомления, профиль) и маршрутизация разделов через #адрес. */
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
home: '<path d="M3.5 10.5 12 3.8l8.5 6.7V19a1.5 1.5 0 0 1-1.5 1.5h-4.2v-5.8H9.2v5.8H5A1.5 1.5 0 0 1 3.5 19z"/>',
|
||||||
|
leads: '<path d="M5.2 3.8h3.3l1.7 4.3-2.1 1.3a10.5 10.5 0 0 0 5.3 5.3l1.3-2.1 4.3 1.7v3.3a1.9 1.9 0 0 1-2 1.9A15.8 15.8 0 0 1 3.3 5.8a1.9 1.9 0 0 1 1.9-2z"/>',
|
||||||
|
doctors: '<path d="M6 3.5v5.2a4 4 0 0 0 8 0V3.5"/><path d="M10 12.7v2.1a4.8 4.8 0 0 0 9.6 0v-2"/><circle cx="19.6" cy="10.8" r="1.9"/>',
|
||||||
|
catalog: '<path d="M20.4 5.9a4.9 4.9 0 0 0-6.9 0L12 7.4l-1.5-1.5a4.9 4.9 0 1 0-6.9 6.9l8.4 8.4 8.4-8.4a4.9 4.9 0 0 0 0-6.9z"/><path d="M6.5 12.2h3l1.4-2.2 2.2 4.3 1.4-2.1h3"/>',
|
||||||
|
content: '<path d="M7 3.5h7l5 5v12H7a1.5 1.5 0 0 1-1.5-1.5V5A1.5 1.5 0 0 1 7 3.5z"/><path d="M14 3.5v5h5M9.5 13h6M9.5 16.5h6"/>',
|
||||||
|
reviews: '<path d="M20.5 11.6a8 8 0 0 1-11.7 7.1L4 19.8l1.1-4.4a8 8 0 1 1 15.4-3.8z"/><path d="m12 8.4 1 2 2.2.3-1.6 1.6.4 2.2-2-1-2 1 .4-2.2-1.6-1.6 2.2-.3z"/>',
|
||||||
|
settings: '<circle cx="12" cy="12" r="3.1"/><path d="M19.2 14.7a1.6 1.6 0 0 0 .3 1.8l.1.1a1.9 1.9 0 1 1-2.7 2.7l-.1-.1a1.6 1.6 0 0 0-1.8-.3 1.6 1.6 0 0 0-1 1.5v.2a1.9 1.9 0 1 1-3.9 0v-.1a1.6 1.6 0 0 0-1-1.5 1.6 1.6 0 0 0-1.8.3l-.1.1a1.9 1.9 0 1 1-2.7-2.7l.1-.1a1.6 1.6 0 0 0 .3-1.8 1.6 1.6 0 0 0-1.5-1H3.3a1.9 1.9 0 1 1 0-3.9h.1a1.6 1.6 0 0 0 1.5-1 1.6 1.6 0 0 0-.3-1.8l-.1-.1a1.9 1.9 0 1 1 2.7-2.7l.1.1a1.6 1.6 0 0 0 1.8.3h.1a1.6 1.6 0 0 0 1-1.5v-.2a1.9 1.9 0 1 1 3.9 0v.1a1.6 1.6 0 0 0 1 1.5 1.6 1.6 0 0 0 1.8-.3l.1-.1a1.9 1.9 0 1 1 2.7 2.7l-.1.1a1.6 1.6 0 0 0-.3 1.8v.1a1.6 1.6 0 0 0 1.5 1h.2a1.9 1.9 0 1 1 0 3.9h-.1a1.6 1.6 0 0 0-1.5 1z"/>',
|
||||||
|
stats: '<path d="M4 20.5h16"/><rect x="5.5" y="12" width="3.2" height="6.5" rx="1"/><rect x="10.4" y="7" width="3.2" height="11.5" rx="1"/><rect x="15.3" y="3.5" width="3.2" height="15" rx="1"/>',
|
||||||
|
reports: '<path d="M7 3.5h7l5 5v12H7a1.5 1.5 0 0 1-1.5-1.5V5A1.5 1.5 0 0 1 7 3.5z"/><path d="M14 3.5v5h5M9 17v-3M12 17v-5M15 17v-2"/>',
|
||||||
|
logout: '<path d="M14.5 4.2A8.3 8.3 0 1 0 20 12"/><path d="M12 12h9M18 9l3 3-3 3"/>',
|
||||||
|
search: '<circle cx="11" cy="11" r="6.3"/><path d="m20 20-4.3-4.3"/>',
|
||||||
|
calendar: '<rect x="4" y="5.5" width="16" height="14.5" rx="2.5"/><path d="M8 3.5v4M16 3.5v4M4 10h16M9.5 14.5h5"/>',
|
||||||
|
bell: '<path d="M6.3 16.6V11a5.7 5.7 0 0 1 11.4 0v5.6l1.6 1.9H4.7z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>',
|
||||||
|
};
|
||||||
|
const svg = (name) => `<svg viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`;
|
||||||
|
|
||||||
|
// Разделы ленты. Подразделы — прежние вкладки: их разметка и код не менялись.
|
||||||
|
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) + `<span class="tip" aria-hidden="true">${g.label}</span>`;
|
||||||
|
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') + '<span class="tip" aria-hidden="true">Выйти</span>';
|
||||||
|
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 = '<span>' + svg('search') + '</span>';
|
||||||
|
$('#cal-btn').insertAdjacentHTML('afterbegin', svg('calendar'));
|
||||||
|
// сегодняшняя дата рядом с календарём, как в шапке референса
|
||||||
|
const now = new Date();
|
||||||
|
$('#cal-date').innerHTML = `<b>${new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }).format(now).replace(' г.', '')}</b>` +
|
||||||
|
`<small>${new Intl.DateTimeFormat('ru-RU', { weekday: 'long' }).format(now).replace(/^./, (c) => c.toUpperCase())}</small>`;
|
||||||
|
$('#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();
|
||||||
225
internal/api/adminui/stats.js
Normal file
@ -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: '<path d="M7 3.5h7l5 5v12H7a1.5 1.5 0 0 1-1.5-1.5V5A1.5 1.5 0 0 1 7 3.5z"/><path d="M14 3.5v5h5M9.5 13h6M9.5 16.5h6"/>',
|
||||||
|
people: '<circle cx="9" cy="8.5" r="3.2"/><path d="M3 19.5a6 6 0 0 1 12 0M16 5.5a3 3 0 0 1 0 6M18 19.5a5.5 5.5 0 0 0-2.5-4.6"/>',
|
||||||
|
check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
|
||||||
|
repeat: '<path d="M4 12a8 8 0 0 1 13.7-5.6L20 8.7M20 4v4.7h-4.7M20 12a8 8 0 0 1-13.7 5.6L4 15.3M4 20v-4.7h4.7"/>',
|
||||||
|
shield: '<path d="M12 3.5 19 6v5.5c0 4.4-3 7.8-7 9-4-1.2-7-4.6-7-9V6z"/><path d="m9.5 12 2 2 3.5-4"/>',
|
||||||
|
calendar: '<rect x="4" y="5.5" width="16" height="14.5" rx="2.5"/><path d="M8 3.5v4M16 3.5v4M4 10h16"/>',
|
||||||
|
};
|
||||||
|
const stIcon = (n) => `<svg viewBox="0 0 24 24" aria-hidden="true">${ST_ICONS[n]}</svg>`;
|
||||||
|
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 '<span class="kpi__delta up">новое</span>';
|
||||||
|
const d = Math.round(((cur - prev) / prev) * 100);
|
||||||
|
if (!d) return '<span class="kpi__delta">без изменений</span>';
|
||||||
|
return `<span class="kpi__delta ${d > 0 ? 'up' : 'down'}">${d > 0 ? '↑' : '↓'} ${Math.abs(d)}%</span>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
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 = `<span class="kpi__icon" style="background:${bg};color:${ink}">${stIcon(icon)}</span>
|
||||||
|
<div><span class="kpi__label">${label}</span><b class="kpi__value">${value}</b>${delta}<small>${hint}</small></div>`;
|
||||||
|
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 += `<line x1="${L}" x2="${W - R}" y1="${y(v)}" y2="${y(v)}" class="st-grid"/><text x="${L - 8}" y="${y(v) + 4}" text-anchor="end" class="st-axis">${v}</text>`;
|
||||||
|
}
|
||||||
|
const every = Math.ceil(pts.length / 10);
|
||||||
|
const labels = pts.map((p, i) => (i % every === 0 || i === pts.length - 1) ? `<text x="${x(i)}" y="${H - 6}" text-anchor="middle" class="st-axis">${p.label}</text>` : '').join('');
|
||||||
|
const svg = `<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="Заявки ${Stats.group === 'week' ? 'по неделям' : 'по дням'}: всего ${Stats.data.total}">
|
||||||
|
<defs><linearGradient id="st-fill" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#4f8ff0" stop-opacity=".32"/><stop offset="1" stop-color="#4f8ff0" stop-opacity="0"/></linearGradient></defs>
|
||||||
|
${grid}<path d="${area}" fill="url(#st-fill)"/><path d="${line}" class="st-line"/>${labels}
|
||||||
|
<line class="st-guide" y1="${T}" y2="${y(0)}" hidden/><circle class="st-dot" r="5" hidden/></svg>`;
|
||||||
|
box.innerHTML = svg + '<div class="tooltip st-tip" hidden></div>';
|
||||||
|
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 = `<b>${p.v} ${plural(p.v, 'заявка', 'заявки', 'заявок')}</b>${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 += `<circle r="${R}" cx="80" cy="80" fill="none" stroke="${it[2] || ST_COLORS[i % ST_COLORS.length]}" stroke-width="26"
|
||||||
|
stroke-dasharray="${Math.max(len - 2, 0.5)} ${C}" stroke-dashoffset="${-off}" transform="rotate(-90 80 80)"/>`;
|
||||||
|
off += len;
|
||||||
|
});
|
||||||
|
const wrap = el('div', { className: 'st-donut' });
|
||||||
|
wrap.innerHTML = `<svg viewBox="0 0 160 160" role="img" aria-label="${items.map((x) => x[0] + ': ' + x[1]).join(', ')}">${arcs}
|
||||||
|
<text x="80" y="80" text-anchor="middle" class="st-donut__n">${sum}</text><text x="80" y="98" text-anchor="middle" class="st-donut__u">${unit}</text></svg>`;
|
||||||
|
const legend = el('ul', { className: 'st-legend' });
|
||||||
|
items.forEach((it, i) => {
|
||||||
|
const li = el('li');
|
||||||
|
li.innerHTML = `<i style="background:${it[2] || ST_COLORS[i % ST_COLORS.length]}"></i><span></span><em>${pct(it[1], sum)}%</em><b>${it[1]}</b>`;
|
||||||
|
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 = `<span></span><div class="st-bar__track"><i style="width:${Math.max(3, (n / max) * 100)}%"></i></div><em>${pct(n, total)}%</em><b>${n}</b>`;
|
||||||
|
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 = `<thead><tr><th>#</th><th>${what}</th><th>Заявки</th><th>Обработано</th></tr></thead>`;
|
||||||
|
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();
|
||||||
71
internal/api/adminui/weekly-experiment.css
Normal file
@ -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; }
|
||||||
|
}
|
||||||
102
internal/api/adminui/weekly-experiment.js
Normal file
@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
521
internal/api/adminui/weekly.js
Normal file
@ -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: '<path d="M22 16.9v3a2 2 0 0 1-2.2 2A19.8 19.8 0 0 1 3.1 5.2 2 2 0 0 1 5.1 3h3a2 2 0 0 1 2 1.7c.1.9.3 1.7.6 2.5a2 2 0 0 1-.5 2.1L9 10.6a16 16 0 0 0 4.4 4.4l1.3-1.2a2 2 0 0 1 2.1-.5c.8.3 1.6.5 2.5.6a2 2 0 0 1 2 2Z"/>',
|
||||||
|
website: '<rect x="2" y="3" width="20" height="18" rx="2.5"/><path d="M2 8h20M5 5.5h.01M8 5.5h.01M11 5.5h.01"/><circle cx="12" cy="14.5" r="4.5"/><path d="M7.5 14.5h9M12 10c1.5 1.5 2 3 2 4.5s-.5 3-2 4.5c-1.5-1.5-2-3-2-4.5s.5-3 2-4.5Z"/>',
|
||||||
|
internet: '<rect x="3" y="3" width="6" height="6" rx="1.5"/><rect x="15" y="3" width="6" height="6" rx="1.5"/><rect x="3" y="15" width="6" height="6" rx="1.5"/><rect x="15" y="15" width="6" height="6" rx="1.5"/>',
|
||||||
|
banner: '<rect x="2" y="3" width="20" height="14" rx="2.5" style="fill:#fff3dd;stroke:#d9953f"/><path d="M7 17v4m10-4v4M5 21h4m6 0h4" style="stroke:#547994"/><circle cx="16.5" cy="7" r="1.5" style="fill:#f5bf55;stroke:none"/><path d="m4.5 14 4-5 3 3 2-2 6 4Z" style="fill:#8dc9ce;stroke:none"/><path d="M5 6h5" style="stroke:#db9b4a"/>',
|
||||||
|
tv: '<rect x="3" y="6" width="18" height="14" rx="2"/><path d="m8 2 4 4 4-4"/>',
|
||||||
|
route: '<rect x="5" y="3" width="14" height="16" rx="3"/><path d="M5 11h14M8 19v2m8-2v2M8 15h.01M16 15h.01"/>',
|
||||||
|
radio: '<rect x="3" y="8" width="18" height="12" rx="2"/><path d="m5 8 14-5M15 12h3m-3 4h3"/><circle cx="8" cy="14" r="2"/>',
|
||||||
|
print: '<path d="M6 8V3h12v5M6 17H3v-7h18v7h-3"/><path d="M6 14h12v7H6zM17 11h.01"/>',
|
||||||
|
video: '<rect x="3" y="4" width="18" height="16" rx="3"/><path d="m10 8 6 4-6 4Z"/>',
|
||||||
|
chat: '<path d="M21 11.5a9 9 0 0 1-13.4 7.9L3 21l1.6-4.6A9 9 0 1 1 21 11.5Z"/><path d="M8 7.5c-.8 2.6 2 5.4 4.6 6.2l1.5-1.4 2 .9c.2 1.2-.8 2.2-2 2.2-3.5-.2-7.1-3.8-7.3-7.3 0-1.2 1-2.2 2.2-2l.9 2Z"/>',
|
||||||
|
generic: '<path d="M4 11V5h6l10 10-5 5L4 11Z"/><circle cx="8" cy="8" r="1"/>',
|
||||||
|
};
|
||||||
|
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 = `<svg viewBox="0 0 24 24" aria-hidden="true">${utilityMarks[name]}</svg>`;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
};
|
||||||
327
internal/api/api.go
Normal file
@ -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: запрет плагинов, чужого <base>,
|
||||||
|
// отправки форм на сторонние адреса и встраивания сайта в чужие фреймы.
|
||||||
|
// Полный 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)
|
||||||
|
})
|
||||||
|
}
|
||||||