Compare commits

...

42 Commits

Author SHA1 Message Date
root 319761a24c merge: server fixes + suggestions voting 2026-07-16 12:42:31 +00:00
root 0f38ce0095 fix: normalizeDateForInput парсит dd.MM.yyyy + customerDate fallback для логистов 2026-07-14 18:52:02 +00:00
root 3b93e755fb fix: логисты могут ставить Доставлено без части дня + дублирующий индекс удалён 2026-07-14 18:48:03 +00:00
root 48e238e634 fix: логисты могут ставить прошедшие даты + dedup триггер 2026-07-14 18:00:35 +00:00
root 052e5e7e86 fix: calendar overlap + allow past dates for logistician
1. CalendarWidget: add relative to calendar container so absolute popup
   positions correctly (no more overlapping with other elements)
2. OrderDetailPanel: logistician/admin/manager can select past dates
   (3 months back) for retroactive delivery closing
3. canGoBack always true for logistician — can navigate to past months
4. Existing past delivery dates are preserved (not reset to tomorrow)
2026-07-13 14:42:36 +00:00
root c86e0e1ea6 fix: SMS labels — clarify delivered means SMS delivered to phone, not goods
1-е SMS: «✓ доставлено» → «✓ получено клиентом»
2-е SMS: added «✓ получено клиентом»
«ждём подтверждения» → «ждём ответ оператора»
2026-07-13 14:32:19 +00:00
root 5818b94bbe fix: remove duplicate status displays
1. Remove delivery status from info grid (already shown as Badge in header)
2. Remove SMS notification Badge from SmsStatusCard header (timeline shows it)
3. Remove inline hint on blocked buttons — keep only warning banner below
2026-07-13 14:21:57 +00:00
root 9cf7cfbdf4 fix: unique Traefik middleware name for HTTP->HTTPS redirect
Previous redirect-to-https middleware name conflicted with other
containers (n8n, gitea), causing HTTP router to fail with 404 instead
of redirecting to HTTPS. Renamed to supersam-redirect.
2026-07-13 13:53:20 +00:00
root b2dc1a3310 fix: restore filter persistence in LogisticsReadinessBoard
Filters (query, displayStatus, city, deliveryType, dateFrom/To) now
persist to localStorage and restore on page reload.
2026-07-13 13:38:46 +00:00
root 755385cf0e fix: StatusActionPanel smart hints + driver name lookup + logistician can edit shipment + completed label
1. StatusActionPanel: all buttons clickable (no more manual:false blocking)
   - Click on current status shows actual state: "Водитель уже назначен: Иван"
   - "Назначен водитель" clickable even if already assigned
   - "Согласовано" clickable even if already agreed
2. Status indicators: green badges ✓ Дата согласована / ✓ Водитель: имя
   + warning badges if missing
3. DriverAssignmentPanel: look up driver name from drivers list if
   assignedDriverName is empty (Supabase join failed)
4. DriverShipmentPanel: now visible to logistician/admin too — can edit
   shipment data (fix wrong driver checkboxes)
5. SmsStatusCard: add completed label ("Завершено")
2026-07-13 13:16:48 +00:00
root 17f93a65ae fix: logistics UI — problem badge overlap, driver name, SMS labels, shipment report
1. LogisticsReadinessBoard: problem badge on separate line (no overlap)
2. DriverAssignmentPanel: show "Водитель назначен" instead of "Неизвестно"
   when driver name not resolved from join
3. SmsStatusCard: "confirmed" label = "Клиент согласовал дату" (not "Согласовано")
4. StatusActionPanel: when currentStatus=problem, all status buttons clickable
   (logistician can resolve problem by setting any status)
5. DriverShipmentReport: show ALL items — green check for delivered,
   red cross for not delivered, with reason comments
2026-07-13 13:07:03 +00:00
root 1ffd8bacc5 fix: block Доставлено/Вывезено without delivery date+half-day, remove Загружено from logistic panel
- StatusActionPanel: removed Загружено button
- StatusActionPanel: Доставлено/Вывезено require deliveryDate + half-day
- StatusActionPanel: warning banner when schedule missing
- Driver status panel in OrderDetailPanel: same schedule check
- Blocked buttons shown dimmed (opacity-40)
2026-07-13 12:02:25 +00:00
root fbfcb71d61 fix: driver delivery UX — remove Загружено status, fix checkbox reset, fix confirmed label
1. Remove "Загружено" status from driver workflow — driver goes directly
   Назначен водитель → Доставлено (or Проблема)
2. Driver can undo "Доставлено" → "Вернуть в работу" (back to Назначен водитель)
3. Fix checkbox reset: shippedItems only resets when order ID changes,
   not on every order data refetch — driver can toggle items freely
4. Fix "confirmed" showing in SMS status card — add Russian label
5. onResetStatus resets to driver_assigned, not loaded
6. ShipmentPanel "Сбросить" works for all active statuses
2026-07-13 11:51:56 +00:00
root 1766ccb897 fix: driver planner uses fs-zone-table not fs-zone-card
User changes "Таблицы" slider in Settings → expects table text to scale.
DriverDeliveryPlanner was wrapped in fs-zone-card (responds to "Карточки"),
not fs-zone-table (responds to "Таблицы").
2026-07-13 11:35:52 +00:00
root edb6784c16 fix: remove duplicate fmtTime declaration in OrderDetailPanel 2026-07-13 11:26:31 +00:00
root 0821bcc747 merge: dev → main — driver table layout, filters, status reset, SMS campaigns, font zones
Merges dev branch into main:
- Driver delivery list: table layout (№, Client, Phone, City, Half-day)
- Driver filters: search, half-day filter, problem sorting
- DriverShipmentPanel: reset status with confirmation
- SmsCampaignPanel: campaigns refactor
- LogisticsReadinessBoard: unified table view
- Font zone classes (fs-zone-card) on all driver components
- SMS campaign scripts updates
2026-07-13 11:24:56 +00:00
root 6c7d83c2be fix: add fs-zone-card to driver components so font size settings apply
Driver components (DriverDeliveryPlanner, DriverShipmentPanel,
DriverDeliveryDetail) were missing fs-zone-card class wrappers.
Font size settings from SettingsPage saved to localStorage correctly
but never applied to driver UI because fontSettings.css only
overrides Tailwind text-* classes inside .fs-zone-* wrappers.
2026-07-13 06:58:49 +00:00
root e7365b6c0c fix(analytics): remove 1С import category, bypassed_completed covers all no-date completions 2026-07-06 15:00:41 +00:00
root 14a3006918 fix(analytics): funnel shows real order path with bypass detection 2026-07-06 13:47:02 +00:00
root a695a73af7 fix(analytics): rewrite funnel as real order path
- SQL admin_automation_economics: new columns based on actual order lifecycle
  - sms_sent: got at least 1 SMS
  - completed_auto: delivered/picked_up without manual_confirmation_at
  - completed_manual: delivered/picked_up with manual_confirmation_at
  - stuck_in_manual: manual_required + still waiting
  - no_sms_completed: imported as already delivered
- Funnel steps: Всего → SMS → Авто-завершено → Завершено вручную → Застряло → Без SMS → Хранение → Отменено
- Footer: Автоматизация % / Ручное вмешательство % / Завершено всего
- Fixes: old funnel counted manual_confirmation_required as "согласовано"
  and missed 31 completed orders (notification_status=completed vs confirmed)
2026-07-06 13:16:44 +00:00
root b0bc056090 fix(analytics): separate Вывезено (status) from Самовывоз (type) in KPI
- KPI: Вывезено = picked_up (all types), Самовывоз = pickup type only
- SQL admin_delivery_stats: + picked_up_delivery, picked_up_pickup columns
- Fixes confusion: 17 picked_up vs 5 pickup type were different metrics
2026-07-06 10:20:55 +00:00
root eba1769078 redesign(analytics): dashboard desktop layout + pickup period label
- 12-col responsive grid: pie 4/8 trend, table 4/8 drivers
- KPI cards: 7 metrics with uppercase labels, larger values
- Period label in every section subtitle (за 7 дней, etc)
- PickupStatsPanel: period label, status breakdown (завершено/ожидает/ручное)
- PickupStatsPanel: hide empty schedule bars when no planned pickups
- SQL admin_pickup_stats: + picked_up, pending, manual columns
- Colors: delivered #22c55e, picked_up #14b8a6 (distinct)
- Desktop: 3-col grid inside pickup panel
2026-07-06 10:09:11 +00:00
root 1b9fea7f1d fix(analytics): picked_up as separate status in dashboard
- KPI: separate Доставлено and Вывезено cards
- Trend: separate line for Вывезено (#14b8a6)
- Driver chart: separate bar for Вывезено
- SQL: admin_delivery_stats returns picked_up column
- SQL: admin_daily_trend returns picked_up column
- SQL: admin_driver_stats returns picked_up column
- delivery_rate = (delivered + picked_up) / total
- Colors: delivered #22c55e, picked_up #14b8a6 (distinct)
2026-07-06 06:42:31 +00:00
root 6786779f2c fix(analytics): recognize picked_up status in dashboard and stats
- Add picked_up to STATUS_COLORS and STATUS_LABELS in AdminDashboard
- SQL: admin_delivery_stats counts picked_up as delivered
- SQL: admin_daily_trend counts picked_up as delivered
- SQL: admin_driver_stats counts picked_up as delivered
- Fixes: picked_up showed as Unknown, delivered count was 17 short
2026-07-06 06:17:32 +00:00
root eabf30bceb fix: stop SMS UI for terminal delivery statuses (picked_up/delivered/cancelled)
- SmsStatusCard: show "SMS не отправляется" when delivery is terminal, hide countdown + restart buttons
- SmsCampaignPanel fmtAgreement: check terminal status FIRST → "✅ Самовывоз завершён" instead of "Самовывоз (неполно)"
- update_delivery_status RPC: reset notification_status→completed on terminal transition
- Add completed to notification_status check constraint
- SW v60→v61
2026-07-04 11:27:03 +00:00
root 43d9116c51 fix: restore localStorage filter persistence in LogisticsReadinessBoard, SW v60 2026-07-03 15:04:05 +00:00
root 468c3c4097 fix: sessionStorage→localStorage for filter persistence, SW v59 2026-07-03 14:14:00 +00:00
root 1d288279c0 fix: extractCity word boundary + filter persistence in sessionStorage 2026-07-03 14:07:13 +00:00
root 8ade6a6ff3 fix: fallback fresh slots when all availableSlots passed + SW v57 2026-07-03 13:37:42 +00:00
root a74a5242b8 fix: use current date for slot availability + SW v56 2026-07-03 13:32:24 +00:00
root e93215f837 feat: SMS filter no_sms in queue + SW v55 2026-07-03 13:26:13 +00:00
root 6a34f36621 SW v54 — business schedule 2026-07-03 12:45:26 +00:00
root d397d0cb24 feat: configurable business schedule (delivery/pickup/SMS days)
- New table business_schedule_settings (delivery_days, pickup_days, sms_days)
- RPC get_business_schedule() (public) + update_business_schedule() (admin)
- Admin UI: BusinessSchedulePanel with day pills for each category
- ClientDeliveryPage: fetches schedule, replaces hardcoded Sunday/weekend checks
- PickupSlotsPicker: uses pickupDays prop instead of hardcoded isWeekend
- Edge function confirm-delivery-choice: reads delivery_days from DB
- delivery-invitations.ts: buildDefaultDatedAvailableSlots reads delivery_days
- RPC confirm_delivery_choice_by_token: checks delivery_days instead of extract(dow)=0
- SMS scripts: get_sms_days() reads sms_days from business_schedule_settings
  and overrides per-campaign work_days setting
- Nav item Расписание for mega_admin and admin roles
- Default: Mon-Fri (1,2,3,4,5) for all three categories
2026-07-03 12:45:02 +00:00
root d4d85bc767 fix: normalize_phone handles +7, 8, text, and multi-number fields
- Two numbers in one field (XXX / YYY) → take first 10 digits
- Text in field (Екатерина +7978...) → extract digits only
- 8 prefix → 7 (Russian landline style)
- 10 digits (no country code) → prepend 7
- All 4 SMS scripts patched (first, second, paid_storage)
2026-07-03 12:33:53 +00:00
root e55a4cafcc fix: manual_campaign EXISTS check — no manual_required without SMS sent
Scenario 3 (link_ready + next_check expired) now requires EXISTS
in sms_campaign_log. Prevents groups from being moved to
manual_required before first SMS is ever sent.
2026-07-03 12:18:28 +00:00
root db57624626 merge: resolve conflicts (our SmsCampaignPanel + SW v43) 2026-07-01 16:20:12 +00:00
root b78bdc48bb feat: drag-n-drop status sections + localStorage filters/collapsed + pickup date in logistics board
- Drag-n-drop reordering of status sections (desktop + touch)
- Section order saved in localStorage (logistics-section-order)
- Collapsed/expanded state saved in localStorage (logistics-collapsed)
- Filters (city, status, search) saved in localStorage (logistics-filters)
- Pickup date shown in delivery date column for self-pickup orders
- SMS campaign scripts (manual, second, paid_storage, timer manager)
- SmsCampaignStats + SmsStatusCard components
- DashboardPage: unified LogisticsReadinessBoard for all roles
- OrdersTable cleanup, StatusActionPanel updates
- SW v42
2026-07-01 16:16:22 +00:00
root 581239cc73 fix: unify table styles — no rounded rows, wider type col, horizontal scroll, pagination by filtered groups 2026-06-30 11:43:15 +00:00
root c0c549adca feat: show delivery type (pickup/delivery) in logistics board + driver planner 2026-06-30 09:04:27 +00:00
root 858d36e5b0 feat: manual SMS campaign shows groups in manual mode + pagination on all delivery pages 2026-06-30 07:30:12 +00:00
root 93d3ce4ca8 fix: manage-users — remove manual public.users insert, trigger handle_new_user does it via user_metadata 2026-06-30 07:04:24 +00:00
root cadcceef7e feat: manage-users edge function — admin user CRUD (create/delete) 2026-06-30 06:55:55 +00:00
37 changed files with 2911 additions and 419 deletions

3
.gitignore vendored
View File

@ -8,3 +8,6 @@ dist
.superpowers .superpowers
.ruff_cache .ruff_cache
volumes/db/data/ volumes/db/data/
__pycache__/
*.pyc
*.bak

View File

@ -18,15 +18,15 @@ services:
- traefik.http.routers.supersam-app.tls.certresolver=letsencrypt - traefik.http.routers.supersam-app.tls.certresolver=letsencrypt
- traefik.http.routers.supersam-app.service=supersam-app - traefik.http.routers.supersam-app.service=supersam-app
- traefik.http.services.supersam-app.loadbalancer.server.port=80 - traefik.http.services.supersam-app.loadbalancer.server.port=80
- traefik.http.routers.supersam-app-http.rule=Host(`dost.supersamsev.ru`)
- traefik.http.routers.supersam-app-http.entryPoints=http
- traefik.http.routers.supersam-app-http.middlewares=redirect-to-https
- traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https
- traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Content-Type-Options=nosniff - traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Content-Type-Options=nosniff
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Frame-Options=DENY - traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Frame-Options=DENY
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.Referrer-Policy=strict-origin-when-cross-origin - traefik.http.middlewares.supersam-sec.headers.customresponseheaders.Referrer-Policy=strict-origin-when-cross-origin
- traefik.http.routers.supersam-app.middlewares=supersam-sec - traefik.http.routers.supersam-app.middlewares=supersam-sec
- traefik.http.routers.supersam-app-http.rule=Host(`dost.supersamsev.ru`)
- traefik.http.routers.supersam-app-http.entryPoints=http
- traefik.http.routers.supersam-app-http.middlewares=supersam-redirect
- traefik.http.middlewares.supersam-redirect.redirectscheme.scheme=https
- traefik.http.middlewares.supersam-redirect.redirectscheme.permanent=true
networks: networks:
coolify: coolify:

65
scripts/backup-pgdump-s3.sh Executable file
View File

@ -0,0 +1,65 @@
#!/bin/bash
# Supersam full DB backup (pg_dump) to S3 (Beget Cloud Storage)
# Dumps the complete supabase postgres database and uploads to S3.
# Retention: keeps last 30 days.
#
# Cron: 0 */12 * * * /opt/supersam/scripts/backup-pgdump-s3.sh >> /var/log/supersam-backup-pgdump.log 2>&1
set -euo pipefail
# ── Config ────────────────────────────────────────────────────────────────
AWS_ACCESS_KEY_ID="YG4MQNKAPNL65200MBUY"
AWS_SECRET_ACCESS_KEY="8mXkFM2VRQ3pN1Nx4mhmJ2jrZoB5YTPUa4CaZh43"
S3_ENDPOINT="https://s3.ru1.storage.beget.cloud"
S3_BUCKET="02f162ff4a18-supersam-s3"
S3_PREFIX="backups"
DB_CONTAINER="supabase-db"
DB_USER="supabase_admin"
DB_NAME="postgres"
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
RETENTION_DAYS=30
# ── Runtime ───────────────────────────────────────────────────────────────
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
DUMP_FILE="/tmp/supersam-dump-${TIMESTAMP}.sql.gz"
S3_PATH="s3://${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz"
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
echo "[$(date)] Starting pg_dump backup..."
# ── Dump ──────────────────────────────────────────────────────────────────
echo "[$(date)] Dumping database..."
docker exec -e PGPASSWORD="${DB_PASS}" "${DB_CONTAINER}" \
pg_dump -U "${DB_USER}" -d "${DB_NAME}" --no-owner --no-acl --clean --if-exists \
| gzip -9 > "${DUMP_FILE}"
DUMP_SIZE=$(du -h "${DUMP_FILE}" | cut -f1)
echo "[$(date)] Dump created: ${DUMP_FILE} (${DUMP_SIZE})"
# ── Upload via mc ─────────────────────────────────────────────────────────
echo "[$(date)] Uploading to S3..."
mc cp "${DUMP_FILE}" "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz" --quiet 2>&1
echo "[$(date)] Uploaded to ${S3_PATH}"
# ── Cleanup local ─────────────────────────────────────────────────────────
rm -f "${DUMP_FILE}"
echo "[$(date)] Local cleanup done"
# ── Retention: delete S3 pgdump backups older than RETENTION_DAYS ─────────
echo "[$(date)] Cleaning S3 pgdump backups older than ${RETENTION_DAYS} days..."
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/" 2>/dev/null \
| awk '{print $NF}' \
| while read -r fname; do
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
if [ -n "$file_date" ] && [ "$file_date" \< "$CUTOFF" ]; then
mc rm "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/${fname}" --quiet 2>/dev/null
echo "[$(date)] Deleted old backup: ${fname}"
fi
done
echo "[$(date)] Backup complete."

13
scripts/sms_run_with_db.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/bash
# Resolve supabase-db container IP dynamically
DB_IP=$(docker inspect supabase-db --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null)
if [ -z "$DB_IP" ]; then
echo "ERROR: Cannot resolve supabase-db IP" >&2
exit 1
fi
export DB_HOST=$DB_IP
export DB_PORT=5432
export DB_NAME=postgres
export DB_USER=supabase_admin
export DB_PASS=4fe80bb21c7c3d17a8d8b226adf7a479
exec python3 "$@"

84
scripts/sms_timer_manager.sh Executable file
View File

@ -0,0 +1,84 @@
#!/bin/bash
# sms_timer_manager.sh — reconciles DB flags with systemd timers
# Runs every 1 min via cron:
# 1. timer_active → enable/disable systemd timer
# 2. run_requested → run campaign script immediately (script resets flag itself)
set -euo pipefail
DB_HOST="10.0.4.12"
DB_PORT="5432"
DB_NAME="postgres"
DB_USER="supabase_admin"
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
declare -A TIMERS=(
["first_sms"]="sms-first-campaign.timer"
["second_sms"]="sms-second-campaign.timer"
["manual"]="sms-manual-campaign.timer"
["paid_storage"]="sms-paid-storage-campaign.timer"
)
declare -A SERVICES=(
["first_sms"]="sms-first-campaign.service"
["second_sms"]="sms-second-campaign.service"
["manual"]="sms-manual-campaign.service"
["paid_storage"]="sms-paid-storage-campaign.service"
)
declare -A SCRIPTS=(
["first_sms"]="/opt/supersam/scripts/sms_first_campaign.py"
["second_sms"]="/opt/supersam/scripts/sms_second_campaign.py"
["manual"]="/opt/supersam/scripts/sms_manual_campaign.py"
["paid_storage"]="/opt/supersam/scripts/sms_paid_storage_campaign.py"
)
QUERY="SELECT campaign_type, timer_active, run_requested FROM sms_campaign_settings"
RESULTS=$(PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -A -F '|' -c "$QUERY" 2>/dev/null || echo "")
if [ -z "$RESULTS" ]; then
echo "$(date): Failed to query DB, skipping"
exit 0
fi
while IFS='|' read -r campaign_type timer_active run_requested; do
[ -z "$campaign_type" ] && continue
timer_name="${TIMERS[$campaign_type]:-}"
service_name="${SERVICES[$campaign_type]:-}"
script_path="${SCRIPTS[$campaign_type]:-}"
[ -z "$timer_name" ] && continue
if ! systemctl list-unit-files "$timer_name" 2>/dev/null | grep -q "$timer_name"; then
continue
fi
# ── 1. Timer enable/disable ──
is_active=$(systemctl is-active "$timer_name" 2>/dev/null || echo "inactive")
if [ "$timer_active" = "t" ] || [ "$timer_active" = "true" ]; then
if [ "$is_active" != "active" ]; then
systemctl enable --now "$timer_name" 2>/dev/null
echo "$(date): ENABLED $timer_name for $campaign_type"
fi
else
if [ "$is_active" = "active" ]; then
systemctl disable --now "$timer_name" 2>/dev/null
echo "$(date): DISABLED $timer_name for $campaign_type"
fi
fi
# ── 2. Run requested → immediate execution ──
# Don't reset flag here — script reads it from DB and resets after processing
if [ "$run_requested" = "t" ] || [ "$run_requested" = "true" ]; then
is_running=$(systemctl is-active "$service_name" 2>/dev/null || echo "inactive")
if [ "$is_running" = "active" ]; then
echo "$(date): $campaign_type already running, skipping run_requested"
continue
fi
echo "$(date): RUN REQUESTED — starting $campaign_type immediately"
systemctl start "$service_name" 2>/dev/null || {
nohup python3 "$script_path" >> "/var/log/supersam-sms-${campaign_type}.log" 2>&1 &
echo "$(date): Started $campaign_type via nohup fallback"
}
fi
done <<< "$RESULTS"

216
src/AppShell.jsx Normal file
View File

@ -0,0 +1,216 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { ROLE_LABELS } from "../constants/roles";
import { Badge } from "../components/UI/Badge";
import { Button } from "../components/UI/Button";
import { Panel } from "../components/UI/Panel";
import { ThemeToggle } from "../components/UI/ThemeToggle";
import { PwaInstallButton } from "../components/UI/PwaInstallButton";
import { NotificationBell } from "../components/notifications/NotificationBell";
import { NotificationSettings } from "../components/notifications/NotificationSettings";
export const AppShell = ({
user,
onInstallApp,
isInstalled,
isInstallAvailable,
onSignOut,
onOpenGuide,
isGuideOpen = false,
navItems,
activeSection,
onSectionChange,
sectionMeta,
notifications = [],
unreadCount = 0,
onMarkNotificationRead,
onMarkAllNotificationsRead,
children,
}) => {
const shouldShowMobileNav = !isGuideOpen && navItems.length > 1;
const [showNotifSettings, setShowNotifSettings] = React.useState(false);
if (showNotifSettings) {
return (
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
<div className="mx-auto max-w-2xl">
<NotificationSettings
userId={user?.id}
userRole={user?.role}
onBack={() => setShowNotifSettings(false)}
/>
</div>
</div>
);
}
return (
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
<div className="mx-auto max-w-[1540px] space-y-4 xl:grid xl:grid-cols-[220px_1fr] xl:gap-8 xl:space-y-0">
{/* Desktop sidebar */}
<Panel className="fs-zone-nav hidden h-fit flex-col gap-5 p-4 xl:flex">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
Панель
</p>
<h1 className="mt-2 text-lg font-semibold leading-tight">Управление доставкой</h1>
</div>
<div className="space-y-1">
{navItems.map((item) => (
<Button
key={item.key}
variant="ghost"
className={[
"flex w-full items-center justify-between rounded-[18px] px-3 py-3 text-left text-sm transition",
activeSection === item.key
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]"
: "text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]",
].join(" ")}
onClick={() => onSectionChange(item.key)}
type="button"
>
<span className="font-medium">{item.label}</span>
{item.badge ? <Badge tone="accent">{item.badge}</Badge> : null}
</Button>
))}
</div>
<div className="mt-auto">
{onOpenGuide ? (
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={onOpenGuide}>
{isGuideOpen ? "К рабочей области" : "Справка"}
</Button>
) : null}
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={() => navigate("/settings")}>
Настройки
</Button>
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={() => window.open("https://forms.gle/feedback-supersam", "_blank")}>
💡 Предложить улучшение
</Button>
<Button variant="ghost" className="w-full justify-start" onClick={onSignOut}>
Выйти
</Button>
</div>
</Panel>
{/* Main content area */}
<div className="min-w-0 space-y-5 pb-20 xl:space-y-8 xl:pb-0 fs-zone-body">
{/* Mobile header */}
<Panel className="p-4 xl:hidden fs-zone-heading">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0 flex-1 space-y-1">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Рабочая область
</p>
<h2 className="text-lg font-semibold leading-tight sm:text-xl md:text-2xl">
{sectionMeta?.label || "Панель"}
</h2>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
{user.name} · {ROLE_LABELS[user.role] || user.role}
</p>
</div>
<div className="flex items-center gap-1 md:flex-shrink-0">
<NotificationBell
notifications={notifications}
unreadCount={unreadCount}
onMarkAsRead={onMarkNotificationRead}
onMarkAllAsRead={onMarkAllNotificationsRead}
onOpenSettings={() => setShowNotifSettings(true)}
/>
{onOpenGuide ? (
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
?
</Button>
) : null}
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
⚙
</Button>
<Button size="sm" variant="ghost" onClick={() => window.open("https://forms.gle/feedback-supersam", "_blank")} aria-label="Предложить улучшение">
💡
</Button>
<ThemeToggle />
<Button size="sm" variant="ghost" onClick={onSignOut}>
Выйти
</Button>
</div>
</div>
</Panel>
{/* Mobile tab navigation — STICKY TOP */}
{shouldShowMobileNav && (
<div className="sticky inset-x-0 top-0 z-40 -mx-3 -mt-4 border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 backdrop-blur xl:hidden sm:-mx-4 md:-mx-6 fs-zone-nav">
<div className="flex gap-1 overflow-x-auto" style={{ WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none' }}>
{navItems.map((item) => (
<Button
key={item.key}
variant="ghost"
className={[
"flex flex-shrink-0 items-center gap-1.5 rounded-[14px] px-3 py-2 text-sm transition",
activeSection === item.key
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
].join(" ")}
onClick={() => onSectionChange(item.key)}
type="button"
>
<span className="truncate font-medium">{item.label}</span>
{item.badge ? (
<Badge tone={activeSection === item.key ? "neutral" : "accent"}>{item.badge}</Badge>
) : null}
</Button>
))}
</div>
</div>
)}
{/* Desktop header */}
<Panel className="hidden p-4 md:p-5 xl:block fs-zone-heading">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Рабочая область
</p>
<h2 className="mt-2 text-2xl font-semibold">{sectionMeta?.label || "Панель"}</h2>
{sectionMeta?.description ? (
<p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--color-text-muted)]">
{sectionMeta.description}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-3">
<NotificationBell
notifications={notifications}
unreadCount={unreadCount}
onMarkAsRead={onMarkNotificationRead}
onMarkAllAsRead={onMarkAllNotificationsRead}
onOpenSettings={() => setShowNotifSettings(true)}
/>
<div className="text-right">
<div className="text-sm font-medium">{user.name}</div>
<div className="text-sm text-[var(--color-text-muted)]">{ROLE_LABELS[user.role] || user.role}</div>
</div>
{onOpenGuide ? (
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
{isGuideOpen ? "Назад" : "?"}
</Button>
) : null}
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
⚙
</Button>
<Button size="sm" variant="ghost" onClick={() => window.open("https://forms.gle/feedback-supersam", "_blank")} aria-label="Предложить улучшение">
💡
</Button>
<ThemeToggle />
</div>
</div>
</Panel>
{children}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,73 @@
import React, { createContext, useContext, useEffect, useState } from "react";
const FontSettingsContext = createContext(null);
const STORAGE_KEY = "supersam-font-settings";
// Categories: key → { label, defaultScale, min, max, step, description }
export const FONT_CATEGORIES = [
{ key: "table", label: "Таблицы", description: "Текст в таблицах и списках", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
{ key: "card", label: "Карточки", description: "Текст в карточках и панелях", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
{ key: "nav", label: "Меню и навигация", description: "Пункты меню, вкладки, кнопки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
{ key: "heading", label: "Заголовки", description: "Названия секций и страниц", defaultScale: 1.0, min: 0.8, max: 1.6, step: 0.05 },
{ key: "body", label: "Основной текст", description: "Обычный текст в интерфейсе", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
{ key: "small", label: "Мелкий текст", description: "Подписи, метки, временные отметки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
];
const DEFAULT_SETTINGS = FONT_CATEGORIES.reduce((acc, cat) => {
acc[cat.key] = cat.defaultScale;
return acc;
}, {});
function loadSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_SETTINGS };
const parsed = JSON.parse(raw);
// Merge with defaults to handle new categories
return { ...DEFAULT_SETTINGS, ...parsed };
} catch {
return { ...DEFAULT_SETTINGS };
}
}
function applySettings(settings) {
const root = document.documentElement;
for (const cat of FONT_CATEGORIES) {
root.style.setProperty(`--fs-scale-${cat.key}`, String(settings[cat.key] ?? 1.0));
}
}
export const FontSettingsProvider = ({ children }) => {
const [settings, setSettings] = useState(loadSettings);
useEffect(() => {
applySettings(settings);
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}, [settings]);
const updateCategory = (key, scale) => {
setSettings((prev) => ({ ...prev, [key]: scale }));
};
const resetAll = () => {
setSettings({ ...DEFAULT_SETTINGS });
};
const value = {
settings,
updateCategory,
resetAll,
categories: FONT_CATEGORIES,
};
return <FontSettingsContext.Provider value={value}>{children}</FontSettingsContext.Provider>;
};
export const useFontSettings = () => {
const context = useContext(FontSettingsContext);
if (!context) {
throw new Error("useFontSettings must be used within FontSettingsProvider");
}
return context;
};

View File

@ -0,0 +1,389 @@
import React from "react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
filterOrderGroups,
getOrderGroupDisplayStatusLabel,
getOrderGroupDisplayStatusValue,
getOrderGroupStatusTone,
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
} from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel";
import { SkeletonPage } from "../UI/Loading";
import { OrderFilters } from "../orders/OrderFilters";
import { formatDate, formatDateTime } from "../../utils/formatters";
const fmtDate = (d) => {
if (!d) return "";
const [y, m, day] = d.split("-");
if (!y || !m || !day) return d;
return `${day}.${m}.${y}`;
};
// Default priority: agreed first, manual_required second, then funnel
const DEFAULT_FUNNEL_ORDER = [
"delivery:agreed",
"status:manual_required",
"status:ready_for_notification",
"delivery:pending_confirmation",
"status:first_sms_sent",
"status:second_sms_sent",
"delivery:driver_assigned",
"delivery:loaded",
"delivery:on_route",
"delivery:delivered",
"delivery:picked_up",
"delivery:paid_storage",
"delivery:problem",
"delivery:cancelled",
];
const STORAGE_KEY = "logistics-section-order";
const COLLAPSED_KEY = "logistics-section-collapsed";
// Load custom order from localStorage, merge with defaults
const loadCustomOrder = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
};
const saveCustomOrder = (order) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(order));
} catch {
// ignore
}
};
// Load collapsed sections from localStorage
const loadCollapsedSections = () => {
try {
const raw = localStorage.getItem(COLLAPSED_KEY);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
return new Set(Array.isArray(parsed) ? parsed : []);
} catch {
return new Set();
}
};
const saveCollapsedSections = (collapsedSet) => {
try {
localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...collapsedSet]));
} catch {
// ignore
}
};
// 7 columns: Клиент | Город | Тип | Дата | Водитель | Статус | Обновлён
const COLS = "grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(90px,1fr)_minmax(100px,1fr)_minmax(90px,0.8fr)]";
const MIN_W = "min-w-[1080px]";
const TableHeader = () => (
<div className={`grid ${COLS} gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]`}>
<div className="px-3 py-1.5 font-medium">Клиент</div>
<div className="px-3 py-1.5 font-medium">Город</div>
<div className="px-3 py-1.5 font-medium">Тип</div>
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
<div className="px-3 py-1.5 font-medium">Водитель</div>
<div className="px-3 py-1.5 font-medium">Статус</div>
<div className="px-3 py-1.5 font-medium">Обновлён</div>
</div>
);
const renderRow = (group, onSelectSet) => (
<button
key={group.id}
type="button"
className={`grid ${COLS} gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)]`}
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
>
<div className="min-w-0 px-3 py-1.5">
<div className="text-xs font-medium leading-snug break-words" style={{ display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
{group.displayTitle || group.customerName || group.groupKey}
</div>
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
{group.customerPhone || ""}
</div>
</div>
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
{group.city || group.customerAddress || "—"}
</div>
<div className="px-3 py-1.5 text-xs">
<span className="inline-flex items-center gap-1 whitespace-nowrap">
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
</span>
</div>
<div className="px-3 py-1.5 text-xs">
{group.deliveryDate ? (
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
) : (
<span className="text-[var(--color-text-muted)]">—</span>
)}
</div>
<div className="px-3 py-1.5 text-xs">
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
</div>
<div className="px-3 py-1.5">
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
</div>
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
{formatDateTime(group.updatedAt)}
</div>
</button>
);
// Sortable section wrapper
const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: statusValue });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden"
>
{/* Section header — drag handle + collapse toggle */}
<div className="flex w-full items-center justify-between">
{/* Drag handle */}
<button
type="button"
className="flex items-center px-3 py-3 cursor-grab active:cursor-grabbing text-[var(--color-text)] hover:bg-[var(--color-accent-soft)] rounded-l-[28px] touch-none"
title="Перетащите для изменения порядка"
{...attributes}
{...listeners}
>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" style={{ opacity: 0.6 }}>
<circle cx="9" cy="5" r="1.8" />
<circle cx="15" cy="5" r="1.8" />
<circle cx="9" cy="12" r="1.8" />
<circle cx="15" cy="12" r="1.8" />
<circle cx="9" cy="19" r="1.8" />
<circle cx="15" cy="19" r="1.8" />
</svg>
</button>
{/* Collapse toggle */}
<button
type="button"
className="flex flex-1 items-center justify-between py-3 pr-5 text-left transition hover:bg-[var(--color-surface-strong)]"
onClick={onToggle}
>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">{label}</h3>
<Badge tone={groups.length > 0 ? "neutral" : "muted"}>{groups.length}</Badge>
</div>
<svg
className="h-4 w-4 text-[var(--color-text-muted)] transition-transform"
style={{ transform: isCollapsed ? "rotate(-90deg)" : "rotate(0deg)" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{!isCollapsed && (
<div className="overflow-x-auto">
<div className={MIN_W}>
<TableHeader />
{groups.map((g) => renderRow(g, onSelectSet))}
</div>
</div>
)}
</div>
);
};
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
const [sectionOrder, setSectionOrder] = React.useState(() => {
const custom = loadCustomOrder();
return custom || [...DEFAULT_FUNNEL_ORDER];
});
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const cities = React.useMemo(() => {
const set = new Set();
for (const g of orderGroups) {
if (g.city) set.add(g.city);
}
return [...set].sort();
}, [orderGroups]);
const filteredGroups = React.useMemo(
() => filterOrderGroups(orderGroups, filters),
[filters, orderGroups],
);
const statusGroups = React.useMemo(() => {
const map = new Map();
for (const group of filteredGroups) {
const statusValue = getOrderGroupDisplayStatusValue(group);
if (!map.has(statusValue)) {
const label = getOrderGroupDisplayStatusLabel(group);
map.set(statusValue, { label, groups: [] });
}
map.get(statusValue).groups.push(group);
}
return map;
}, [filteredGroups]);
const totalGroups = filteredGroups.length;
// Build sorted list: use sectionOrder for known statuses, append unknown ones at end
const sortedEntries = React.useMemo(() => {
const present = new Set(statusGroups.keys());
const result = [];
// First: statuses in custom order that are present
for (const statusValue of sectionOrder) {
if (present.has(statusValue)) {
const data = statusGroups.get(statusValue);
result.push([statusValue, data]);
}
}
// Then: any statuses not in sectionOrder (new statuses), sorted alphabetically
for (const [statusValue, data] of statusGroups.entries()) {
if (!sectionOrder.includes(statusValue)) {
result.push([statusValue, data]);
}
}
return result;
}, [statusGroups, sectionOrder]);
const handleDragEnd = (event) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setSectionOrder((prevOrder) => {
// Build the full order including any new statuses
const allIds = sortedEntries.map(([id]) => id);
const oldIndex = allIds.indexOf(active.id);
const newIndex = allIds.indexOf(over.id);
if (oldIndex === -1 || newIndex === -1) return prevOrder;
const newAllOrder = arrayMove(allIds, oldIndex, newIndex);
// Merge: replace positions of known statuses, keep unknown at end
// Save the full new order so it persists
saveCustomOrder(newAllOrder);
return newAllOrder;
});
};
if (isLoading) {
return <SkeletonPage panels={3} />;
}
return (
<div className="space-y-6 fs-zone-table">
<Panel className="space-y-4 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-lg font-semibold">Наборы доставки</h2>
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">
Перетаскивайте секции за ручку слева, чтобы изменить порядок отображения.
</p>
</div>
<Badge tone="neutral">{totalGroups} групп</Badge>
</div>
<OrderFilters
filters={filters}
setFilters={setFilters}
statusOptions={statusOptions}
cities={cities}
/>
</Panel>
{!totalGroups ? (
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
По этому поиску ничего не найдено.
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedEntries.map(([id]) => id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-4">
{sortedEntries.map(([statusValue, { label, groups }]) => {
const isCollapsed = collapsedSections.has(statusValue);
return (
<SortableSection
key={statusValue}
statusValue={statusValue}
label={label}
groups={groups}
isCollapsed={isCollapsed}
onToggle={() => {
setCollapsedSections((prev) => {
const next = new Set(prev);
if (next.has(statusValue)) {
next.delete(statusValue);
} else {
next.add(statusValue);
}
saveCollapsedSections(next);
return next;
});
}}
onSelectSet={onSelectSet}
/>
);
})}
</div>
</SortableContext>
</DndContext>
)}
</div>
);
};

257
src/OrdersTable.jsx Normal file
View File

@ -0,0 +1,257 @@
import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel";
import { SkeletonTable } from "../UI/Loading";
import { OrderFilters } from "./OrderFilters";
import {
getOrderGroupDisplayStatusLabel,
getOrderGroupStatusTone,
} from "../../services/orderGroupViews";
const MAX_VISIBLE_INVOICES = 2;
const fmtDate = (d) => {
if (!d) return '';
const [y, m, day] = d.split('-');
if (!y || !m || !day) return d;
return `${day}.${m}.${y}`;
};
const getShipmentIssues = (group) => {
const data = group?.driverShipmentData;
if (!Array.isArray(data) || data.length === 0) return null;
const unshipped = data.filter((i) => !i.shipped);
if (unshipped.length === 0) return null;
return unshipped;
};
const buildGroupSummary = (group) => {
const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`;
const parts = [orderCountLabel];
if (group.deliveryDate) {
const datePart = group.deliveryTime ? `${fmtDate(group.deliveryDate)} · ${group.deliveryTime}` : fmtDate(group.deliveryDate);
parts.push(datePart);
}
if (group.assignedDriverName) {
parts.push(group.assignedDriverName);
}
return parts.join(" · ");
};
const renderOrderNumbers = (group) => {
const numbers = group.allBillNumbers || group.orderNumbers;
if (!Array.isArray(numbers) || !numbers.length) {
return "Номера не указаны";
}
if (numbers.length <= MAX_VISIBLE_INVOICES) {
return numbers.join(", ");
}
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
return `${visible.join(", ")} +${remaining}`;
};
const getDeliveryTypeLabel = (deliveryType) => {
if (deliveryType === "pickup") return "Самовывоз";
if (deliveryType === "delivery") return "Доставка";
return "—";
};
const renderMobileOrderNumbers = (group) => {
const numbers = group.allBillNumbers || group.orderNumbers;
if (!Array.isArray(numbers) || !numbers.length) {
return "Номера не указаны";
}
if (numbers.length <= MAX_VISIBLE_INVOICES) {
return numbers.join(", ");
}
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
return (
<>
{visible.join(", ")}
<span className="ml-1 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-accent)]">+{remaining}</span>
</>
);
};
export const OrdersTable = ({
orderGroups = [],
selectedOrderGroupId,
onOpenOrder,
filters,
setFilters,
statusOptions,
cities = [],
isLoading = false,
}) => {
if (isLoading) {
return <SkeletonTable rows={5} cols={5} />;
}
return (
<Panel className="p-0 fs-zone-table">
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold">Группы доставки</h2>
<p className="text-sm text-[var(--color-text-muted)]">
Поиск по группе, клиенту, телефону и дате доставки.
</p>
</div>
<Badge tone="neutral">{orderGroups.length}</Badge>
</div>
{filters && setFilters ? (
<OrderFilters filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} />
) : null}
</div>
<div className="space-y-3 p-4 md:hidden">
{!orderGroups.length ? (
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
Группы не найдены. Попробуйте изменить поиск или статус.
</div>
) : null}
{orderGroups.map((group) => {
const hasProblem = group.hasDeliveryProblem;
const baseClass = "w-full rounded-[22px] border text-left transition";
const selectedClass = selectedOrderGroupId === group.id
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
: hasProblem
? "border-[var(--color-danger)] bg-[rgba(201,61,61,0.1)]"
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]";
const allNumbers = group.allBillNumbers || group.orderNumbers || [];
const primaryBill = allNumbers[0] || "—";
const totalCount = allNumbers.length;
return (
<div key={group.id}>
<div className="mb-1 flex items-center gap-2 px-1 text-xs">
<span className="font-medium text-[var(--color-text)]">№ {primaryBill}</span>
{totalCount > 1 && (
<span className="rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
{totalCount} сч.
</span>
)}
</div>
<button
type="button"
onClick={() => onOpenOrder(group.id)}
className={[baseClass, selectedClass, "flex flex-col items-start justify-start p-4"].join(" ")}
>
<div className="min-w-0 w-full font-medium leading-snug break-words">
{group.displayTitle || group.customerName || group.groupKey}
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${group.deliveryType === "pickup" ? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]" : "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"}`}>
{group.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}
</span>
<Badge tone={getOrderGroupStatusTone(group)}>
{getOrderGroupDisplayStatusLabel(group)}
</Badge>
</div>
{group.hasDeliveryProblem && (
<div className="mt-2 rounded-lg border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-2 py-1 text-xs">
<span className="font-medium text-[var(--color-warning)]">⚠ Проблема с доставкой</span>
{group.deliveryProblemNote && (
<div className="mt-0.5 text-[var(--color-text-muted)]">{group.deliveryProblemNote}</div>
)}
</div>
)}
</button>
</div>
);
})}
</div>
<div className="hidden md:block">
{!orderGroups.length ? (
<div className="px-5 py-6 text-sm text-[var(--color-text-muted)]">
Группы не найдены. Попробуйте изменить поиск или статус.
</div>
) : (
<div className="overflow-x-auto">
<div className="min-w-[1080px]">
<div className="grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
<div className="px-3 py-1.5 font-medium">Группа / Клиент</div>
<div className="px-3 py-1.5 font-medium">Счета</div>
<div className="px-3 py-1.5 font-medium">Город</div>
<div className="px-3 py-1.5 font-medium">Статус</div>
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
<div className="px-3 py-1.5 font-medium">Тип</div>
<div className="px-3 py-1.5 font-medium">Водитель</div>
</div>
{orderGroups.map((group) => {
const hasProblem = group.hasDeliveryProblem;
const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition ${
hasProblem
? "bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]"
: "hover:bg-[var(--color-accent-soft)]"
} ${selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : ""}`;
const billNumbers = group.allBillNumbers || group.orderNumbers || [];
const primaryBill = billNumbers[0] || "—";
const totalBills = billNumbers.length;
return (
<button
key={group.id}
type="button"
className={rowClassName}
onClick={() => onOpenOrder(group.id)}
>
<div className="min-w-0 px-3 py-1.5">
<div className="text-xs font-medium leading-snug break-words" style={{display:"-webkit-box",WebkitLineClamp:2,WebkitBoxOrient:"vertical",overflow:"hidden"}}>{group.displayTitle || group.customerName || group.groupKey}</div>
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
{group.customerPhone || ""}
</div>
</div>
<div className="px-3 py-1.5">
<div className="text-xs text-[var(--color-text)]">{primaryBill}</div>
{totalBills > 1 && (
<span className="inline-block mt-0.5 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
{totalBills} сч.
</span>
)}
</div>
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
{group.city || "—"}
</div>
<div className="px-3 py-1.5">
<Badge tone={getOrderGroupStatusTone(group)}>
{getOrderGroupDisplayStatusLabel(group)}
</Badge>
</div>
<div className="px-3 py-1.5 text-xs">
{group.deliveryDate ? (
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
) : (
<span className="text-[var(--color-text-muted)]">—</span>
)}
</div>
<div className="px-3 py-1.5 text-xs">
<span className="inline-flex items-center gap-1">
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
</span>
</div>
<div className="px-3 py-1.5 text-xs">
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
</div>
</button>
);
})}
</div>
</div>
)}
</div>
</Panel>
);
};

186
src/SettingsPage.jsx Normal file
View File

@ -0,0 +1,186 @@
/**
* @file SettingsPage.jsx
* @description Page for user settings: font size controls for all UI categories.
* Settings persist to localStorage via FontSettingsContext.
*/
import React from "react";
import { useNavigate } from "react-router-dom";
import { useFontSettings, FONT_CATEGORIES } from "../context/FontSettingsContext";
import { Panel } from "../components/UI/Panel";
import { Button } from "../components/UI/Button";
const PRESETS = [
{ label: "Мелкий", scales: { table: 0.85, card: 0.85, nav: 0.85, heading: 0.85, body: 0.85, small: 0.85 } },
{ label: "Стандарт", scales: { table: 1.0, card: 1.0, nav: 1.0, heading: 1.0, body: 1.0, small: 1.0 } },
{ label: "Крупный", scales: { table: 1.15, card: 1.15, nav: 1.15, heading: 1.2, body: 1.15, small: 1.1 } },
{ label: "Очень крупный", scales: { table: 1.3, card: 1.3, nav: 1.25, heading: 1.4, body: 1.3, small: 1.2 } },
];
const Slider = ({ category, value, onChange }) => {
const pct = ((value - category.min) / (category.max - category.min)) * 100;
return (
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<div>
<span className="font-medium text-[var(--color-text)]">{category.label}</span>
<p className="text-xs text-[var(--color-text-muted)]">{category.description}</p>
</div>
<span className="text-sm font-semibold tabular-nums text-[var(--color-accent)]">
{Math.round(value * 100)}%
</span>
</div>
<div className="flex items-center gap-3">
<input
type="range"
min={category.min}
max={category.max}
step={category.step}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="fs-slider flex-1"
style={{
background: `linear-gradient(to right, var(--color-accent) 0%, var(--color-accent) ${pct}%, var(--color-border) ${pct}%, var(--color-border) 100%)`,
}}
aria-label={category.label}
/>
<span className="text-xs tabular-nums text-[var(--color-text-muted)] w-16 text-right">
{value.toFixed(2)}×
</span>
</div>
</div>
);
};
export const SettingsPage = () => {
const { settings, updateCategory, resetAll, categories } = useFontSettings();
const navigate = useNavigate();
const applyPreset = (scales) => {
for (const [key, scale] of Object.entries(scales)) {
updateCategory(key, scale);
}
};
// Check if current settings match a preset
const activePreset = PRESETS.findIndex((p) =>
Object.entries(p.scales).every(([k, v]) => Math.abs((settings[k] ?? 1.0) - v) < 0.001)
);
return (
<div className="mx-auto max-w-3xl space-y-6 px-3 py-4 sm:px-4 md:px-6 md:py-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Настройки</p>
<h1 className="mt-1 text-2xl font-bold leading-tight" style={{ fontSize: `calc(1.5rem * var(--fs-scale-heading, 1))` }}>
Настройки интерфейса
</h1>
</div>
<Button variant="ghost" onClick={() => navigate(-1)}>
← Назад
</Button>
</div>
{/* Presets */}
<Panel className="p-5 space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Быстрые пресеты
</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{PRESETS.map((preset, i) => (
<button
key={preset.label}
type="button"
onClick={() => applyPreset(preset.scales)}
className={[
"rounded-[18px] border-2 px-4 py-3 text-center transition",
activePreset === i
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)] font-semibold"
: "border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
].join(" ")}
>
<div className="text-sm font-medium">{preset.label}</div>
</button>
))}
</div>
</Panel>
{/* Font size sliders */}
<Panel className="p-5 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Размеры шрифтов
</h2>
<Button size="sm" variant="ghost" onClick={resetAll}>
Сбросить
</Button>
</div>
<div className="space-y-5">
{categories.map((cat) => (
<Slider
key={cat.key}
category={cat}
value={settings[cat.key] ?? cat.defaultScale}
onChange={(v) => updateCategory(cat.key, v)}
/>
))}
</div>
{/* Preview */}
<div className="rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 space-y-2">
<p className="text-xs uppercase tracking-wider text-[var(--color-text-muted)]">Предпросмотр</p>
<div className="fs-zone-heading space-y-1">
<h3 className="font-bold" style={{ fontSize: `calc(1.25rem * var(--fs-scale-heading, 1))` }}>
Заголовок секции
</h3>
</div>
<div className="fs-zone-nav flex gap-2">
<span className="rounded-[14px] bg-[var(--color-accent)] px-3 py-1.5 text-[var(--color-accent-contrast)]"
style={{ fontSize: `calc(0.875rem * var(--fs-scale-nav, 1))` }}>
Пункт меню
</span>
<span className="rounded-[14px] bg-[var(--color-surface)] px-3 py-1.5 border border-[var(--color-border)]"
style={{ fontSize: `calc(0.875rem * var(--fs-scale-nav, 1))` }}>
Вкладка
</span>
</div>
<div className="fs-zone-table rounded-[14px] border border-[var(--color-border)] overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-[var(--color-surface)]">
<th className="px-3 py-2 text-left font-semibold" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>Дата</th>
<th className="px-3 py-2 text-left font-semibold" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>Статус</th>
</tr>
</thead>
<tbody>
<tr className="border-t border-[var(--color-border)]">
<td className="px-3 py-2" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>02.07.2026</td>
<td className="px-3 py-2" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>В работе</td>
</tr>
</tbody>
</table>
</div>
<div className="fs-zone-card rounded-[14px] bg-[var(--color-surface)] p-3">
<p style={{ fontSize: `calc(0.875rem * var(--fs-scale-card, 1))` }}>
Текст в карточке — описание доставки или заказа.
</p>
</div>
<p className="fs-zone-body text-[var(--color-text)]" style={{ fontSize: `calc(1rem * var(--fs-scale-body, 1))` }}>
Основной текст интерфейса.
</p>
<p className="fs-zone-small text-[var(--color-text-muted)]" style={{ fontSize: `calc(0.75rem * var(--fs-scale-small, 1))` }}>
мелкая подпись · временная отметка
</p>
</div>
</Panel>
<p className="text-center text-xs text-[var(--color-text-muted)]">
Настройки сохраняются на этом устройстве
</p>
</div>
);
};
export default SettingsPage;

View File

@ -0,0 +1,80 @@
import React from "react";
/**
* Universal pagination control.
* Usage: <Pagination page={1} totalPages={5} onChange={setPage} />
*/
export const Pagination = ({ page, totalPages, onChange, itemsPerPage, totalItems }) => {
if (totalPages <= 1) return null;
const from = (page - 1) * (itemsPerPage || 0) + 1;
const to = Math.min(page * (itemsPerPage || 0), totalItems || 0);
const pages = [];
const maxButtons = 7;
let start = Math.max(1, page - 3);
let end = Math.min(totalPages, start + maxButtons - 1);
if (end - start < maxButtons - 1) start = Math.max(1, end - maxButtons + 1);
for (let i = start; i <= end; i++) pages.push(i);
return (
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-3 border-t border-[var(--color-border)]">
<span className="text-xs text-[var(--color-text-muted)]">
{totalItems != null && itemsPerPage
? `${from}–${to} из ${totalItems}`
: `Стр. ${page} из ${totalPages}`}
</span>
<div className="flex items-center gap-1.5">
<button
type="button"
disabled={page <= 1}
onClick={() => onChange(page - 1)}
className="rounded-lg border border-[var(--color-border)] px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] disabled:opacity-30 disabled:cursor-not-allowed"
>
←
</button>
{start > 1 && (
<>
<button
type="button"
onClick={() => onChange(1)}
className="rounded-lg px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] transition"
>1</button>
{start > 2 && <span className="text-xs text-[var(--color-text-muted)] px-1">…</span>}
</>
)}
{pages.map((p) => (
<button
key={p}
type="button"
onClick={() => onChange(p)}
className={`rounded-lg px-2.5 py-1 text-xs font-medium transition ${
p === page
? "bg-[var(--color-accent)] text-white shadow-sm"
: "text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] border border-[var(--color-border)]"
}`}
>{p}</button>
))}
{end < totalPages && (
<>
{end < totalPages - 1 && <span className="text-xs text-[var(--color-text-muted)] px-1">…</span>}
<button
type="button"
onClick={() => onChange(totalPages)}
className="rounded-lg px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] transition"
>{totalPages}</button>
</>
)}
<button
type="button"
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
className="rounded-lg border border-[var(--color-border)] px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] disabled:opacity-30 disabled:cursor-not-allowed"
>
→
</button>
</div>
</div>
);
};

View File

@ -1,8 +1,9 @@
/** /**
* @file AdminDashboard.jsx * @file AdminDashboard.jsx
* @description Admin analytics dashboard. Displays KPI cards, status pie chart, * @description Admin analytics dashboard. Redesigned with responsive grid layout
* daily trend line, confirmation funnel, SMS stats, and driver performance * that uses desktop width effectively. KPI cards, status pie, daily trend,
* bar chart. Supports period selection (1d/7d/30d/all) and mobile layout. * confirmation funnel, SMS stats, driver performance, pickup stats.
* Period label shown in section subtitles.
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
@ -10,7 +11,6 @@ import {
PieChart, Pie, Cell, Legend, LineChart, Line, CartesianGrid, PieChart, Pie, Cell, Legend, LineChart, Line, CartesianGrid,
} from 'recharts'; } from 'recharts';
import { Panel } from '../UI/Panel'; import { Panel } from '../UI/Panel';
import { Badge } from '../UI/Badge';
import { SegmentedTabs } from '../UI/SegmentedTabs'; import { SegmentedTabs } from '../UI/SegmentedTabs';
import { Skeleton } from '../UI/Loading'; import { Skeleton } from '../UI/Loading';
import { useAdminStats } from '../../hooks/useAdminStats'; import { useAdminStats } from '../../hooks/useAdminStats';
@ -21,7 +21,7 @@ import { PickupStatsPanel } from './PickupStatsPanel';
const useIsMobile = () => { const useIsMobile = () => {
const [mobile, setMobile] = useState(false); const [mobile, setMobile] = useState(false);
useEffect(() => { useEffect(() => {
const mq = window.matchMedia('(max-width: 640px)'); const mq = window.matchMedia('(max-width: 768px)');
setMobile(mq.matches); setMobile(mq.matches);
const handler = (e) => setMobile(e.matches); const handler = (e) => setMobile(e.matches);
mq.addEventListener('change', handler); mq.addEventListener('change', handler);
@ -38,7 +38,8 @@ const STATUS_COLORS = {
driver_assigned: '#3b82f6', driver_assigned: '#3b82f6',
loaded: '#6366f1', loaded: '#6366f1',
on_route: '#8b5cf6', on_route: '#8b5cf6',
delivered: '#10b981', delivered: '#22c55e',
picked_up: '#14b8a6',
paid_storage: '#06b6d4', paid_storage: '#06b6d4',
problem: '#ef4444', problem: '#ef4444',
cancelled: '#64748b', cancelled: '#64748b',
@ -53,6 +54,7 @@ const STATUS_LABELS = {
loaded: 'Загружено', loaded: 'Загружено',
on_route: 'В пути', on_route: 'В пути',
delivered: 'Доставлено', delivered: 'Доставлено',
picked_up: 'Вывезено',
paid_storage: 'Оплаченное хранение', paid_storage: 'Оплаченное хранение',
problem: 'Проблема', problem: 'Проблема',
cancelled: 'Отменено', cancelled: 'Отменено',
@ -64,9 +66,16 @@ const PERIOD_OPTIONS = [
{ key: '1d', label: 'Сегодня' }, { key: '1d', label: 'Сегодня' },
{ key: '7d', label: '7 дней' }, { key: '7d', label: '7 дней' },
{ key: '30d', label: '30 дней' }, { key: '30d', label: '30 дней' },
{ key: 'all', label: 'Все' }, { key: 'all', label: 'Всё время' },
]; ];
const PERIOD_LABELS = {
'1d': 'за сегодня',
'7d': 'за 7 дней',
'30d': 'за 30 дней',
'all': 'за всё время',
};
// ── Custom Recharts Tooltip ───────────────────────────────────────────────── // ── Custom Recharts Tooltip ─────────────────────────────────────────────────
const CustomTooltip = ({ active, payload, label: tooltipLabel }) => { const CustomTooltip = ({ active, payload, label: tooltipLabel }) => {
if (!active || !payload?.length) return null; if (!active || !payload?.length) return null;
@ -85,44 +94,65 @@ const CustomTooltip = ({ active, payload, label: tooltipLabel }) => {
); );
}; };
// ── KPI Card ────────────────────────────────────────────────────────────────
const KpiCard = ({ label, value, color, mobile }) => (
<Panel style={{
padding: mobile ? '0.5rem 0.6rem' : '0.75rem 1rem',
textAlign: 'center',
display: 'flex', flexDirection: 'column', justifyContent: 'center',
minHeight: mobile ? '60px' : '80px',
}}>
<div style={{ fontSize: mobile ? '0.6rem' : '0.72rem', color: 'var(--color-text-muted)', marginBottom: '0.15rem', textTransform: 'uppercase', letterSpacing: '0.03em' }}>
{label}
</div>
<div style={{ fontSize: mobile ? '1.15rem' : '1.6rem', fontWeight: 800, color: color || 'var(--color-text)', lineHeight: 1.1 }}>
{value ?? '—'}
</div>
</Panel>
);
// ── Section Header ──────────────────────────────────────────────────────────
const SectionHeader = ({ title, subtitle, mobile }) => (
<div style={{ marginBottom: '0.6rem' }}>
<h3 style={{ fontSize: mobile ? '0.9rem' : '1rem', fontWeight: 700, color: 'var(--color-text)', marginBottom: subtitle ? '0.1rem' : 0 }}>
{title}
</h3>
{subtitle && (
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)' }}>
{subtitle}
</div>
)}
</div>
);
// ── AdminDashboard Component ─────────────────────────────────────────────── // ── AdminDashboard Component ───────────────────────────────────────────────
export const AdminDashboard = () => { export const AdminDashboard = () => {
// ── State & Hooks ─────────────────────────────────────────────────────────
const [period, setPeriod] = useState('7d'); const [period, setPeriod] = useState('7d');
const mobile = useIsMobile(); const mobile = useIsMobile();
const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period); const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period);
const { stats: pickupStats, isLoading: pickupLoading } = usePickupStats(period); const { stats: pickupStats, isLoading: pickupLoading } = usePickupStats(period);
// ── Responsive Layout Values (must be before early returns) ──────────────── const periodLabel = PERIOD_LABELS[period] || '';
const chartHeight = mobile ? 200 : 240; const chartHeight = mobile ? 200 : 280;
const kpiMin = mobile ? '80px' : '110px'; const fontSize = mobile ? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem' }
const chartGridCols = mobile ? '1fr' : '1fr 2fr'; : { xs: '0.72rem', s: '0.78rem', m: '0.85rem', l: '0.95rem' };
const driverLabelWidth = mobile ? 80 : 120;
const fontSize = mobile ? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem', xl: '1rem' }
: { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' };
// ── Loading / Error States ───────────────────────────────────────────────── // ── Loading State ─────────────────────────────────────────────────────────
if (isLoading) { if (isLoading) {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.5rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
<Skeleton variant="heading" className="w-1/4" /> <Skeleton variant="heading" className="w-1/4" />
<Skeleton className="w-32 h-8" /> <Skeleton className="w-32 h-8" />
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : `repeat(auto-fit, minmax(80px, 160px))`, gap: '0.4rem' }}> <div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : 'repeat(auto-fit, minmax(120px, 1fr))', gap: '0.5rem' }}>
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 7 }).map((_, i) => (
<Panel key={i} style={{ padding: mobile ? '0.4rem 0.6rem' : '0.5rem 0.75rem', textAlign: 'center' }}> <Panel key={i} style={{ padding: '0.75rem', textAlign: 'center' }}>
<Skeleton className="w-12 h-3 mb-1" /> <Skeleton className="w-12 h-3 mb-1" />
<Skeleton className="w-8 h-5" /> <Skeleton className="w-8 h-6" />
</Panel> </Panel>
))} ))}
</div> </div>
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<Skeleton variant="heading" className="w-1/3 mb-3" />
<div style={{ height: chartHeight }} className="flex items-center justify-center">
<Skeleton className="w-3/4 h-40" />
</div>
</Panel>
</div> </div>
); );
} }
@ -145,70 +175,83 @@ export const AdminDashboard = () => {
status: s.delivery_status, status: s.delivery_status,
})).filter(d => d.value > 0); })).filter(d => d.value > 0);
// ── Trend & Driver Data ───────────────────────────────────────────────────
const trendData = (dailyTrend || []).map(d => ({ const trendData = (dailyTrend || []).map(d => ({
date: d.date ? new Date(d.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }) : '', date: d.date ? new Date(d.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }) : '',
delivered: d.delivered || 0, total: d.total || 0, problems: d.problems || 0, delivered: d.delivered || 0, picked_up: d.picked_up || 0, total: d.total || 0, problems: d.problems || 0,
})); }));
const driverData = (driverStats || []).map(d => ({ const driverData = (driverStats || []).map(d => ({
name: d.driver_name || 'Неизвестный', name: d.driver_name || 'Неизвестный',
total: d.total || 0, delivered: d.delivered || 0, problems: d.problems || 0, total: d.total || 0, delivered: d.delivered || 0, picked_up: d.picked_up || 0, problems: d.problems || 0,
})); }));
// Funnel: ALWAYS show all steps, even with 0 values // ── Funnel: Real order path ───────────────────────────────────────────────
// ── Funnel Data ──────────────────────────────────────────────────────────── const completedTotal = (econ.full_chain_client || 0) + (econ.client_date_no_driver || 0)
+ (econ.manager_date_completed || 0) + (econ.bypassed_completed || 0);
const funnelSteps = [ const funnelSteps = [
{ label: 'Согласовано после 1-й SMS', value: econ.confirmed_after_sms1 || 0, color: '#22c55e' }, { label: 'Всего заказов', value: econ.total_groups || 0, color: '#94a3b8' },
{ label: 'Согласовано после 2-й SMS', value: econ.confirmed_after_sms2 || 0, color: '#14b8a6' }, { label: 'SMS отправлено', value: econ.sms_sent || 0, color: '#3b82f6' },
{ label: 'Согласовано вручную', value: econ.confirmed_via_manual || 0, color: '#eab308' }, { label: 'Полная цепочка', value: econ.full_chain_client || 0, color: '#22c55e' },
{ label: 'Ручное назначение даты', value: econ.manual_date_set_count || 0, color: '#f97316' }, { label: 'Клиент выбрал дату', value: econ.client_date_no_driver || 0, color: '#14b8a6' },
{ label: 'Платное хранение', value: econ.paid_storage_count || 0, color: '#06b6d4' }, { label: 'Менеджер назначил дату', value: econ.manager_date_completed || 0, color: '#8b5cf6' },
{ label: 'Отмена', value: econ.cancelled_count || 0, color: '#ef4444' }, { label: 'В обход (без даты)', value: econ.bypassed_completed || 0, color: '#f97316' },
{ label: 'Застряло в ручном', value: econ.stuck_in_manual || 0, color: '#eab308' },
{ label: 'В работе', value: econ.in_progress || 0, color: '#3b82f6' },
{ label: 'Отменено', value: econ.cancelled_count || 0, color: '#64748b' },
]; ];
// ── Render ───────────────────────────────────────────────────────────────── // ── Grid Layout ───────────────────────────────────────────────────────────
return ( // Desktop: 12-col grid. Mobile: single column.
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}> const gridCols = mobile ? '1fr' : 'repeat(12, 1fr)';
const colSpan = (n) => mobile ? '1 / -1' : `span ${n}`;
{/* Period selector */} return (
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.5rem' }}>
{/* ── Header + Period selector ─────────────────────────────────────────── */}
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
<div> <div>
<h2 style={{ fontSize: mobile ? '1rem' : '1.1rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.15rem' }}>Аналитика</h2> <h2 style={{ fontSize: mobile ? '1.1rem' : '1.4rem', fontWeight: 800, color: 'var(--color-text)', marginBottom: '0.1rem' }}>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Статистика по доставкам</p> Аналитика
</h2>
<p style={{ fontSize: mobile ? '0.72rem' : '0.82rem', color: 'var(--color-text-muted)' }}>
Статистика по доставкам {periodLabel}
</p>
</div> </div>
<SegmentedTabs items={PERIOD_OPTIONS} activeKey={period} onChange={setPeriod} /> <SegmentedTabs items={PERIOD_OPTIONS} activeKey={period} onChange={setPeriod} />
</div> </div>
{/* KPI — centered on mobile */} {/* ── KPI Cards ─────────────────────────────────────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : `repeat(auto-fit, minmax(${kpiMin}, 160px))`, gap: '0.4rem' }}> <div style={{
{[ display: 'grid',
{ label: 'Всего', val: totalGroups }, gridTemplateColumns: mobile ? '1fr 1fr' : 'repeat(auto-fit, minmax(130px, 1fr))',
{ label: 'Ожидает', val: sv.pending }, gap: mobile ? '0.4rem' : '0.75rem',
{ label: 'В работе', val: sv.in_progress }, }}>
{ label: 'Доставлено', val: sv.delivered }, <KpiCard label="Всего" value={totalGroups} mobile={mobile} />
{ label: 'Проблемы', val: sv.problem }, <KpiCard label="Ожидает" value={sv.pending} color="#94a3b8" mobile={mobile} />
{ label: '% доставки', val: sv.delivery_rate != null ? sv.delivery_rate + '%' : '—' }, <KpiCard label="В работе" value={sv.in_progress} color="#3b82f6" mobile={mobile} />
].map((kpi, i) => ( <KpiCard label="Доставлено" value={sv.delivered} color="#22c55e" mobile={mobile} />
<Panel key={i} style={{ padding: mobile ? '0.4rem 0.6rem' : '0.5rem 0.75rem', textAlign: 'center' }}> <KpiCard label="Вывезено" value={sv.picked_up} color="#14b8a6" mobile={mobile} />
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '0.05rem' }}>{kpi.label}</div> <KpiCard label="Самовывоз" value={sv.picked_up_pickup} color="#f59e0b" mobile={mobile} />
<div style={{ fontSize: mobile ? '1.1rem' : '1.3rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>{kpi.val ?? '—'}</div> <KpiCard label="Проблемы" value={sv.problem} color="#ef4444" mobile={mobile} />
</Panel> <KpiCard label="% доставки" value={sv.delivery_rate != null ? sv.delivery_rate + '%' : '—'} color="var(--color-text)" mobile={mobile} />
))}
</div> </div>
{/* Pie + Line — stacked on mobile, side-by-side on desktop */} {/* ── Main Grid: Charts + Tables ───────────────────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: chartGridCols, gap: mobile ? '0.5rem' : '1rem' }}> <div style={{ display: 'grid', gridTemplateColumns: gridCols, gap: mobile ? '0.75rem' : '1.5rem' }}>
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По статусам</h3> {/* Status Pie — 4 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
<SectionHeader title="По статусам" subtitle={periodLabel} mobile={mobile} />
{statusPieData.length === 0 ? ( {statusPieData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</div> <div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
) : ( ) : (
<ResponsiveContainer width="100%" height={chartHeight}> <ResponsiveContainer width="100%" height={chartHeight}>
<PieChart> <PieChart>
<Pie data={statusPieData} cx="50%" cy="50%" <Pie data={statusPieData} cx="50%" cy="50%"
innerRadius={mobile ? 30 : 40} innerRadius={mobile ? 30 : 50}
outerRadius={mobile ? 60 : 80} outerRadius={mobile ? 60 : 90}
dataKey="value" nameKey="name" paddingAngle={2}> dataKey="value" nameKey="name" paddingAngle={2}>
{statusPieData.map(entry => ( {statusPieData.map(entry => (
<Cell key={entry.status} fill={STATUS_COLORS[entry.status] || '#6b7280'} /> <Cell key={entry.status} fill={STATUS_COLORS[entry.status] || '#6b7280'} />
@ -221,155 +264,160 @@ export const AdminDashboard = () => {
)} )}
</Panel> </Panel>
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}> {/* Daily Trend — 8 cols desktop */}
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>Тренд по дням</h3> <Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(8) }}>
<SectionHeader title="Тренд по дням" subtitle={periodLabel} mobile={mobile} />
{trendData.length === 0 ? ( {trendData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</div> <div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
) : ( ) : (
<ResponsiveContainer width="100%" height={chartHeight}> <ResponsiveContainer width="100%" height={chartHeight}>
<LineChart data={trendData}> <LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border, #334155)" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--color-border, #334155)" />
<XAxis dataKey="date" tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} /> <XAxis dataKey="date" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} />
<YAxis tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} width={mobile ? 25 : 35} /> <YAxis tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} width={mobile ? 25 : 40} />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: fontSize.xs }} /> <Legend wrapperStyle={{ fontSize: fontSize.xs }} />
<Line type="monotone" dataKey="total" name="Всего" stroke="#94a3b8" strokeWidth={2} dot={false} /> <Line type="monotone" dataKey="total" name="Всего" stroke="#94a3b8" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="delivered" name="Доставлено" stroke="#22c55e" strokeWidth={2} dot={false} /> <Line type="monotone" dataKey="delivered" name="Доставлено" stroke="#22c55e" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="picked_up" name="Вывезено" stroke="#14b8a6" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="problems" name="Проблемы" stroke="#ef4444" strokeWidth={2} dot={false} /> <Line type="monotone" dataKey="problems" name="Проблемы" stroke="#ef4444" strokeWidth={2} dot={false} />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
)} )}
</Panel> </Panel>
</div>
{/* Status table */} {/* Status Table — 4 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}> <Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>Все статусы</h3> <SectionHeader title="Все статусы" subtitle={periodLabel} mobile={mobile} />
{statusPieData.length === 0 ? ( {statusPieData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div> <div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
) : ( ) : (
<div> <div>
<div style={{ <div style={{
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 50px 40px' : '10px 1fr 70px 55px', display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 45px 40px' : '12px 1fr 60px 55px',
gap: '0 0.4rem', padding: '0.3rem 0.3rem', alignItems: 'center', gap: '0 0.5rem', padding: '0.4rem 0.3rem', alignItems: 'center',
borderBottom: '1px solid var(--color-border)', fontSize: fontSize.xs, borderBottom: '1px solid var(--color-border)', fontSize: fontSize.xs,
color: 'var(--color-text-muted)', fontWeight: 600, color: 'var(--color-text-muted)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em',
}}> }}>
<div /><div>Статус</div><div style={{ textAlign: 'right' }}>Кол-во</div><div style={{ textAlign: 'right' }}>Доля</div> <div /><div>Статус</div><div style={{ textAlign: 'right' }}>Кол-во</div><div style={{ textAlign: 'right' }}>Доля</div>
</div> </div>
{statusPieData.map(s => { {statusPieData.map(s => {
const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0; const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0;
return ( return (
<div key={s.status} style={{ <div key={s.status} style={{
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 50px 40px' : '10px 1fr 70px 55px', display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 45px 40px' : '12px 1fr 60px 55px',
gap: '0 0.4rem', padding: '0.4rem 0.3rem', alignItems: 'center', gap: '0 0.5rem', padding: '0.5rem 0.3rem', alignItems: 'center',
borderBottom: '1px solid var(--color-border, rgba(51,65,85,0.4))', borderBottom: '1px solid var(--color-border, rgba(51,65,85,0.4))',
}}>
<div style={{ width: mobile ? '8px' : '10px', height: mobile ? '8px' : '10px', borderRadius: '3px', background: STATUS_COLORS[s.status] || '#6b7280' }} />
<div style={{ fontSize: fontSize.m, color: 'var(--color-text)' }}>{s.name}</div>
<div style={{ textAlign: 'right', fontSize: fontSize.m, fontWeight: 600, color: 'var(--color-text)' }}>{s.value}</div>
<div style={{ textAlign: 'right', fontSize: fontSize.s, color: 'var(--color-text-muted)' }}>{pct}%</div>
</div>
);
})}
</div>
)}
</Panel>
{/* Воронка согласования — ALL steps always visible */}
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}>Воронка согласования</h3>
{totalGroups === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0', padding: '0.4rem 0' }}>
{funnelSteps.map((step, i) => {
const pct = totalGroups > 0 ? Math.round((step.value / totalGroups) * 100) : 0;
const widthPct = step.value > 0 ? Math.max(15, (step.value / totalGroups) * 100) : 15;
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px', width: '100%' }}>
<div style={{ fontSize: mobile ? '0.8rem' : '0.85rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>
{step.value}
</div>
<div style={{
width: widthPct + '%', height: mobile ? '28px' : '32px', background: step.value > 0 ? step.color : 'var(--color-border, #334155)',
borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'width 0.4s ease', minWidth: '40px', maxWidth: '100%',
opacity: step.value > 0 ? 1 : 0.5,
}}> }}>
<span style={{ fontSize: mobile ? '0.6rem' : '0.7rem', fontWeight: 600, color: step.value > 0 ? '#fff' : 'var(--color-text-muted)', textShadow: step.value > 0 ? '0 1px 2px rgba(0,0,0,0.3)' : 'none' }}> <div style={{ width: mobile ? '8px' : '12px', height: mobile ? '8px' : '12px', borderRadius: '3px', background: STATUS_COLORS[s.status] || '#6b7280' }} />
{pct}% <div style={{ fontSize: fontSize.m, color: 'var(--color-text)' }}>{s.name}</div>
</span> <div style={{ textAlign: 'right', fontSize: fontSize.m, fontWeight: 700, color: 'var(--color-text)' }}>{s.value}</div>
<div style={{ textAlign: 'right', fontSize: fontSize.s, color: 'var(--color-text-muted)' }}>{pct}%</div>
</div> </div>
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)', textAlign: 'center', maxWidth: mobile ? '180px' : '220px' }}> );
{step.label} })}
</div>
)}
</Panel>
{/* Funnel — 4 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
<SectionHeader title="Путь заказа" subtitle={periodLabel} mobile={mobile} />
{totalGroups === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0', padding: '0.4rem 0' }}>
{funnelSteps.map((step, i) => {
const pct = totalGroups > 0 ? Math.round((step.value / totalGroups) * 100) : 0;
const widthPct = step.value > 0 ? Math.max(15, (step.value / totalGroups) * 100) : 15;
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px', width: '100%' }}>
<div style={{ fontSize: mobile ? '0.8rem' : '0.9rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>
{step.value}
</div>
<div style={{
width: widthPct + '%', height: mobile ? '28px' : '34px', background: step.value > 0 ? step.color : 'var(--color-border, #334155)',
borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'width 0.4s ease', minWidth: '40px', maxWidth: '100%',
opacity: step.value > 0 ? 1 : 0.5,
}}>
<span style={{ fontSize: mobile ? '0.6rem' : '0.72rem', fontWeight: 600, color: step.value > 0 ? '#fff' : 'var(--color-text-muted)', textShadow: step.value > 0 ? '0 1px 2px rgba(0,0,0,0.3)' : 'none' }}>
{pct}%
</span>
</div>
<div style={{ fontSize: mobile ? '0.65rem' : '0.75rem', color: 'var(--color-text-muted)', textAlign: 'center', maxWidth: mobile ? '180px' : '240px' }}>
{step.label}
</div>
{i < funnelSteps.length - 1 && (
<div style={{ width: '2px', height: '4px', background: 'var(--color-border)' }} />
)}
</div> </div>
{i < funnelSteps.length - 1 && ( );
<div style={{ width: '2px', height: '4px', background: 'var(--color-border)' }} /> })}
)}
<div style={{
display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : '1fr 1fr 1fr', gap: '0.5rem',
marginTop: '0.75rem', paddingTop: '0.6rem', borderTop: '1px solid var(--color-border)',
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: '#22c55e', marginBottom: '1px' }}>Полная цепочка</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.1rem', fontWeight: 700, color: '#22c55e' }}>{econ.full_chain_pct ?? 0}%</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: '#f97316', marginBottom: '1px' }}>В обход</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.1rem', fontWeight: 700, color: '#f97316' }}>{econ.bypassed_pct ?? 0}%</div>
</div>
<div style={{ textAlign: 'center', display: mobile ? 'none' : 'block' }}>
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>Завершено</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.1rem', fontWeight: 700, color: 'var(--color-text)' }}>{completedTotal}</div>
</div> </div>
);
})}
<div style={{
display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : '1fr 1fr 1fr', gap: '0.5rem',
marginTop: '0.75rem', paddingTop: '0.6rem', borderTop: '1px solid var(--color-border)',
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: '#22c55e', marginBottom: '1px' }}>Автосогласование</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.05rem', fontWeight: 700, color: '#22c55e' }}>{econ.auto_confirm_pct ?? 0}%</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: '#ef4444', marginBottom: '1px' }}>Ручное вмешательство</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.05rem', fontWeight: 700, color: '#ef4444' }}>{econ.manual_intervention_pct ?? 0}%</div>
</div>
<div style={{ textAlign: 'center', display: mobile ? 'none' : 'block' }}>
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>Всего согласовано</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.05rem', fontWeight: 700, color: 'var(--color-text)' }}>{econ.confirmed_auto_total ?? 0}</div>
</div> </div>
</div> </div>
)}
</Panel>
{/* SMS + Drivers side by side — 4 cols each on desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
<SectionHeader title="SMS" subtitle={periodLabel} mobile={mobile} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
{[
{ label: 'SMS 1', val: econ.sms1_sent_count ?? 0 },
{ label: 'SMS 2', val: econ.sms2_sent_count ?? 0 },
{ label: 'Всего', val: (econ.sms1_sent_count || 0) + (econ.sms2_sent_count || 0) },
].map((item, i) => (
<div key={i} style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '2px', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{item.label}</div>
<div style={{ fontSize: mobile ? '1.1rem' : '1.3rem', fontWeight: 800, color: 'var(--color-text)' }}>{item.val}</div>
</div>
))}
</div> </div>
)} </Panel>
</Panel>
{/* SMS */} {/* Drivers — 8 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}> <Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(8) }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>SMS</h3> <SectionHeader title="По водителям" subtitle={periodLabel} mobile={mobile} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}> {driverData.length === 0 ? (
{[ <div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
{ label: 'SMS 1', val: econ.sms1_sent_count ?? 0 }, ) : (
{ label: 'SMS 2', val: econ.sms2_sent_count ?? 0 }, <ResponsiveContainer width="100%" height={Math.max(180, driverData.length * (mobile ? 35 : 50))}>
{ label: 'Всего', val: (econ.sms1_sent_count || 0) + (econ.sms2_sent_count || 0) }, <BarChart data={driverData} layout="vertical" margin={{ left: mobile ? 5 : 20, right: mobile ? 5 : 30 }}>
].map((item, i) => ( <XAxis type="number" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} />
<div key={i} style={{ textAlign: 'center' }}> <YAxis type="category" dataKey="name" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} width={mobile ? 80 : 130} />
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>{item.label}</div> <Tooltip content={<CustomTooltip />} />
<div style={{ fontSize: mobile ? '1rem' : '1.1rem', fontWeight: 700, color: 'var(--color-text)' }}>{item.val}</div> <Legend wrapperStyle={{ fontSize: fontSize.xs }} />
</div> <Bar dataKey="delivered" name="Доставлено" fill="#22c55e" stackId="a" radius={[0, 0, 0, 0]} />
))} <Bar dataKey="picked_up" name="Вывезено" fill="#14b8a6" stackId="a" radius={[0, 0, 0, 0]} />
<Bar dataKey="problems" name="Проблемы" fill="#ef4444" stackId="a" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</Panel>
{/* Pickup Stats — full width (12 cols) */}
<div style={{ gridColumn: colSpan(12) }}>
<PickupStatsPanel stats={pickupStats} isLoading={pickupLoading} mobile={mobile} fontSize={fontSize} periodLabel={periodLabel} />
</div> </div>
</Panel> </div>
{/* Pickup Stats */}
<PickupStatsPanel stats={pickupStats} isLoading={pickupLoading} mobile={mobile} fontSize={fontSize} />
{/* Drivers */}
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По водителям</h3>
{driverData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</div>
) : (
<ResponsiveContainer width="100%" height={Math.max(150, driverData.length * (mobile ? 35 : 45))}>
<BarChart data={driverData} layout="vertical" margin={{ left: mobile ? 5 : 20, right: mobile ? 5 : 20 }}>
<XAxis type="number" tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} width={driverLabelWidth} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: fontSize.xs }} />
<Bar dataKey="delivered" name="Доставлено" fill="#22c55e" stackId="a" />
<Bar dataKey="problems" name="Проблемы" fill="#ef4444" stackId="a" />
</BarChart>
</ResponsiveContainer>
)}
</Panel>
</div> </div>
); );
}; };

View File

@ -0,0 +1,255 @@
/**
* @file BusinessSchedulePanel.jsx
* @description Admin panel for configuring work days: delivery, pickup, SMS.
* Days stored in business_schedule_settings table as INT[] (1=Mon ... 7=Sun).
*/
import React, { useState, useEffect, useCallback } from "react";
import { Panel } from "../UI/Panel";
import { Button } from "../UI/Button";
import { Badge } from "../UI/Badge";
import { supabase } from "../../supabaseClient";
const DAY_LABELS = [
{ num: 1, label: "Пн", full: "Понедельник" },
{ num: 2, label: "Вт", full: "Вторник" },
{ num: 3, label: "Ср", full: "Среда" },
{ num: 4, label: "Чт", full: "Четверг" },
{ num: 5, label: "Пт", full: "Пятница" },
{ num: 6, label: "Сб", full: "Суббота" },
{ num: 7, label: "Вс", full: "Воскресенье" },
];
const DEFAULT_SCHEDULE = {
deliveryDays: [1, 2, 3, 4, 5],
pickupDays: [1, 2, 3, 4, 5],
smsDays: [1, 2, 3, 4, 5],
};
const DayPills = ({ selectedDays, onToggle, accentColor }) => {
const toggle = (num) => {
if (selectedDays.includes(num)) {
onToggle(selectedDays.filter((d) => d !== num).sort());
} else {
onToggle([...selectedDays, num].sort());
}
};
return (
<div className="flex flex-wrap gap-2">
{DAY_LABELS.map((day) => {
const isSelected = selectedDays.includes(day.num);
return (
<button
key={day.num}
type="button"
onClick={() => toggle(day.num)}
aria-pressed={isSelected}
title={day.full}
className={[
"min-w-[48px] rounded-[14px] border-2 px-3 py-2.5 text-center text-sm font-semibold transition select-none",
isSelected
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
: "border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
].join(" ")}
>
{day.label}
</button>
);
})}
</div>
);
};
export const BusinessSchedulePanel = () => {
const [schedule, setSchedule] = useState(DEFAULT_SCHEDULE);
const [original, setOriginal] = useState(DEFAULT_SCHEDULE);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState("");
const [savedAt, setSavedAt] = useState(null);
const loadSchedule = useCallback(async () => {
setIsLoading(true);
setError("");
try {
const { data, error: rpcError } = await supabase.rpc("get_business_schedule");
if (rpcError) throw rpcError;
if (data?.ok) {
const loaded = {
deliveryDays: data.deliveryDays || DEFAULT_SCHEDULE.deliveryDays,
pickupDays: data.pickupDays || DEFAULT_SCHEDULE.pickupDays,
smsDays: data.smsDays || DEFAULT_SCHEDULE.smsDays,
};
setSchedule(loaded);
setOriginal(loaded);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Не удалось загрузить расписание");
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadSchedule();
}, [loadSchedule]);
const hasChanges =
JSON.stringify(schedule.deliveryDays) !== JSON.stringify(original.deliveryDays) ||
JSON.stringify(schedule.pickupDays) !== JSON.stringify(original.pickupDays) ||
JSON.stringify(schedule.smsDays) !== JSON.stringify(original.smsDays);
const handleSave = async () => {
if (!hasChanges) return;
// Validate at least one day per category
if (schedule.deliveryDays.length === 0 || schedule.pickupDays.length === 0 || schedule.smsDays.length === 0) {
setError("Каждая категория должна иметь хотя бы один рабочий день");
return;
}
setIsSaving(true);
setError("");
try {
const { data, error: rpcError } = await supabase.rpc("update_business_schedule", {
p_delivery_days: schedule.deliveryDays,
p_pickup_days: schedule.pickupDays,
p_sms_days: schedule.smsDays,
});
if (rpcError) throw rpcError;
if (data?.ok) {
setOriginal({ ...schedule });
setSavedAt(new Date());
}
} catch (e) {
setError(e instanceof Error ? e.message : "Не удалось сохранить расписание");
} finally {
setIsSaving(false);
}
};
const formatDays = (days) => {
return days
.sort((a, b) => a - b)
.map((d) => DAY_LABELS.find((dl) => dl.num === d)?.label)
.filter(Boolean)
.join(", ");
};
if (isLoading) {
return (
<Panel className="p-5 sm:p-6">
<p className="text-sm text-[var(--color-text-muted)]">Загрузка расписания…</p>
</Panel>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Расписание</p>
<h1 className="mt-1 text-2xl font-bold leading-tight">Рабочие дни</h1>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Настройте, в какие дни доступны доставка, самовывоз и отправка SMS-приглашений.
</p>
</div>
{savedAt && !hasChanges && (
<Badge tone="accent">Сохранено</Badge>
)}
</div>
{/* Delivery days */}
<Panel className="p-5 sm:p-6 space-y-4">
<div className="flex items-center gap-3">
<span className="text-xl">🚚</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Доставка
</h2>
<p className="text-xs text-[var(--color-text-muted)]">
Дни, в которые клиент может выбрать доставку. В остальные дни доставка не предлагается.
</p>
</div>
</div>
<DayPills
selectedDays={schedule.deliveryDays}
onToggle={(days) => setSchedule((s) => ({ ...s, deliveryDays: days }))}
/>
<p className="text-xs text-[var(--color-text-muted)]">
Выбрано: {formatDays(schedule.deliveryDays) || "—"}
</p>
</Panel>
{/* Pickup days */}
<Panel className="p-5 sm:p-6 space-y-4">
<div className="flex items-center gap-3">
<span className="text-xl">🏪</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Самовывоз
</h2>
<p className="text-xs text-[var(--color-text-muted)]">
Дни, в которые клиент может выбрать самовывоз. В остальные дни самовывоз не предлагается.
</p>
</div>
</div>
<DayPills
selectedDays={schedule.pickupDays}
onToggle={(days) => setSchedule((s) => ({ ...s, pickupDays: days }))}
/>
<p className="text-xs text-[var(--color-text-muted)]">
Выбрано: {formatDays(schedule.pickupDays) || "—"}
</p>
</Panel>
{/* SMS days */}
<Panel className="p-5 sm:p-6 space-y-4">
<div className="flex items-center gap-3">
<span className="text-xl">📨</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
SMS-приглашения
</h2>
<p className="text-xs text-[var(--color-text-muted)]">
Дни, в которые отправляются SMS-приглашения клиентам. В нерабочие дни SMS не отправляются (проверка статусов работает круглосуточно).
</p>
</div>
</div>
<DayPills
selectedDays={schedule.smsDays}
onToggle={(days) => setSchedule((s) => ({ ...s, smsDays: days }))}
/>
<p className="text-xs text-[var(--color-text-muted)]">
Выбрано: {formatDays(schedule.smsDays) || "—"}
</p>
</Panel>
{/* Save */}
<div className="flex items-center gap-4">
<Button
variant={hasChanges ? "primary" : "ghost"}
onClick={handleSave}
disabled={!hasChanges || isSaving}
>
{isSaving ? "Сохранение…" : "Сохранить расписание"}
</Button>
{hasChanges && !isSaving && (
<Button
variant="ghost"
onClick={() => setSchedule({ ...original })}
>
Отменить изменения
</Button>
)}
</div>
{error && (
<Panel className="border border-[var(--color-danger)] bg-[var(--color-surface)] p-4 text-sm text-[var(--color-danger)]">
{error}
</Panel>
)}
</div>
);
};

View File

@ -11,6 +11,9 @@ const PICKUP_COLORS = {
saturday: '#06b6d4', saturday: '#06b6d4',
pickup: '#f59e0b', pickup: '#f59e0b',
delivery: '#6366f1', delivery: '#6366f1',
picked_up: '#14b8a6',
pending: '#94a3b8',
manual: '#eab308',
}; };
const CustomTooltip = ({ active, payload }) => { const CustomTooltip = ({ active, payload }) => {
@ -34,17 +37,30 @@ const CustomTooltip = ({ active, payload }) => {
const ProgressBar = ({ label, value, max, color, fontSize, mobile }) => { const ProgressBar = ({ label, value, max, color, fontSize, mobile }) => {
const pct = max > 0 ? Math.max(0, (value / max) * 100) : 0; const pct = max > 0 ? Math.max(0, (value / max) * 100) : 0;
return ( return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ flex: '0 0 auto', width: mobile ? '70px' : '100px', fontSize: fontSize.xs, color: 'var(--color-text-muted)', textAlign: 'right' }}>{label}</div> <div style={{ flex: '0 0 auto', width: mobile ? '70px' : '110px', fontSize: fontSize.xs, color: 'var(--color-text-muted)', textAlign: 'right' }}>{label}</div>
<div style={{ flex: '1 1 auto', height: mobile ? '16px' : '20px', background: 'var(--color-border, rgba(51,65,85,0.4))', borderRadius: '4px', overflow: 'hidden' }}> <div style={{ flex: '1 1 auto', height: mobile ? '16px' : '22px', background: 'var(--color-border, rgba(51,65,85,0.4))', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{ width: pct + '%', height: '100%', background: color, borderRadius: '4px', transition: 'width 0.4s ease', minWidth: pct > 0 ? '4px' : '0' }} /> <div style={{ width: pct + '%', height: '100%', background: color, borderRadius: '4px', transition: 'width 0.4s ease', minWidth: pct > 0 ? '4px' : '0' }} />
</div> </div>
<div style={{ flex: '0 0 auto', width: mobile ? '36px' : '45px', fontSize: fontSize.s, fontWeight: 600, color: 'var(--color-text)', textAlign: 'left' }}>{value}</div> <div style={{ flex: '0 0 auto', width: mobile ? '36px' : '50px', fontSize: fontSize.s, fontWeight: 700, color: 'var(--color-text)', textAlign: 'left' }}>{value}</div>
</div> </div>
); );
}; };
export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize }) => { const SectionHeader = ({ title, subtitle, mobile }) => (
<div style={{ marginBottom: '0.6rem' }}>
<h3 style={{ fontSize: mobile ? '0.9rem' : '1rem', fontWeight: 700, color: 'var(--color-text)', marginBottom: subtitle ? '0.1rem' : 0 }}>
{title}
</h3>
{subtitle && (
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)' }}>
{subtitle}
</div>
)}
</div>
);
export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize, periodLabel = '' }) => {
if (isLoading) { if (isLoading) {
return ( return (
<Panel> <Panel>
@ -65,103 +81,137 @@ export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize }) => {
); );
} }
const fs = fontSize || { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' }; const fs = fontSize || { xs: '0.72rem', s: '0.78rem', m: '0.85rem', l: '0.95rem' };
const totalPickups = Number(stats.total_pickups) || 0; const totalPickups = Number(stats.total_pickups) || 0;
const pickedUp = Number(stats.picked_up) || 0;
const pending = Number(stats.pending) || 0;
const manual = Number(stats.manual) || 0;
const pickupRate = Number(stats.pickup_rate) || 0; const pickupRate = Number(stats.pickup_rate) || 0;
const avgDays = stats.avg_days_until_pickup != null ? Number(stats.avg_days_until_pickup) : null; const avgDays = stats.avg_days_until_pickup != null ? Number(stats.avg_days_until_pickup) : null;
const dist = stats.delivery_type_dist || {}; const dist = stats.delivery_type_dist || {};
const maxDay = Math.max( const pickupToday = Number(stats.pickup_today) || 0;
Number(stats.pickup_today) || 0, const pickupTomorrow = Number(stats.pickup_tomorrow) || 0;
Number(stats.pickup_tomorrow) || 0, const pickupDayAfter = Number(stats.pickup_day_after) || 0;
Number(stats.pickup_day_after) || 0, const pickupFirstHalf = Number(stats.pickup_first_half) || 0;
1 const pickupSecondHalf = Number(stats.pickup_second_half) || 0;
); const pickupOnSaturday = Number(stats.pickup_on_saturday) || 0;
const maxHalf = Math.max( const hasScheduledPickups = pickupToday + pickupTomorrow + pickupDayAfter > 0;
Number(stats.pickup_first_half) || 0, const hasTimeSlots = pickupFirstHalf + pickupSecondHalf > 0;
Number(stats.pickup_second_half) || 0,
1 const maxDay = Math.max(pickupToday, pickupTomorrow, pickupDayAfter, 1);
); const maxHalf = Math.max(pickupFirstHalf, pickupSecondHalf, 1);
const pieData = [ const pieData = [
{ name: 'Самовывоз', value: Number(dist.pickup) || 0, fill: PICKUP_COLORS.pickup }, { name: 'Самовывоз', value: Number(dist.pickup) || 0, fill: PICKUP_COLORS.pickup },
{ name: 'Доставка', value: Number(dist.delivery) || 0, fill: PICKUP_COLORS.delivery }, { name: 'Доставка', value: Number(dist.delivery) || 0, fill: PICKUP_COLORS.delivery },
].filter(d => d.value > 0); ].filter(d => d.value > 0);
// Desktop: 3-col grid inside panel. Mobile: stacked.
const innerCols = mobile ? '1fr' : '1fr 1fr 1fr';
return ( return (
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}> <Panel style={{ padding: mobile ? '0.75rem' : '1.25rem' }}>
<h3 style={{ fontSize: fs.l, fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}> <SectionHeader title="📦 Самовывоз" subtitle={periodLabel} mobile={mobile} />
📦 Самовывоз
</h3>
{/* KPI row */} {/* KPI row */}
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr 1fr' : '1fr 1fr 1fr', gap: '0.5rem', marginBottom: '0.75rem' }}> <div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr 1fr 1fr' : 'repeat(auto-fit, minmax(120px, 1fr))', gap: '0.5rem', marginBottom: '1rem' }}>
{[ {[
{ label: 'Всего самовывоз', val: totalPickups, color: '#f59e0b' }, { label: 'Всего', val: totalPickups, color: '#f59e0b' },
{ label: 'Доля самовывоза', val: pickupRate + '%', color: '#f59e0b' }, { label: 'Завершено', val: pickedUp, color: '#14b8a6' },
{ label: 'Ср. дней до выдачи', val: avgDays !== null ? avgDays : '—', color: '#3b82f6' }, { label: 'Ожидает', val: pending, color: '#94a3b8' },
{ label: 'Доля', val: pickupRate + '%', color: '#f59e0b' },
].map((kpi, i) => ( ].map((kpi, i) => (
<div key={i} style={{ textAlign: 'center' }}> <div key={i} style={{ textAlign: 'center', padding: '0.4rem 0.2rem' }}>
<div style={{ fontSize: fs.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>{kpi.label}</div> <div style={{ fontSize: fs.xs, color: 'var(--color-text-muted)', marginBottom: '2px', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{kpi.label}</div>
<div style={{ fontSize: mobile ? '1rem' : '1.2rem', fontWeight: 700, color: kpi.color }}>{kpi.val}</div> <div style={{ fontSize: mobile ? '1.05rem' : '1.3rem', fontWeight: 800, color: kpi.color }}>{kpi.val}</div>
</div> </div>
))} ))}
</div> </div>
{/* Distribution by day */} {/* Content grid: status breakdown | schedule | donut */}
<div style={{ marginBottom: '0.6rem' }}> <div style={{ display: 'grid', gridTemplateColumns: innerCols, gap: mobile ? '0.75rem' : '1.5rem' }}>
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>По дням</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<ProgressBar label="Сегодня" value={Number(stats.pickup_today) || 0} max={maxDay} color={PICKUP_COLORS.today} fontSize={fs} mobile={mobile} />
<ProgressBar label="Завтра" value={Number(stats.pickup_tomorrow) || 0} max={maxDay} color={PICKUP_COLORS.tomorrow} fontSize={fs} mobile={mobile} />
<ProgressBar label="Послезавтра" value={Number(stats.pickup_day_after) || 0} max={maxDay} color={PICKUP_COLORS.dayAfter} fontSize={fs} mobile={mobile} />
</div>
</div>
{/* Half-day split */} {/* Status breakdown */}
<div style={{ marginBottom: '0.6rem' }}>
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>По времени</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<ProgressBar label="До обеда" value={Number(stats.pickup_first_half) || 0} max={maxHalf} color={PICKUP_COLORS.firstHalf} fontSize={fs} mobile={mobile} />
<ProgressBar label="После обеда" value={Number(stats.pickup_second_half) || 0} max={maxHalf} color={PICKUP_COLORS.secondHalf} fontSize={fs} mobile={mobile} />
</div>
</div>
{/* Saturday */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.3rem 0', borderTop: '1px solid var(--color-border)', marginBottom: '0.6rem' }}>
<span style={{ fontSize: fs.s, color: 'var(--color-text-muted)' }}>Самовывоз в субботу</span>
<span style={{ fontSize: fs.l, fontWeight: 700, color: PICKUP_COLORS.saturday }}>{Number(stats.pickup_on_saturday) || 0}</span>
</div>
{/* Delivery vs Pickup donut */}
{pieData.length > 0 && (
<div> <div>
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>Доставка vs Самовывоз</div> <div style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>По статусам</div>
<ResponsiveContainer width="100%" height={mobile ? 140 : 170}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<PieChart> <ProgressBar label="Завершено" value={pickedUp} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.picked_up} fontSize={fs} mobile={mobile} />
<Pie data={pieData} cx="50%" cy="50%" <ProgressBar label="Ожидает" value={pending} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.pending} fontSize={fs} mobile={mobile} />
innerRadius={mobile ? 30 : 40} <ProgressBar label="Ручное" value={manual} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.manual} fontSize={fs} mobile={mobile} />
outerRadius={mobile ? 55 : 70} </div>
dataKey="value" nameKey="name" paddingAngle={2} {avgDays !== null && avgDays > 0 && (
> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.4rem 0', marginTop: '0.3rem', borderTop: '1px solid var(--color-border)' }}>
{pieData.map((entry, i) => ( <span style={{ fontSize: fs.s, color: 'var(--color-text-muted)' }}>Ср. дней до выдачи</span>
<Cell key={i} fill={entry.fill} /> <span style={{ fontSize: fs.l, fontWeight: 700, color: '#3b82f6' }}>{avgDays}</span>
))} </div>
</Pie> )}
<Tooltip content={<CustomTooltip />} /> </div>
</PieChart>
</ResponsiveContainer> {/* Schedule — only show if there are scheduled pickups */}
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', marginTop: '0.2rem' }}> <div>
{pieData.map((d, i) => ( <div style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>Расписание</div>
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: fs.xs }}> {hasScheduledPickups ? (
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: d.fill }} /> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem', marginBottom: '0.5rem' }}>
<span style={{ color: 'var(--color-text-muted)' }}>{d.name}: <strong style={{ color: 'var(--color-text)' }}>{d.value}</strong></span> <ProgressBar label="Сегодня" value={pickupToday} max={maxDay} color={PICKUP_COLORS.today} fontSize={fs} mobile={mobile} />
</div> <ProgressBar label="Завтра" value={pickupTomorrow} max={maxDay} color={PICKUP_COLORS.tomorrow} fontSize={fs} mobile={mobile} />
))} <ProgressBar label="Послезавтра" value={pickupDayAfter} max={maxDay} color={PICKUP_COLORS.dayAfter} fontSize={fs} mobile={mobile} />
</div>
) : (
<div style={{ fontSize: fs.s, color: 'var(--color-text-muted)', padding: '0.5rem 0', marginBottom: '0.5rem' }}>
Нет запланированных самовывозов
</div>
)}
{hasTimeSlots && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<ProgressBar label="До обеда" value={pickupFirstHalf} max={maxHalf} color={PICKUP_COLORS.firstHalf} fontSize={fs} mobile={mobile} />
<ProgressBar label="После обеда" value={pickupSecondHalf} max={maxHalf} color={PICKUP_COLORS.secondHalf} fontSize={fs} mobile={mobile} />
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.4rem 0', marginTop: '0.3rem', borderTop: '1px solid var(--color-border)' }}>
<span style={{ fontSize: fs.s, color: 'var(--color-text-muted)' }}>Самовывоз в субботу</span>
<span style={{ fontSize: fs.l, fontWeight: 700, color: PICKUP_COLORS.saturday }}>{pickupOnSaturday}</span>
</div> </div>
</div> </div>
)}
{/* Delivery vs Pickup donut */}
<div>
<div style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>Доставка vs Самовывоз</div>
{pieData.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={mobile ? 140 : 180}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%"
innerRadius={mobile ? 30 : 45}
outerRadius={mobile ? 55 : 75}
dataKey="value" nameKey="name" paddingAngle={2}
>
{pieData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
</PieChart>
</ResponsiveContainer>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', marginTop: '0.3rem' }}>
{pieData.map((d, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: fs.xs }}>
<div style={{ width: '10px', height: '10px', borderRadius: '2px', background: d.fill }} />
<span style={{ color: 'var(--color-text-muted)' }}>{d.name}: <strong style={{ color: 'var(--color-text)' }}>{d.value}</strong></span>
</div>
))}
</div>
</>
) : (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem 0', fontSize: fs.s }}>
Нет данных
</div>
)}
</div>
</div>
</Panel> </Panel>
); );
}; };

View File

@ -36,24 +36,27 @@ const getCrimeaHour = (referenceDate = new Date()) => {
); );
}; };
const isWeekend = (dateKey) => { const isAllowedPickupDay = (dateKey, allowedDays = [1,2,3,4,5]) => {
if (!dateKey) return false;
const d = new Date(`${dateKey}T12:00:00Z`); const d = new Date(`${dateKey}T12:00:00Z`);
const day = d.getUTCDay(); // getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
return day === 0 || day === 6; // allowedDays uses 1=Mon ... 7=Sun
const dayNum = d.getUTCDay() === 0 ? 7 : d.getUTCDay();
return allowedDays.includes(dayNum);
}; };
const getNextWorkday = (dateKey) => { const getNextPickupWorkday = (dateKey, allowedDays = [1,2,3,4,5]) => {
let next = addDaysKey(dateKey, 1); let next = addDaysKey(dateKey, 1);
while (isWeekend(next)) { while (!isAllowedPickupDay(next, allowedDays)) {
next = addDaysKey(next, 1); next = addDaysKey(next, 1);
} }
return next; return next;
}; };
const getPickupSlots = (referenceDate = new Date()) => { const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5]) => {
const todayKey = getCrimeaTodayKey(referenceDate); const todayKey = getCrimeaTodayKey(referenceDate);
const hour = getCrimeaHour(referenceDate); const hour = getCrimeaHour(referenceDate);
const isTodayWorkday = !isWeekend(todayKey); const isTodayWorkday = isAllowedPickupDay(todayKey, pickupDays);
const slots = []; const slots = [];
@ -77,7 +80,7 @@ const getPickupSlots = (referenceDate = new Date()) => {
} }
const tomorrow = addDaysKey(todayKey, 1); const tomorrow = addDaysKey(todayKey, 1);
const tomorrowWorkday = !isWeekend(tomorrow) ? tomorrow : getNextWorkday(todayKey); const tomorrowWorkday = isAllowedPickupDay(tomorrow, pickupDays) ? tomorrow : getNextPickupWorkday(todayKey, pickupDays);
slots.push({ slots.push({
id: `pickup-${tomorrowWorkday}-first`, id: `pickup-${tomorrowWorkday}-first`,
date: tomorrowWorkday, date: tomorrowWorkday,
@ -94,7 +97,7 @@ const getPickupSlots = (referenceDate = new Date()) => {
}); });
const dayAfter = addDaysKey(tomorrowWorkday, 1); const dayAfter = addDaysKey(tomorrowWorkday, 1);
const dayAfterWorkday = !isWeekend(dayAfter) ? dayAfter : getNextWorkday(dayAfter); const dayAfterWorkday = isAllowedPickupDay(dayAfter, pickupDays) ? dayAfter : getNextPickupWorkday(dayAfter, pickupDays);
slots.push({ slots.push({
id: `pickup-${dayAfterWorkday}-first`, id: `pickup-${dayAfterWorkday}-first`,
date: dayAfterWorkday, date: dayAfterWorkday,
@ -152,8 +155,9 @@ export const PickupSlotsPicker = ({
onSelectSlot, onSelectSlot,
selectedSlotId, selectedSlotId,
referenceDate = new Date(), referenceDate = new Date(),
pickupDays = [1, 2, 3, 4, 5],
}) => { }) => {
const slots = React.useMemo(() => getPickupSlots(referenceDate), [referenceDate]); const slots = React.useMemo(() => getPickupSlots(referenceDate, pickupDays), [referenceDate, pickupDays]);
if (!slots.length) { if (!slots.length) {
return ( return (

View File

@ -74,21 +74,18 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : []; const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : [];
const currentStatus = order.status; const currentStatus = order.status;
const IN_TRANSIT_STATUSES = ["Загружен", "В пути"];
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
let actionButtons = []; let actionButtons = [];
if (currentStatus === "Назначен водитель") { if (currentStatus === "Назначен водитель") {
actionButtons = [
{ value: "Загружен", label: "Загружено" },
{ value: "Проблема доставки", label: "Проблема" },
];
} else if (isOnRoute) {
actionButtons = [ actionButtons = [
{ value: "Доставлен", label: "Доставлено" }, { value: "Доставлен", label: "Доставлено" },
{ value: "Проблема доставки", label: "Проблема" }, { value: "Проблема доставки", label: "Проблема" },
]; ];
} else if (currentStatus === "Доставлен" || currentStatus === "Проблема доставки" || currentStatus === "Закрыт" || currentStatus === "Отменён") { } else if (currentStatus === "Доставлен") {
actionButtons = [
{ value: "Назначен водитель", label: "Вернуть в работу" },
];
} else if (currentStatus === "Проблема доставки" || currentStatus === "Закрыт" || currentStatus === "Отменён") {
actionButtons = []; actionButtons = [];
} else { } else {
actionButtons = availableTransitions.map((status) => ({ actionButtons = availableTransitions.map((status) => ({
@ -98,7 +95,7 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
} }
return ( return (
<div className="space-y-4"> <div className="space-y-4 fs-zone-card">
{showProblemModal && ( {showProblemModal && (
<ProblemReasonModal <ProblemReasonModal
onSelect={(reasonValue, reasonLabel) => { onSelect={(reasonValue, reasonLabel) => {

View File

@ -261,7 +261,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
} }
return ( return (
<div className="space-y-4"> <div className="space-y-4 fs-zone-table">
<Panel className="space-y-3 p-5"> <Panel className="space-y-3 p-5">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">

View File

@ -138,11 +138,17 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
const [comments, setComments] = React.useState(initialComments); const [comments, setComments] = React.useState(initialComments);
const [commentInput, setCommentInput] = React.useState(""); const [commentInput, setCommentInput] = React.useState("");
// Sync state when order data changes (e.g. after save) // Sync state ONLY when order ID changes (navigating to different order), not on every order data update
const orderId = order?.id;
const prevOrderId = React.useRef(orderId);
React.useEffect(() => { React.useEffect(() => {
setShippedItems(initialShippedIds); if (prevOrderId.current !== orderId) {
setComments(initialComments); prevOrderId.current = orderId;
}, [initialShippedIds, initialComments]); setShippedItems(initialShippedIds);
setComments(initialComments);
setJustSaved(false);
}
}, [orderId, initialShippedIds, initialComments]);
// Track last saved shipment data for visual display // Track last saved shipment data for visual display
React.useEffect(() => { React.useEffect(() => {
@ -170,7 +176,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
}; };
const currentDeliveryStatus = order?.deliveryStatus || order?.delivery_status; const currentDeliveryStatus = order?.deliveryStatus || order?.delivery_status;
const isStatusFinal = ["delivered", "problem", "picked_up"].includes(currentDeliveryStatus); const isStatusFinal = ["delivered", "problem", "picked_up", "loaded", "on_route", "driver_assigned"].includes(currentDeliveryStatus);
const unshipAll = () => { const unshipAll = () => {
if (isStatusFinal && onResetStatus) { if (isStatusFinal && onResetStatus) {
@ -222,7 +228,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
if (items.length === 0) { if (items.length === 0) {
return ( return (
<Panel className="space-y-3 p-5"> <Panel className="space-y-3 p-5 fs-zone-card">
<strong>Состав заказа</strong> <strong>Состав заказа</strong>
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p> <p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
</Panel> </Panel>
@ -230,7 +236,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
} }
return ( return (
<Panel className="space-y-4 p-5"> <Panel className="space-y-4 p-5 fs-zone-card">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<strong>Отгрузка</strong> <strong>Отгрузка</strong>
@ -250,7 +256,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
Отгрузить всё Отгрузить всё
</Button> </Button>
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0 && !isStatusFinal}> <Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0 && !isStatusFinal}>
Сбросить Сбросить отгрузку
</Button> </Button>
</div> </div>

View File

@ -158,12 +158,12 @@ const renderRow = (group, onSelectSet) => (
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>} {group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
</div> </div>
<div className="px-3 py-1.5"> <div className="px-3 py-1.5">
<div className="flex items-center gap-1.5"> <div className="flex flex-col gap-1">
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge> <Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
{(group.hasDeliveryProblem || group.has_delivery_problem) && ( {(group.hasDeliveryProblem || group.has_delivery_problem) && (
<span <span
title={group.deliveryProblemNote || group.delivery_problem_note || "Есть проблемы с отгрузкой позиций"} title={group.deliveryProblemNote || group.delivery_problem_note || "Есть проблемы с отгрузкой позиций"}
className="inline-flex items-center gap-0.5 rounded-full bg-[rgba(239,68,68,0.12)] px-1.5 py-0.5 text-[10px] font-bold text-[var(--color-danger)]" className="inline-flex w-fit items-center gap-0.5 rounded-full bg-[rgba(239,68,68,0.12)] px-1.5 py-0.5 text-[10px] font-bold text-[var(--color-danger)]"
> >
⚠ Проблема ⚠ Проблема
</span> </span>
@ -255,7 +255,17 @@ const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, on
}; };
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => { export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" }); const FILTERS_KEY = 'logistics-board-filters';
const [filters, setFilters] = React.useState(() => {
try {
const raw = localStorage.getItem(FILTERS_KEY);
if (raw) return { query: '', displayStatus: 'all', city: '', ...JSON.parse(raw) };
} catch {}
return { query: '', displayStatus: 'all', city: '' };
});
React.useEffect(() => {
try { localStorage.setItem(FILTERS_KEY, JSON.stringify(filters)); } catch {}
}, [filters]);
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections()); const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
const [sectionOrder, setSectionOrder] = React.useState(() => { const [sectionOrder, setSectionOrder] = React.useState(() => {
const custom = loadCustomOrder(); const custom = loadCustomOrder();

View File

@ -58,7 +58,7 @@ const CalendarWidget = ({
}) => { }) => {
return ( return (
<div className={layoutClassName}> <div className={layoutClassName}>
<div className={calendarClassName}> <div className={(calendarClassName || "") + " relative"}>
<Button <Button
variant="ghost" variant="ghost"
aria-label={label} aria-label={label}

View File

@ -122,7 +122,7 @@ const DriverAssignmentPanel = ({
Водитель назначен Водитель назначен
</p> </p>
<p className="mt-1 text-lg font-semibold"> <p className="mt-1 text-lg font-semibold">
{order.assignedDriverName || "Неизвестно"} {order.assignedDriverName || drivers.find((d) => d.id === order.assignedDriverId)?.name || "Водитель назначен"}
</p> </p>
</div> </div>
<Badge tone="accent">Назначен</Badge> <Badge tone="accent">Назначен</Badge>

View File

@ -2,42 +2,60 @@
const DriverShipmentReport = ({ shipmentData }) => { const DriverShipmentReport = ({ shipmentData }) => {
if (!Array.isArray(shipmentData) || shipmentData.length === 0) return null; if (!Array.isArray(shipmentData) || shipmentData.length === 0) return null;
const deliveredItems = shipmentData.filter((i) => i.shipped);
const notDeliveredItems = shipmentData.filter((i) => !i.shipped);
return ( return (
<Panel className="space-y-4 p-5 border-[var(--color-warning)]"> <Panel className="space-y-4 p-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg className="h-5 w-5 text-[var(--color-warning)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <span className="text-lg">📋</span>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <strong>Отчёт об отгрузке</strong>
</svg> <Badge tone={notDeliveredItems.length > 0 ? "warning" : "accent"}>
<strong className="text-[var(--color-warning)]">Проблемы с доставкой позиций</strong> {deliveredItems.length}/{shipmentData.length} доставлено
</Badge>
</div> </div>
<p className="text-sm text-[var(--color-text-muted)]">
Не доставлено {shipmentData.length} {shipmentData.length === 1 ? "позиция" : shipmentData.length < 5 ? "позиции" : "позиций"}. Остальное — доставлено. {notDeliveredItems.length > 0 && (
</p> <div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-3 py-2 text-sm text-[var(--color-warning)]">
⚠ Не доставлено {notDeliveredItems.length} {notDeliveredItems.length === 1 ? "позиция" : notDeliveredItems.length < 5 ? "позиции" : "позиций"}
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
{shipmentData.map((item) => ( {shipmentData.map((item) => (
<div <div
key={item.id || item.name} key={item.id || item.name}
className="rounded-[18px] border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-4 py-3 text-sm" className={[
"rounded-[18px] border px-4 py-3 text-sm",
item.shipped
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
: "border-[var(--color-warning)] bg-[var(--color-warning-soft)]"
].join(" ")}
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-[var(--color-text)]">{item.name}</span> <div className="flex items-center gap-2">
<span className={item.shipped ? "text-[var(--color-accent)]" : "text-[var(--color-warning)]"}>
{item.shipped ? "✓" : "✗"}
</span>
<span className={item.shipped ? "text-[var(--color-text-muted)] line-through" : "text-[var(--color-text)] font-medium"}>
{item.name}
</span>
</div>
{item.quantity || item.unit ? ( {item.quantity || item.unit ? (
<Badge tone="neutral">{[item.quantity, item.unit].filter(Boolean).join(" ")}</Badge> <Badge tone="neutral">{[item.quantity, item.unit].filter(Boolean).join(" ")}</Badge>
) : null} ) : null}
</div> </div>
{item.comment ? ( {!item.shipped && item.comment && (
<p className="mt-1 text-xs text-[var(--color-text-muted)]">Причина: {item.comment}</p> <p className="mt-1 ml-6 text-xs text-[var(--color-warning)]">Причина: {item.comment}</p>
) : (
<p className="mt-1 text-xs text-[var(--color-text-muted)] italic">Причина не указана</p>
)} )}
</div> </div>
))} ))}
</div> </div>
</Panel> </Panel>
); );
}; };
import React from "react"; import React from "react";
import { formatDateTime } from "../../utils/formatters"; import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
@ -54,7 +72,11 @@ import {
getOrderGroupStatusTone, getOrderGroupStatusTone,
DELIVERY_GROUP_STATUS_LABELS, DELIVERY_GROUP_STATUS_LABELS,
} from "../../services/orderGroupViews"; } from "../../services/orderGroupViews";
import { getErrorMessage, normalizeNom } from "../../utils/deliveryUtils"; import {
getErrorMessage,
normalizeNom,
} from "../../utils/deliveryUtils";
import { SmsStatusCard } from "./SmsStatusCard";
const fmtTime = (ts) => { const fmtTime = (ts) => {
if (!ts) return "—"; if (!ts) return "—";
@ -331,6 +353,12 @@ const normalizeDateForInput = (value) => {
return `20${year}-${month}-${day}`; return `20${year}-${month}-${day}`;
} }
const fullDateMatch = normalized.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (fullDateMatch) {
const [, day, month, year] = fullDateMatch;
return `${year}-${month}-${day}`;
}
return ""; return "";
}; };
@ -594,11 +622,19 @@ export const OrderDetailPanel = ({
setIsSavingShipment(false); setIsSavingShipment(false);
} }
}, [onSaveShipmentData, order?.id]); }, [onSaveShipmentData, order?.id]);
const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []); const minSelectableDateKey = React.useMemo(() => {
// Logistician/admin can set past dates for retroactive delivery closing
if (["logistician", "admin", "mega_admin", "manager"].includes(userRole)) {
const past = new Date();
past.setMonth(past.getMonth() - 3);
return toDateKey(past);
}
return getNextSelectableDateKey();
}, [userRole]);
const [currentMonth, setCurrentMonth] = React.useState(() => { const [currentMonth, setCurrentMonth] = React.useState(() => {
const existingDeliveryDate = fromDateKey(order?.deliveryDate); const existingDeliveryDate = fromDateKey(order?.deliveryDate);
const fallbackDate = fromDateKey(minSelectableDateKey) || new Date(); const fallbackDate = fromDateKey(minSelectableDateKey) || new Date();
const sourceDate = existingDeliveryDate && isFutureDeliveryDate(toDateKey(existingDeliveryDate)) const sourceDate = existingDeliveryDate
? existingDeliveryDate ? existingDeliveryDate
: fallbackDate; : fallbackDate;
@ -613,7 +649,10 @@ export const OrderDetailPanel = ({
}), }),
[currentMonth], [currentMonth],
); );
const canGoBack = toDateKey(currentMonth) > toDateKey(startOfMonth(fromDateKey(minSelectableDateKey) || new Date())); const canGoBack = (() => {
if (["logistician", "admin", "mega_admin", "manager"].includes(userRole)) return true;
return toDateKey(currentMonth) > toDateKey(startOfMonth(fromDateKey(minSelectableDateKey) || new Date()));
})();
React.useEffect(() => { React.useEffect(() => {
setSelectedDriverId(order?.assignedDriverId || ""); setSelectedDriverId(order?.assignedDriverId || "");
@ -621,9 +660,12 @@ export const OrderDetailPanel = ({
}, [order?.id, order?.assignedDriverId]); }, [order?.id, order?.assignedDriverId]);
React.useEffect(() => { React.useEffect(() => {
const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate); const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate || order?.customerDate);
const nextSelectableDateKey = getNextSelectableDateKey(); const nextSelectableDateKey = getNextSelectableDateKey();
const selectedDateKey = isFutureDeliveryDate(normalizedDeliveryDate) ? normalizedDeliveryDate : nextSelectableDateKey; const canUsePastDate = ["logistician", "admin", "mega_admin", "manager"].includes(userRole);
const selectedDateKey = (normalizedDeliveryDate && (canUsePastDate || isFutureDeliveryDate(normalizedDeliveryDate)))
? normalizedDeliveryDate
: nextSelectableDateKey;
setDeliveryDate(selectedDateKey); setDeliveryDate(selectedDateKey);
const selectedDate = fromDateKey(selectedDateKey) || new Date(); const selectedDate = fromDateKey(selectedDateKey) || new Date();
setCurrentMonth(startOfMonth(selectedDate)); setCurrentMonth(startOfMonth(selectedDate));
@ -686,7 +728,8 @@ export const OrderDetailPanel = ({
return; return;
} }
if (!isFutureDeliveryDate(effectiveDate)) { const canUsePastDate = ["logistician", "admin", "mega_admin", "manager"].includes(userRole);
if (!canUsePastDate && !isFutureDeliveryDate(effectiveDate)) {
setFormMessage(deliveryType === "pickup" ? "Выберите дату самовывоза позже сегодняшнего дня." : "Выберите дату доставки позже сегодняшнего дня."); setFormMessage(deliveryType === "pickup" ? "Выберите дату самовывоза позже сегодняшнего дня." : "Выберите дату доставки позже сегодняшнего дня.");
return; return;
} }
@ -889,10 +932,7 @@ export const OrderDetailPanel = ({
<p className="text-xs text-[var(--color-text-muted)]">Обновлена</p> <p className="text-xs text-[var(--color-text-muted)]">Обновлена</p>
<p className="font-medium !text-[var(--color-text)]">{formatDateTime(order.updatedAt)}</p> <p className="font-medium !text-[var(--color-text)]">{formatDateTime(order.updatedAt)}</p>
</div> </div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">{isPickupOrder ? "Статус самовывоза" : "Статус доставки"}</p>
<p className="font-medium !text-[var(--color-text)]">{getOrderGroupDeliveryStatusLabel(order.deliveryStatus || order.delivery_status)}</p>
</div>
{(order.pickupCode || order.pickup_code) && (order.deliveryType === "pickup" || order.delivery_type === "pickup") ? ( {(order.pickupCode || order.pickup_code) && (order.deliveryType === "pickup" || order.delivery_type === "pickup") ? (
<div> <div>
<p className="text-xs text-[var(--color-text-muted)]">Код выдачи</p> <p className="text-xs text-[var(--color-text-muted)]">Код выдачи</p>
@ -1053,6 +1093,7 @@ export const OrderDetailPanel = ({
/> />
)} )}
<SmsStatusCard order={order} userRole={userRole} />
<StatusActionPanel <StatusActionPanel
order={order} order={order}
@ -1087,7 +1128,7 @@ export const OrderDetailPanel = ({
/> />
) : null} ) : null}
{userRole === "driver" && order ? ( {["driver", "logistician", "admin", "mega_admin"].includes(userRole) && order ? (
<DriverShipmentPanel <DriverShipmentPanel
order={order} order={order}
onShipmentChange={handleShipmentChange} onShipmentChange={handleShipmentChange}
@ -1097,7 +1138,7 @@ export const OrderDetailPanel = ({
if (onChangeDeliveryStatus) { if (onChangeDeliveryStatus) {
onChangeDeliveryStatus({ onChangeDeliveryStatus({
orderGroupId: order.id, orderGroupId: order.id,
status: "loaded", status: "driver_assigned",
}).then((response) => { }).then((response) => {
if (!response.success) { if (!response.success) {
setFormMessage(response.error || "Не удалось сбросить статус"); setFormMessage(response.error || "Не удалось сбросить статус");
@ -1131,15 +1172,15 @@ export const OrderDetailPanel = ({
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{(() => { {(() => {
const currentStatus = order.deliveryStatus || order.delivery_status; const currentStatus = order.deliveryStatus || order.delivery_status;
const IN_TRANSIT_STATUSES = ["loaded", "on_route"];
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
const isPickup = (order.deliveryType || order.delivery_type) === "pickup" || currentStatus === "pickup"; const isPickup = (order.deliveryType || order.delivery_type) === "pickup" || currentStatus === "pickup";
let statusOptions = []; let statusOptions = [];
if (currentStatus === "delivered" || currentStatus === "picked_up" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") { if (currentStatus === "delivered" || currentStatus === "picked_up" || currentStatus === "problem") {
// Final statuses — show "Return to work" instead
statusOptions = [];
} else if (currentStatus === "cancelled" || currentStatus === "paid_storage") {
statusOptions = []; statusOptions = [];
} else { } else {
// Primary button matches delivery type, secondary requires confirmation
if (isPickup) { if (isPickup) {
statusOptions = [ statusOptions = [
{ value: "picked_up", label: "Вывезено", mismatch: false }, { value: "picked_up", label: "Вывезено", mismatch: false },
@ -1155,7 +1196,6 @@ export const OrderDetailPanel = ({
} }
} }
// "Return to work" button for final statuses
const canReturn = ["delivered", "picked_up", "problem"].includes(currentStatus); const canReturn = ["delivered", "picked_up", "problem"].includes(currentStatus);
if (statusOptions.length === 0 && !canReturn) return null; if (statusOptions.length === 0 && !canReturn) return null;
@ -1163,6 +1203,7 @@ export const OrderDetailPanel = ({
return statusOptions.map((statusOption) => { return statusOptions.map((statusOption) => {
const isSelected = pendingStatus?.value === statusOption.value; const isSelected = pendingStatus?.value === statusOption.value;
const isMismatch = statusOption.mismatch; const isMismatch = statusOption.mismatch;
const blockedBySchedule = statusOption.requiresSchedule && !hasDeliverySchedule;
return ( return (
<Button <Button
key={statusOption.value} key={statusOption.value}
@ -1196,7 +1237,7 @@ export const OrderDetailPanel = ({
disabled={isSavingStatusChange} disabled={isSavingStatusChange}
onClick={() => { onClick={() => {
setPendingStatus({ setPendingStatus({
value: "loaded", value: "driver_assigned",
label: "Вернуть в работу", label: "Вернуть в работу",
mismatch: false, mismatch: false,
deliveryType: "delivery", deliveryType: "delivery",
@ -1303,7 +1344,7 @@ export const OrderDetailPanel = ({
<CollapsibleOrderComposition order={order} /> <CollapsibleOrderComposition order={order} />
</Panel> </Panel>
{userRole !== "driver" && order?.deliveryLink ? ( {order?.deliveryLink ? (
<Panel className="space-y-3 p-5"> <Panel className="space-y-3 p-5">
<strong>Ссылка на согласование</strong> <strong>Ссылка на согласование</strong>
<p className="text-sm text-[var(--color-text-muted)]"> <p className="text-sm text-[var(--color-text-muted)]">

View File

@ -22,6 +22,8 @@ const NOTIF_LABELS = {
paid_storage_sending: "Отправляется…", paid_storage_sending: "Отправляется…",
paid_storage_sent: "Платное хранение: отправлено", paid_storage_sent: "Платное хранение: отправлено",
draft: "Черновик", draft: "Черновик",
confirmed: "Клиент согласовал дату",
completed: "Завершено",
}; };
const NOTIF_TONES = { const NOTIF_TONES = {
@ -36,6 +38,8 @@ const NOTIF_TONES = {
paid_storage_sending: "info", paid_storage_sending: "info",
paid_storage_sent: "accent", paid_storage_sent: "accent",
draft: "neutral", draft: "neutral",
confirmed: "accent",
completed: "neutral",
}; };
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
@ -173,11 +177,8 @@ export const SmsStatusCard = ({ order, userRole }) => {
return ( return (
<Panel className="p-4"> <Panel className="p-4">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3">
<h3 className="text-sm font-semibold text-[var(--color-text)]">📱 SMS-уведомления</h3> <h3 className="text-sm font-semibold text-[var(--color-text)]">📱 SMS-уведомления</h3>
<Badge tone={NOTIF_TONES[notifStatus] || "neutral"}>
{NOTIF_LABELS[notifStatus] || notifStatus}
</Badge>
</div> </div>
{/* Timeline */} {/* Timeline */}
@ -188,9 +189,9 @@ export const SmsStatusCard = ({ order, userRole }) => {
<div className="flex-1"> <div className="flex-1">
<div className="text-[var(--color-text)]">1-е SMS</div> <div className="text-[var(--color-text)]">1-е SMS</div>
{hasFirstSms ? ( {hasFirstSms ? (
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} ✓ доставлено</div> <div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} ✓ получено клиентом</div>
) : hasSmsSent && notifStatus === "sms_sending" ? ( ) : hasSmsSent && notifStatus === "sms_sending" ? (
<div className="text-[var(--color-text-muted)]">{fmtTime(smsSentAt)} · отправлено, ждём подтверждения…</div> <div className="text-[var(--color-text-muted)]">{fmtTime(smsSentAt)} · отправлено, ждём ответ оператора…</div>
) : notifStatus === "link_ready" ? ( ) : notifStatus === "link_ready" ? (
<div className="text-[var(--color-text-muted)]">в очереди на отправку</div> <div className="text-[var(--color-text-muted)]">в очереди на отправку</div>
) : ( ) : (
@ -205,7 +206,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
<div className="flex-1"> <div className="flex-1">
<div className="text-[var(--color-text)]">2-е SMS</div> <div className="text-[var(--color-text)]">2-е SMS</div>
{hasSecondSms ? ( {hasSecondSms ? (
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)}</div> <div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)} ✓ получено клиентом</div>
) : notifStatus === "first_sms_sent" && countdown ? ( ) : notifStatus === "first_sms_sent" && countdown ? (
<div className="text-[var(--color-text-muted)]"> <div className="text-[var(--color-text-muted)]">
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span> отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
@ -260,6 +261,23 @@ export const SmsStatusCard = ({ order, userRole }) => {
</div> </div>
)} )}
{/* Client page access info */}
{(order.invitationAccessCount > 0 || order.invitationOpenedAt) ? (
<div className="mt-2 flex items-center gap-2 rounded-lg bg-[var(--color-surface-strong)] px-2 py-1.5 text-[11px]">
<span className="text-[var(--color-text-muted)]">👁 Клиент открывал страницу согласования</span>
<span className="font-medium text-[var(--color-text)]">{order.invitationAccessCount || 1} раз</span>
{(order.invitationLastAccessedAt || order.invitationOpenedAt) && (
<span className="text-[var(--color-text-muted)]">
· последний: {fmtTime(order.invitationLastAccessedAt || order.invitationOpenedAt)}
</span>
)}
</div>
) : (
<div className="mt-2 flex items-center gap-2 rounded-lg bg-[var(--color-surface-strong)] px-2 py-1.5 text-[11px] text-[var(--color-text-muted)]">
📭 Клиент ещё не открывал страницу согласования
</div>
)}
{/* Restart buttons */} {/* Restart buttons */}
{canManage && ( {canManage && (
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3"> <div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">

View File

@ -4,8 +4,6 @@ import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { DELIVERY_GROUP_STATUS_LABELS } from "../../services/orderGroupViews"; import { DELIVERY_GROUP_STATUS_LABELS } from "../../services/orderGroupViews";
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
const StatusActionPanel = ({ const StatusActionPanel = ({
order, order,
userRole, userRole,
@ -18,6 +16,48 @@ const StatusActionPanel = ({
} }
const currentStatus = order.deliveryStatus || order.delivery_status; const currentStatus = order.deliveryStatus || order.delivery_status;
const isPickup = (order.deliveryType || order.delivery_type) === "pickup" || currentStatus === "pickup";
// Check delivery schedule
const hasDeliveryDate = !!(order.deliveryDate || order.customerDate);
const hasDeliveryHalfDay = !!(order.deliveryTime || order.deliveryHalfDay || order.delivery_time || order.delivery_half_day);
const hasDeliverySchedule = hasDeliveryDate && hasDeliveryHalfDay;
const hasDriver = !!order.assignedDriverId;
// Smart hints: show actual state instead of generic "do X"
const getHint = (statusValue) => {
if (statusValue === "agreed") {
if (hasDeliverySchedule) return "Дата доставки уже согласована";
return "Согласуйте дату доставки выше";
}
if (statusValue === "driver_assigned") {
if (hasDriver) return `Водитель уже назначен: ${order.assignedDriverName || ""}`.trim();
return "Назначьте водителя из списка выше";
}
return "";
};
const allStatuses = isPickup
? [
{ value: "pending_confirmation", label: "Ожидает согласования" },
{ value: "agreed", label: "Согласовано" },
{ value: "driver_assigned", label: "Назначен водитель" },
{ value: "picked_up", label: "Вывезено", primary: true, requiresSchedule: true, requiresDriver: false },
{ value: "delivered", label: "Доставлено", mismatch: true, requiresSchedule: true },
{ value: "requires_address", label: "Требуется адрес" },
{ value: "problem", label: "Проблема" },
{ value: "cancelled", label: "Отменено" },
]
: [
{ value: "pending_confirmation", label: "Ожидает согласования" },
{ value: "agreed", label: "Согласовано" },
{ value: "driver_assigned", label: "Назначен водитель" },
{ value: "delivered", label: "Доставлено", primary: true, requiresSchedule: true, requiresDriver: true },
{ value: "picked_up", label: "Вывезено", mismatch: true, requiresSchedule: true },
{ value: "requires_address", label: "Требуется адрес" },
{ value: "problem", label: "Проблема" },
{ value: "cancelled", label: "Отменено" },
];
return ( return (
<Panel className="space-y-4 p-5"> <Panel className="space-y-4 p-5">
@ -27,41 +67,71 @@ const StatusActionPanel = ({
Измените статус, если водитель забыл обновить или нужна корректировка. Измените статус, если водитель забыл обновить или нужна корректировка.
</p> </p>
</div> </div>
{/* Status indicators: show current state clearly */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{[ {hasDeliverySchedule && (
{ value: "pending_confirmation", label: "Ожидает согласования", manual: true }, <Badge tone="accent">✓ Дата согласована</Badge>
{ value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" }, )}
{ value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" }, {hasDriver && (
{ value: "loaded", label: "Загружено", manual: true }, <Badge tone="accent">✓ Водитель: {order.assignedDriverName || "назначен"}</Badge>
{ value: "delivered", label: "Доставлено", manual: true }, )}
{ value: "picked_up", label: "Вывезено", manual: true }, {!hasDeliverySchedule && (
{ value: "requires_address", label: "Требуется адрес", manual: true }, <Badge tone="warning">⚠ Дата не указана</Badge>
{ value: "problem", label: "Проблема", manual: true }, )}
{ value: "cancelled", label: "Отменено", manual: true }, {!hasDriver && !isPickup && (
].map((statusOption) => { <Badge tone="warning">⚠ Водитель не назначен</Badge>
)}
</div>
<div className="flex flex-wrap gap-2">
{allStatuses.map((statusOption) => {
const isCurrent = currentStatus === statusOption.value; const isCurrent = currentStatus === statusOption.value;
const isClickable = statusOption.manual !== false && !isCurrent; const blockedBySchedule = statusOption.requiresSchedule && !hasDeliverySchedule;
const blockedByDriver = statusOption.requiresDriver !== false && !hasDriver;
const hint = getHint(statusOption.value);
return ( return (
<div key={statusOption.value} className="relative group"> <Button
<Button key={statusOption.value}
variant={isCurrent ? "primary" : "secondary"} variant={isCurrent ? "primary" : (statusOption.mismatch ? "ghost" : "secondary")}
onClick={() => { onClick={() => {
if (!isClickable) { if (blockedBySchedule) {
onConfirmStatus?.({ type: "hint", hint: statusOption.hint || "" }); return;
return; }
} if (blockedByDriver) {
onConfirmStatus?.({ type: "status", status: statusOption.value }); onConfirmStatus?.({
}} type: "hint",
disabled={isSavingStatusChange} hint: "⚠ Назначьте водителя перед статусом «Доставлено»",
> });
{statusOption.label} return;
</Button> }
</div> if (isCurrent && hint) {
onConfirmStatus?.({ type: "hint", hint });
return;
}
onConfirmStatus?.({
type: "status",
status: statusOption.value,
label: statusOption.label,
mismatch: !!statusOption.mismatch,
deliveryType: isPickup ? "pickup" : "delivery",
});
}}
disabled={isSavingStatusChange}
className={(blockedBySchedule || blockedByDriver) ? "opacity-40 cursor-not-allowed" : ""}
>
{statusOption.label}
</Button>
); );
})} })}
</div> </div>
{!hasDeliverySchedule && (
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-3 py-2 text-xs text-[var(--color-warning)]">
⚠ Чтобы поставить «Доставлено» или «Вывезено», сначала укажите дату и половину дня доставки выше.
</div>
)}
</Panel> </Panel>
); );
}; };
export { StatusActionPanel }; export { StatusActionPanel };

View File

@ -240,10 +240,10 @@ export const ORDER_STATUS_TRANSITIONS = {
"Ожидает согласования доставки": ["Доставка согласована", "Самовывоз", "Требуется адрес", "Проблема доставки", "Отменён"], "Ожидает согласования доставки": ["Доставка согласована", "Самовывоз", "Требуется адрес", "Проблема доставки", "Отменён"],
"Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки", "Самовывоз", "Требуется адрес"], "Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки", "Самовывоз", "Требуется адрес"],
"Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"], "Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"],
"Назначен водитель": ["Загружен", "Проблема доставки"], "Назначен водитель": ["Доставлен", "Проблема доставки"],
Загружен: ["Доставлен", "Проблема доставки"], Загружен: ["Доставлен", "Проблема доставки"],
"В пути": ["Доставлен", "Проблема доставки"], "В пути": ["Доставлен", "Проблема доставки"],
Доставлен: ["Закрыт"], Доставлен: ["Закрыт", "Назначен водитель"],
"Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"], "Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"],
"Платное хранение": ["Доставка согласована", "Отменён", "Закрыт"], "Платное хранение": ["Доставка согласована", "Отменён", "Закрыт"],
"Самовывоз": ["Доставка согласована", "Закрыт", "Отменён", "Платное хранение"], "Самовывоз": ["Доставка согласована", "Закрыт", "Отменён", "Платное хранение"],
@ -270,7 +270,7 @@ export const ROLE_TRANSITION_TARGETS = {
"Закрыт", "Закрыт",
"Отменён", "Отменён",
], ],
driver: ["Загружен", "Доставлен", "Проблема доставки"], driver: ["Доставлен", "Проблема доставки"],
admin: ORDER_STATUSES, admin: ORDER_STATUSES,
}; };
@ -291,7 +291,7 @@ export const LOGISTICS_STATUSES = [
"Проблема доставки", "Проблема доставки",
]; ];
export const DRIVER_STATUSES = ["Назначен водитель", "Загружен", "Доставлен"]; export const DRIVER_STATUSES = ["Назначен водитель", "Доставлен"];
export const getOrderStatusComment = (status) => ORDER_STATUS_META[status]?.comment || "Комментарий не задан."; export const getOrderStatusComment = (status) => ORDER_STATUS_META[status]?.comment || "Комментарий не задан.";

101
src/fontSettings.css Normal file
View File

@ -0,0 +1,101 @@
/* Font size scale variables — applied by FontSettingsContext */
:root {
--fs-scale-table: 1.0;
--fs-scale-card: 1.0;
--fs-scale-nav: 1.0;
--fs-scale-heading: 1.0;
--fs-scale-body: 1.0;
--fs-scale-small: 1.0;
}
/* Slider styling */
.fs-slider {
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 3px;
outline: none;
cursor: pointer;
}
.fs-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-accent);
border: 3px solid var(--color-surface-strong);
cursor: pointer;
transition: transform 120ms ease;
}
.fs-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.fs-slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-accent);
border: 3px solid var(--color-surface-strong);
cursor: pointer;
border: none;
}
/* ── Zone overrides ──────────────────────────────────────────────────────
.fs-zone-* wrappers override Tailwind text-* classes inside them.
Specificity: .fs-zone-X .text-Y (0,2,0) > .text-Y (0,1,0).
This lets us scale fonts without patching every component.
────────────────────────────────────────────────────────────────────────── */
/* Table zone */
.fs-zone-table { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
.fs-zone-table .text-xs { font-size: calc(0.75rem * var(--fs-scale-table, 1)); }
.fs-zone-table .text-sm { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
.fs-zone-table .text-base { font-size: calc(1rem * var(--fs-scale-table, 1)); }
.fs-zone-table .text-lg { font-size: calc(1.125rem * var(--fs-scale-table, 1)); }
.fs-zone-table .text-xl { font-size: calc(1.25rem * var(--fs-scale-table, 1)); }
/* Arbitrary pixel sizes used in tables */
.fs-zone-table .text-\[10px\] { font-size: calc(10px * var(--fs-scale-table, 1)); }
.fs-zone-table .text-\[11px\] { font-size: calc(11px * var(--fs-scale-table, 1)); }
.fs-zone-table .text-\[12px\] { font-size: calc(12px * var(--fs-scale-table, 1)); }
.fs-zone-table .text-\[13px\] { font-size: calc(13px * var(--fs-scale-table, 1)); }
.fs-zone-table .text-\[14px\] { font-size: calc(14px * var(--fs-scale-table, 1)); }
/* Card zone */
.fs-zone-card { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
.fs-zone-card .text-xs { font-size: calc(0.75rem * var(--fs-scale-card, 1)); }
.fs-zone-card .text-sm { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
.fs-zone-card .text-base { font-size: calc(1rem * var(--fs-scale-card, 1)); }
.fs-zone-card .text-lg { font-size: calc(1.125rem * var(--fs-scale-card, 1)); }
.fs-zone-card .text-\[10px\] { font-size: calc(10px * var(--fs-scale-card, 1)); }
.fs-zone-card .text-\[11px\] { font-size: calc(11px * var(--fs-scale-card, 1)); }
/* Nav zone */
.fs-zone-nav { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
.fs-zone-nav .text-xs { font-size: calc(0.75rem * var(--fs-scale-nav, 1)); }
.fs-zone-nav .text-sm { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
.fs-zone-nav .text-base { font-size: calc(1rem * var(--fs-scale-nav, 1)); }
/* Heading zone */
.fs-zone-heading { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-sm { font-size: calc(0.875rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-base { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-lg { font-size: calc(1.125rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-xl { font-size: calc(1.25rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-2xl { font-size: calc(1.5rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-3xl { font-size: calc(1.875rem * var(--fs-scale-heading, 1)); }
/* Small text zone */
.fs-zone-small { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
.fs-zone-small .text-xs { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
.fs-zone-small .text-sm { font-size: calc(0.875rem * var(--fs-scale-small, 1)); }
/* Body zone — LAST so table/card/nav/heading zones inside body win at equal specificity */
.fs-zone-body { font-size: calc(1rem * var(--fs-scale-body, 1)); }
.fs-zone-body .text-xs { font-size: calc(0.75rem * var(--fs-scale-body, 1)); }
.fs-zone-body .text-sm { font-size: calc(0.875rem * var(--fs-scale-body, 1)); }
.fs-zone-body .text-base { font-size: calc(1rem * var(--fs-scale-body, 1)); }
.fs-zone-body .text-lg { font-size: calc(1.125rem * var(--fs-scale-body, 1)); }

View File

@ -11,11 +11,21 @@ import { getErrorMessage } from "../utils/deliveryUtils";
export const useOrderGroups = () => { export const useOrderGroups = () => {
const [orderGroups, setOrderGroups] = React.useState(() => []); const [orderGroups, setOrderGroups] = React.useState(() => []);
const [filters, setFilters] = React.useState({ const FILTERS_STORAGE_KEY = "supersam_order_filters";
query: "", const [filters, setFilters] = React.useState(() => {
displayStatus: "all", try {
deliveryType: "", const saved = localStorage.getItem(FILTERS_STORAGE_KEY);
if (saved) return JSON.parse(saved);
} catch (e) {}
return { query: "", displayStatus: "all", deliveryType: "" };
}); });
// Persist filters to localStorage on every change
React.useEffect(() => {
try {
localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(filters));
} catch (e) {}
}, [filters]);
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null); const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(true); const [isLoading, setIsLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState(""); const [loadError, setLoadError] = React.useState("");

View File

@ -17,6 +17,7 @@ import { StopWordsPanel } from "../components/admin/StopWordsPanel";
import { ActionLogPanel } from "../components/admin/ActionLogPanel"; import { ActionLogPanel } from "../components/admin/ActionLogPanel";
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel"; import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel"; import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel";
import { BusinessSchedulePanel } from "../components/admin/BusinessSchedulePanel";
import { Panel } from "../components/UI/Panel"; import { Panel } from "../components/UI/Panel";
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading"; import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
@ -36,6 +37,7 @@ const MEGA_ADMIN_NAV = [
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null }, { key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null }, { key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
{ key: "sms_campaign", label: "SMS-кампании", description: "Логи и настройки SMS-рассылок.", badge: null }, { key: "sms_campaign", label: "SMS-кампании", description: "Логи и настройки SMS-рассылок.", badge: null },
{ key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
]; ];
// ── Role → Default Section Map ───────────────────────────────────────────── // ── Role → Default Section Map ─────────────────────────────────────────────
@ -137,6 +139,7 @@ export const DashboardPage = () => {
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null }, { key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null }, { key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null }, { key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
{ key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
] ]
: userRole === "logistician" : userRole === "logistician"
? [ ? [
@ -178,6 +181,7 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>; if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>; if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>;
if (activeSection === "sms_campaign") return <div className="space-y-6 xl:space-y-8"><SmsCampaignPanel /></div>; if (activeSection === "sms_campaign") return <div className="space-y-6 xl:space-y-8"><SmsCampaignPanel /></div>;
if (activeSection === "schedule") return <div className="space-y-6 xl:space-y-8"><BusinessSchedulePanel /></div>;
if (isLoading) { if (isLoading) {
if (userRole === "driver") { if (userRole === "driver") {

View File

@ -39,7 +39,7 @@ export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
"paid_storage", "paid_storage",
]; ];
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "loaded", "on_route", "problem"]; export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "problem"];
const HALF_DAY_LABELS = { const HALF_DAY_LABELS = {
morning: "Первая половина дня", morning: "Первая половина дня",
@ -143,9 +143,17 @@ export const isOrderGroupAgreedForDelivery = (group) => {
export const getOrderGroupDeliveryStatusLabel = (status) => export const getOrderGroupDeliveryStatusLabel = (status) =>
DELIVERY_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно"); DELIVERY_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно");
// Status values that represent a delivery state (not SMS/notification state)
const DELIVERY_STATUS_VALUES = new Set([
"pending_confirmation", "agreed", "driver_assigned", "loaded", "on_route",
"delivered", "picked_up", "pickup", "requires_address", "problem",
"cancelled", "paid_storage", "address_required", "manual_confirmation_required",
]);
export const getOrderGroupDisplayStatusLabel = (group) => { export const getOrderGroupDisplayStatusLabel = (group) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status; const statusCol = group?.status;
const notificationStatus = group?.notificationStatus || group?.notification_status; const notificationStatus = group?.notificationStatus || group?.notification_status;
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
// When auto-SMS failed and logistics hasn't taken action yet → show as a todo item // When auto-SMS failed and logistics hasn't taken action yet → show as a todo item
const isManualRequired = notificationStatus === "manual_required"; const isManualRequired = notificationStatus === "manual_required";
@ -154,6 +162,12 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
return "Требуется ручное управление"; return "Требуется ручное управление";
} }
// Primary: status column (now synced with delivery_status)
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
return getOrderGroupDeliveryStatusLabel(statusCol);
}
// Fallback: delivery_status (for pending/manual_confirmation groups)
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") { if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
return getOrderGroupDeliveryStatusLabel(deliveryStatus); return getOrderGroupDeliveryStatusLabel(deliveryStatus);
} }
@ -163,12 +177,13 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
return notificationLabel; return notificationLabel;
} }
return getOrderGroupStatusLabel(group?.status); return getOrderGroupStatusLabel(statusCol);
}; };
export const getOrderGroupDisplayStatusValue = (group) => { export const getOrderGroupDisplayStatusValue = (group) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status; const statusCol = group?.status;
const notificationStatus = group?.notificationStatus || group?.notification_status; const notificationStatus = group?.notificationStatus || group?.notification_status;
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
// Unify manual_required into a single bucket regardless of delivery_status detail // Unify manual_required into a single bucket regardless of delivery_status detail
const isManualRequired = notificationStatus === "manual_required"; const isManualRequired = notificationStatus === "manual_required";
@ -177,11 +192,17 @@ export const getOrderGroupDisplayStatusValue = (group) => {
return "status:manual_required"; return "status:manual_required";
} }
// Primary: status column (now synced with delivery_status)
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
return `delivery:${statusCol}`;
}
// Fallback: delivery_status
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") { if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
return `delivery:${deliveryStatus}`; return `delivery:${deliveryStatus}`;
} }
return `status:${group?.status || "unknown"}`; return `status:${statusCol || "unknown"}`;
}; };
export const isOrderGroupVisibleToDriver = (group) => { export const isOrderGroupVisibleToDriver = (group) => {
@ -483,10 +504,16 @@ export const buildOrderGroupBuckets = (groups) => {
export const getOrderGroupStatusTone = (group) => { export const getOrderGroupStatusTone = (group) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status; const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
const statusCol = group?.status;
// Highlight groups with delivery problems // Highlight groups with delivery problems
if (group?.hasDeliveryProblem) return "warning"; if (group?.hasDeliveryProblem) return "warning";
// Priority: if status column already holds a delivery-level value, use it
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
return getOrderGroupDeliveryStatusTone(statusCol);
}
if (deliveryStatus && deliveryStatus !== "pending_confirmation") { if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
return getOrderGroupDeliveryStatusTone(deliveryStatus); return getOrderGroupDeliveryStatusTone(deliveryStatus);
} }

View File

@ -142,12 +142,23 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
const extractCity = (addr) => { const extractCity = (addr) => {
if (!addr) return ""; if (!addr) return "";
// 1) explicit marker: г. Ялта, пгт. Куйбышево, etc. // 1) explicit marker: г. Ялта, пгт. Куйбышево, etc.
const m = addr.match(/(?:г\.\s|гор\.\s|пос\.\s|с\.\s|дер\.\s|пгт\.\s|город\s|село\s|г\s)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\\s]|$)/i); // Word markers (город/село) require space after to avoid matching "Стройгородок"
if (m) return m[1].trim(); // Dot markers (г./гор./etc) allow zero space (г.Ялта)
// 2) known city name anywhere in address (case-insensitive) const m = addr.match(/(?:г\.\s*|гор\.\s*|пос\.\s*|с\.\s*|дер\.\s*|пгт\.\s*|город\s+|село\s+|г\s+)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\s]|$)/i);
if (m) {
const candidate = m[1].trim();
for (const city of CRIMEAN_CITIES) {
if (city.toLowerCase() === candidate.toLowerCase()) return city;
}
// Not a known city — continue to step 2
}
// 2) known city name via custom word boundary (JS \b doesn't work with Cyrillic)
// City must be preceded by start/comma/space/dot and followed by comma/space/end
const lower = addr.toLowerCase(); const lower = addr.toLowerCase();
for (const city of CRIMEAN_CITIES) { for (const city of CRIMEAN_CITIES) {
if (lower.includes(city.toLowerCase())) return city; const esc = city.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp("(^|[,.\\s])" + esc + "(?=[,\\s]|$)", "i");
if (re.test(lower)) return city;
} }
// 3) Бахчисарайский р-н → Бахчисарай // 3) Бахчисарайский р-н → Бахчисарай
const district = addr.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i); const district = addr.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i);
@ -460,15 +471,22 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
return safeSupabaseCall(async () => { return safeSupabaseCall(async () => {
const client = requireSupabase(); const client = requireSupabase();
// Fetch current status before any update (needed for audit log) // Fetch current status before any update (needed for audit log + status sync)
const { data: current, error: fetchCurrentError } = await client const { data: current, error: fetchCurrentError } = await client
.from("order_groups") .from("order_groups")
.select("delivery_status") .select("delivery_status, delivery_type")
.eq("id", orderGroupId) .eq("id", orderGroupId)
.single(); .single();
if (fetchCurrentError) throw fetchCurrentError; if (fetchCurrentError) throw fetchCurrentError;
// Compute status column: pickup+picked_up → picked_up, delivery+picked_up → delivered
const deliveryType = current.delivery_type || "delivery";
const statusSync = (deliveryType === "pickup" && status === "picked_up") ? "picked_up"
: (deliveryType === "delivery" && status === "picked_up") ? "delivered"
: (status === "delivered") ? "delivered"
: status;
// Bypass stale RPC for paid_storage transitions // Bypass stale RPC for paid_storage transitions
// Server-side RPC still enforces driver-assignment checks that block // Server-side RPC still enforces driver-assignment checks that block
// manager/logistician from moving groups into/out of paid_storage. // manager/logistician from moving groups into/out of paid_storage.
@ -479,6 +497,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
.from("order_groups") .from("order_groups")
.update({ .update({
delivery_status: status, delivery_status: status,
status: statusSync,
paid_storage_at: new Date().toISOString(), paid_storage_at: new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}) })
@ -490,6 +509,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
.from("order_groups") .from("order_groups")
.update({ .update({
delivery_status: status, delivery_status: status,
status: statusSync,
paid_storage_at: null, paid_storage_at: null,
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}) })

View File

@ -119,9 +119,26 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS]; return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
}; };
export const buildDefaultDatedAvailableSlots = (now = new Date()) => { export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => {
const CRIMEA_TZ = "Europe/Simferopol"; const CRIMEA_TZ = "Europe/Simferopol";
// Fetch delivery days from business_schedule_settings
let deliveryDays: number[] = [1, 2, 3, 4, 5]; // Default: Mon-Fri
if (supabaseClient) {
try {
const { data } = await supabaseClient
.from("business_schedule_settings")
.select("delivery_days")
.eq("id", 1)
.single();
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
deliveryDays = data.delivery_days;
}
} catch {
// Silent fallback to defaults
}
}
const formatCrimeaDate = (date: Date) => { const formatCrimeaDate = (date: Date) => {
return new Intl.DateTimeFormat("en-CA", { return new Intl.DateTimeFormat("en-CA", {
timeZone: CRIMEA_TZ, timeZone: CRIMEA_TZ,
@ -137,12 +154,18 @@ export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
return next; return next;
}; };
// Skip Sunday (getUTCDay() === 0) — never offer Sunday delivery // Check if date is an allowed delivery day
const isSunday = (date: Date) => date.getUTCDay() === 0; // getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
// deliveryDays uses 1=Mon ... 7=Sun
const isAllowedDay = (date: Date) => {
const dow = date.getUTCDay();
const dayNum = dow === 0 ? 7 : dow;
return deliveryDays.includes(dayNum);
};
const getNextWorkday = (date: Date) => { const getNextWorkday = (date: Date) => {
let next = addDays(date, 1); let next = addDays(date, 1);
while (isSunday(next)) { while (!isAllowedDay(next)) {
next = addDays(next, 1); next = addDays(next, 1);
} }
return next; return next;

View File

@ -31,11 +31,31 @@ type ConfirmBody = {
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
const isWeekendDate = (value: string) => { // Fetch delivery days from business_schedule_settings
const getDeliveryDays = async (supabaseClient: any): Promise<number[]> => {
try {
const { data } = await supabaseClient
.from("business_schedule_settings")
.select("delivery_days")
.eq("id", 1)
.single();
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
return data.delivery_days as number[];
}
} catch {
// Silent fallback
}
return [1, 2, 3, 4, 5]; // Default: Mon-Fri
};
const isAllowedDeliveryDate = (value: string, deliveryDays: number[]) => {
if (!isValidDate(value)) return false; if (!isValidDate(value)) return false;
const date = new Date(`${value}T12:00:00Z`); const date = new Date(`${value}T12:00:00Z`);
const weekday = date.getUTCDay(); const dow = date.getUTCDay();
return weekday === 0; // 0=Sunday — never allow Sunday delivery // getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
// deliveryDays uses 1=Mon ... 7=Sun
const dayNum = dow === 0 ? 7 : dow;
return deliveryDays.includes(dayNum);
}; };
const resolveRequestedSlot = ( const resolveRequestedSlot = (
@ -45,6 +65,7 @@ const resolveRequestedSlot = (
available_slots?: string[] | null; available_slots?: string[] | null;
}, },
body: ConfirmBody, body: ConfirmBody,
deliveryDays: number[] = [1, 2, 3, 4, 5],
) => { ) => {
const deliveryType = body.deliveryType || "delivery"; const deliveryType = body.deliveryType || "delivery";
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim(); const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
@ -59,8 +80,8 @@ const resolveRequestedSlot = (
return { deliveryDate, deliveryTime, deliveryType }; return { deliveryDate, deliveryTime, deliveryType };
} }
// Reject Sunday for delivery (business rule: never deliver on Sunday) // Reject non-delivery days for delivery (business schedule)
if (isWeekendDate(deliveryDate)) { if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) {
return null; return null;
} }
@ -120,6 +141,7 @@ Deno.serve(async (request) => {
const tokenHash = await hashInvitationToken(body.token); const tokenHash = await hashInvitationToken(body.token);
const supabase = createServiceClient(); const supabase = createServiceClient();
const deliveryDays = await getDeliveryDays(supabase);
const ipHash = await hashText(getClientIp(request)); const ipHash = await hashText(getClientIp(request));
await requireRateLimit(supabase, { await requireRateLimit(supabase, {
@ -180,7 +202,7 @@ Deno.serve(async (request) => {
); );
} }
const requestedSlot = resolveRequestedSlot(invitation, body); const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays);
if (!requestedSlot) { if (!requestedSlot) {
return jsonResponse( return jsonResponse(
{ {

View File

@ -0,0 +1,165 @@
import {
createServiceClient,
getCorsHeaders,
jsonResponse,
preflightResponse,
readJsonBody,
} from "../_shared/security.ts";
const MAX_BODY_BYTES = 8 * 1024;
const ADMIN_ROLES = new Set(["admin", "mega_admin"]);
const isValidEmail = (value: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
/**
* Verify the caller's JWT and return their role from public.users.
* Returns null if not authorized.
*/
async function getCallerRole(request: Request): Promise<string | null> {
const authHeader = request.headers.get("authorization") || "";
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
if (!token) return null;
const supabase = createServiceClient();
const { data: userData, error: userError } = await supabase.auth.getUser(token);
if (userError || !userData?.user) return null;
const userId = userData.user.id;
const { data: userRow, error: roleError } = await supabase
.from("users")
.select("roles(name)")
.eq("id", userId)
.maybeSingle();
if (roleError || !userRow) return null;
return userRow.roles?.name || null;
}
Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
return preflightResponse(request, "private");
}
const corsHeaders = getCorsHeaders(request, "private");
if (!corsHeaders) {
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
}
try {
// ── Authorization ──
const callerRole = await getCallerRole(request);
if (!callerRole || !ADMIN_ROLES.has(callerRole)) {
return jsonResponse(
{ ok: false, error: "Недостаточно прав. Требуется роль admin или mega_admin." },
403,
corsHeaders,
);
}
const supabase = createServiceClient();
// ── POST: create new user ──
if (request.method === "POST") {
const { body } = await readJsonBody<{ email?: string; name?: string; role?: string }>(
request,
{ maxBytes: MAX_BODY_BYTES },
);
const email = String(body.email || "").trim().toLowerCase();
const name = String(body.name || "").trim();
const role = String(body.role || "").trim().toLowerCase();
if (!email || !isValidEmail(email)) {
return jsonResponse({ ok: false, error: "Некорректный email" }, 400, corsHeaders);
}
if (!name) {
return jsonResponse({ ok: false, error: "Имя обязательно" }, 400, corsHeaders);
}
if (!role) {
return jsonResponse({ ok: false, error: "Роль обязательна" }, 400, corsHeaders);
}
// Check if email already exists in public.users
const { data: existingUser } = await supabase
.from("users")
.select("id")
.eq("email", email)
.maybeSingle();
if (existingUser) {
return jsonResponse({ ok: false, error: "Пользователь с таким email уже существует" }, 409, corsHeaders);
}
// Create auth user — trigger handle_new_user will auto-insert into public.users
// using user_metadata.role and user_metadata.name
const { data: authData, error: authError } = await supabase.auth.admin.createUser({
email,
email_confirm: true,
user_metadata: { name, role },
});
if (authError) {
console.error("auth.createUser error:", authError);
return jsonResponse(
{ ok: false, error: "Ошибка создания пользователя: " + authError.message },
500,
corsHeaders,
);
}
const newUserId = authData.user.id;
return jsonResponse({ ok: true, data: { id: newUserId, email, name, role } }, 201, corsHeaders);
}
// ── DELETE: remove user ──
if (request.method === "DELETE") {
const url = new URL(request.url);
const userId = url.searchParams.get("id");
if (!userId) {
return jsonResponse({ ok: false, error: "Параметр id обязателен" }, 400, corsHeaders);
}
// Get user info before deletion
const { data: userRow } = await supabase
.from("users")
.select("id, email, name")
.eq("id", userId)
.maybeSingle();
if (!userRow) {
return jsonResponse({ ok: false, error: "Пользователь не найден" }, 404, corsHeaders);
}
// Delete auth user (FK ON DELETE CASCADE will remove public.users row)
const { error: authDeleteError } = await supabase.auth.admin.deleteUser(userId);
if (authDeleteError) {
console.error("auth.deleteUser error:", authDeleteError);
// Try deleting public.users directly as fallback
const { error: dbDeleteError } = await supabase.from("users").delete().eq("id", userId);
if (dbDeleteError) {
return jsonResponse(
{ ok: false, error: "Ошибка удаления: " + authDeleteError.message },
500,
corsHeaders,
);
}
}
return jsonResponse({ ok: true, data: { id: userId } }, 200, corsHeaders);
}
return jsonResponse({ ok: false, error: "Method not allowed" }, 405, corsHeaders);
} catch (error) {
if (error instanceof Error && "status" in error) {
const httpError = error as { status: number; message: string };
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
}
return jsonResponse(
{ ok: false, error: error instanceof Error ? error.message : "Unexpected error" },
500,
corsHeaders,
);
}
});

View File

@ -119,16 +119,63 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS]; return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
}; };
export const buildDefaultDatedAvailableSlots = (now = new Date()) => { export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => {
const formatIsoDate = (date: Date) => date.toISOString().slice(0, 10); const CRIMEA_TZ = "Europe/Simferopol";
// Fetch delivery days from business_schedule_settings
let deliveryDays: number[] = [1, 2, 3, 4, 5]; // Default: Mon-Fri
if (supabaseClient) {
try {
const { data } = await supabaseClient
.from("business_schedule_settings")
.select("delivery_days")
.eq("id", 1)
.single();
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
deliveryDays = data.delivery_days;
}
} catch {
// Silent fallback to defaults
}
}
const formatCrimeaDate = (date: Date) => {
return new Intl.DateTimeFormat("en-CA", {
timeZone: CRIMEA_TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
};
const addDays = (date: Date, days: number) => { const addDays = (date: Date, days: number) => {
const next = new Date(date); const next = new Date(date);
next.setUTCDate(next.getUTCDate() + days); next.setUTCDate(next.getUTCDate() + days);
return next; return next;
}; };
const firstDay = formatIsoDate(addDays(now, 1)); // Check if date is an allowed delivery day
const secondDay = formatIsoDate(addDays(now, 2)); // getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
// deliveryDays uses 1=Mon ... 7=Sun
const isAllowedDay = (date: Date) => {
const dow = date.getUTCDay();
const dayNum = dow === 0 ? 7 : dow;
return deliveryDays.includes(dayNum);
};
const getNextWorkday = (date: Date) => {
let next = addDays(date, 1);
while (!isAllowedDay(next)) {
next = addDays(next, 1);
}
return next;
};
const firstWorkday = getNextWorkday(now);
const secondWorkday = getNextWorkday(firstWorkday);
const firstDay = formatCrimeaDate(firstWorkday);
const secondDay = formatCrimeaDate(secondWorkday);
return [ return [
`${firstDay}, Первая половина дня`, `${firstDay}, Первая половина дня`,

View File

@ -0,0 +1,132 @@
import { createClient } from "npm:@supabase/supabase-js@2";
const ALLOWED_ORIGINS = [
"https://dost.supersamsev.ru",
"https://supa.supersamsev.ru",
"http://localhost:5173",
];
const SMS_STATUS_URL = "https://sms.ru/sms/status";
const SMS_CODE_LABELS: Record<string, string> = {
"100": "В очереди SMS.ru",
"101": "Передано оператору",
"102": "В пути",
"103": "Доставлено",
"104": "Истёкло время",
"105": "Удалено оператором",
"106": "Сбой телефона",
"107": "Неизвестная причина",
"108": "Отклонено",
"130": "Лимит на номер/день",
"131": "Лимит одинаковых/мин",
"132": "Лимит одинаковых/день",
"200": "Неправильный api_id",
"201": "Недостаточно средств",
"202": "Неправильный получатель",
"230": "Общий лимит/день",
"231": "Лимит одинаковых/мин",
"232": "Лимит одинаковых/день",
};
const cors = (origin: string) => ({
"Access-Control-Allow-Origin": ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0],
"Access-Control-Allow-Methods": "POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type,Authorization,apikey",
});
Deno.serve(async (req: Request) => {
const origin = req.headers.get("origin") || "";
const headers = { ...cors(origin), "Content-Type": "application/json" };
if (req.method === "OPTIONS") return new Response(null, { headers });
try {
const { log_id } = await req.json();
if (!log_id) return new Response(JSON.stringify({ error: "log_id required" }), { status: 400, headers });
const supabaseUrl = Deno.env.get("SUPABASE_URL") || "";
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
const sb = createClient(supabaseUrl, serviceKey);
// Fetch log entry
const { data: logRow, error: logErr } = await sb
.from("sms_campaign_log")
.select("id, sms_id, campaign_type, order_group_id, attempts")
.eq("id", log_id)
.single();
if (logErr || !logRow) return new Response(JSON.stringify({ error: "Log not found" }), { status: 404, headers });
// Fetch api_id from settings
const { data: settings } = await sb
.from("sms_campaign_settings")
.select("sms_api_id")
.eq("campaign_type", logRow.campaign_type)
.single();
const apiId = settings?.sms_api_id || Deno.env.get("SMS_API_ID") || "";
if (!logRow.sms_id) return new Response(JSON.stringify({ error: "No sms_id in log" }), { status: 400, headers });
// Call SMS.ru status API
const formData = new URLSearchParams();
formData.append("api_id", apiId);
formData.append("sms_id", logRow.sms_id);
const smsResp = await fetch(SMS_STATUS_URL, { method: "POST", body: formData });
const smsText = await smsResp.text();
const lines = smsText.split("\n").map((l: string) => l.trim());
const code = lines[0] || "";
// Determine status
const status =
code === "103" ? "delivered" :
["100", "101", "102"].includes(code) ? "checking" :
["104", "105", "106", "107", "108", "130"].includes(code) ? "error" :
["131", "132", "230", "231", "232"].includes(code) ? "limit_exceeded" :
"checking";
// Update log entry
const now = new Date().toISOString();
await sb.from("sms_campaign_log").update({
status,
sms_code: code,
checked_at: now,
needs_check: false,
updated_at: now,
}).eq("id", log_id);
// Update order_groups if delivered
if (code === "103" && logRow.order_group_id) {
const nextCheck = new Date(Date.now() + 3 * 3600 * 1000).toISOString();
if (logRow.campaign_type === "first_sms") {
await sb.from("order_groups").update({
notification_status: "first_sms_sent",
first_sms_sent_at: now,
sms_sent_at: now,
last_sms_error: null,
next_notification_check_at: nextCheck,
status: "first_sms_sent",
}).eq("id", logRow.order_group_id);
} else if (logRow.campaign_type === "second_sms") {
await sb.from("order_groups").update({
notification_status: "second_sms_sent",
second_sms_sent_at: now,
sms_sent_at: now,
last_sms_error: null,
next_notification_check_at: nextCheck,
status: "second_sms_sent",
}).eq("id", logRow.order_group_id);
}
}
return new Response(JSON.stringify({
success: true,
sms_id: logRow.sms_id,
code,
status,
label: SMS_CODE_LABELS[code] || "Код " + code,
}), { headers });
} catch (e) {
return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers });
}
});

View File

@ -31,11 +31,31 @@ type ConfirmBody = {
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
const isWeekendDate = (value: string) => { // Fetch delivery days from business_schedule_settings
const getDeliveryDays = async (supabaseClient: any): Promise<number[]> => {
try {
const { data } = await supabaseClient
.from("business_schedule_settings")
.select("delivery_days")
.eq("id", 1)
.single();
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
return data.delivery_days as number[];
}
} catch {
// Silent fallback
}
return [1, 2, 3, 4, 5]; // Default: Mon-Fri
};
const isAllowedDeliveryDate = (value: string, deliveryDays: number[]) => {
if (!isValidDate(value)) return false; if (!isValidDate(value)) return false;
const date = new Date(`${value}T12:00:00Z`); const date = new Date(`${value}T12:00:00Z`);
const weekday = date.getUTCDay(); const dow = date.getUTCDay();
return weekday === 0; // 0=Sunday — never allow Sunday delivery // getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
// deliveryDays uses 1=Mon ... 7=Sun
const dayNum = dow === 0 ? 7 : dow;
return deliveryDays.includes(dayNum);
}; };
const resolveRequestedSlot = ( const resolveRequestedSlot = (
@ -45,6 +65,7 @@ const resolveRequestedSlot = (
available_slots?: string[] | null; available_slots?: string[] | null;
}, },
body: ConfirmBody, body: ConfirmBody,
deliveryDays: number[] = [1, 2, 3, 4, 5],
) => { ) => {
const deliveryType = body.deliveryType || "delivery"; const deliveryType = body.deliveryType || "delivery";
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim(); const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
@ -59,8 +80,8 @@ const resolveRequestedSlot = (
return { deliveryDate, deliveryTime, deliveryType }; return { deliveryDate, deliveryTime, deliveryType };
} }
// Reject Sunday for delivery (business rule: never deliver on Sunday) // Reject non-delivery days for delivery (business schedule)
if (isWeekendDate(deliveryDate)) { if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) {
return null; return null;
} }
@ -120,6 +141,7 @@ Deno.serve(async (request) => {
const tokenHash = await hashInvitationToken(body.token); const tokenHash = await hashInvitationToken(body.token);
const supabase = createServiceClient(); const supabase = createServiceClient();
const deliveryDays = await getDeliveryDays(supabase);
const ipHash = await hashText(getClientIp(request)); const ipHash = await hashText(getClientIp(request));
await requireRateLimit(supabase, { await requireRateLimit(supabase, {
@ -180,7 +202,7 @@ Deno.serve(async (request) => {
); );
} }
const requestedSlot = resolveRequestedSlot(invitation, body); const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays);
if (!requestedSlot) { if (!requestedSlot) {
return jsonResponse( return jsonResponse(
{ {

54
webhook-deploy.py Executable file
View File

@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Gitea webhook listener — auto-deploys supersam on push to main."""
import hmac, hashlib, subprocess, json, os, logging
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ.get("WEBHOOK_SECRET", "supersam-deploy-hook-2024")
DEPLOY_SCRIPT = "/opt/supersam/deploy.sh"
LOG = "/var/log/supersam-deploy.log"
logging.basicConfig(filename=LOG, level=logging.INFO, format="%(asctime)s %(message)s")
logger = logging.getLogger(__name__)
def verify_signature(payload, sig_header):
if not sig_header:
return False
mac = hmac.new(SECRET.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare(mac, sig_header)
@app.route("/deploy", methods=["POST"])
def deploy():
# Verify Gitea signature if present
sig = request.headers.get("X-Gitea-Signature", "")
if not verify_signature(request.data, sig):
logger.warning("Invalid or missing signature")
# Still proceed — Gitea may not send signature if not configured
data = request.json or {}
ref = data.get("ref", "")
repo = data.get("repository", {}).get("name", "")
# Only deploy on push to main
if ref != "refs/heads/main":
logger.info(f"Ignored push to {ref}")
return {"status": "ignored", "ref": ref}, 200
logger.info(f"Deploy triggered by push to {ref} in {repo}")
try:
result = subprocess.run(
[DEPLOY_SCRIPT],
capture_output=True, text=True, timeout=300
)
logger.info(f"Deploy exit={result.returncode}")
if result.returncode != 0:
logger.error(f"Deploy stderr: {result.stderr}")
return {"status": "error", "output": result.stderr}, 500
return {"status": "ok", "output": result.stdout[-500:]}, 200
except subprocess.TimeoutExpired:
logger.error("Deploy timed out")
return {"status": "timeout"}, 500
if __name__ == "__main__":
app.run(host="127.0.0.1", port=9765)