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
.ruff_cache
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.service=supersam-app
- 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-Frame-Options=DENY
- 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-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:
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
* @description Admin analytics dashboard. Displays KPI cards, status pie chart,
* daily trend line, confirmation funnel, SMS stats, and driver performance
* bar chart. Supports period selection (1d/7d/30d/all) and mobile layout.
* @description Admin analytics dashboard. Redesigned with responsive grid layout
* that uses desktop width effectively. KPI cards, status pie, daily trend,
* confirmation funnel, SMS stats, driver performance, pickup stats.
* Period label shown in section subtitles.
*/
import React, { useState, useEffect } from 'react';
import {
@ -10,7 +11,6 @@ import {
PieChart, Pie, Cell, Legend, LineChart, Line, CartesianGrid,
} from 'recharts';
import { Panel } from '../UI/Panel';
import { Badge } from '../UI/Badge';
import { SegmentedTabs } from '../UI/SegmentedTabs';
import { Skeleton } from '../UI/Loading';
import { useAdminStats } from '../../hooks/useAdminStats';
@ -21,7 +21,7 @@ import { PickupStatsPanel } from './PickupStatsPanel';
const useIsMobile = () => {
const [mobile, setMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(max-width: 640px)');
const mq = window.matchMedia('(max-width: 768px)');
setMobile(mq.matches);
const handler = (e) => setMobile(e.matches);
mq.addEventListener('change', handler);
@ -38,7 +38,8 @@ const STATUS_COLORS = {
driver_assigned: '#3b82f6',
loaded: '#6366f1',
on_route: '#8b5cf6',
delivered: '#10b981',
delivered: '#22c55e',
picked_up: '#14b8a6',
paid_storage: '#06b6d4',
problem: '#ef4444',
cancelled: '#64748b',
@ -53,6 +54,7 @@ const STATUS_LABELS = {
loaded: 'Загружено',
on_route: 'В пути',
delivered: 'Доставлено',
picked_up: 'Вывезено',
paid_storage: 'Оплаченное хранение',
problem: 'Проблема',
cancelled: 'Отменено',
@ -64,9 +66,16 @@ const PERIOD_OPTIONS = [
{ key: '1d', label: 'Сегодня' },
{ key: '7d', label: '7 дней' },
{ key: '30d', label: '30 дней' },
{ key: 'all', label: 'Все' },
{ key: 'all', label: 'Всё время' },
];
const PERIOD_LABELS = {
'1d': 'за сегодня',
'7d': 'за 7 дней',
'30d': 'за 30 дней',
'all': 'за всё время',
};
// ── Custom Recharts Tooltip ─────────────────────────────────────────────────
const CustomTooltip = ({ active, payload, label: tooltipLabel }) => {
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 ───────────────────────────────────────────────
export const AdminDashboard = () => {
// ── State & Hooks ─────────────────────────────────────────────────────────
const [period, setPeriod] = useState('7d');
const mobile = useIsMobile();
const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period);
const { stats: pickupStats, isLoading: pickupLoading } = usePickupStats(period);
// ── Responsive Layout Values (must be before early returns) ────────────────
const chartHeight = mobile ? 200 : 240;
const kpiMin = mobile ? '80px' : '110px';
const chartGridCols = mobile ? '1fr' : '1fr 2fr';
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' };
const periodLabel = PERIOD_LABELS[period] || '';
const chartHeight = mobile ? 200 : 280;
const fontSize = mobile ? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem' }
: { xs: '0.72rem', s: '0.78rem', m: '0.85rem', l: '0.95rem' };
// ── Loading / Error States ─────────────────────────────────────────────────
// ── Loading State ─────────────────────────────────────────────────────────
if (isLoading) {
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' }}>
<Skeleton variant="heading" className="w-1/4" />
<Skeleton className="w-32 h-8" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : `repeat(auto-fit, minmax(80px, 160px))`, gap: '0.4rem' }}>
{Array.from({ length: 6 }).map((_, i) => (
<Panel key={i} style={{ padding: mobile ? '0.4rem 0.6rem' : '0.5rem 0.75rem', textAlign: 'center' }}>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : 'repeat(auto-fit, minmax(120px, 1fr))', gap: '0.5rem' }}>
{Array.from({ length: 7 }).map((_, i) => (
<Panel key={i} style={{ padding: '0.75rem', textAlign: 'center' }}>
<Skeleton className="w-12 h-3 mb-1" />
<Skeleton className="w-8 h-5" />
<Skeleton className="w-8 h-6" />
</Panel>
))}
</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>
);
}
@ -145,70 +175,83 @@ export const AdminDashboard = () => {
status: s.delivery_status,
})).filter(d => d.value > 0);
// ── Trend & Driver Data ───────────────────────────────────────────────────
const trendData = (dailyTrend || []).map(d => ({
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 => ({
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 Data ────────────────────────────────────────────────────────────
// ── Funnel: Real order path ───────────────────────────────────────────────
const completedTotal = (econ.full_chain_client || 0) + (econ.client_date_no_driver || 0)
+ (econ.manager_date_completed || 0) + (econ.bypassed_completed || 0);
const funnelSteps = [
{ label: 'Согласовано после 1-й SMS', value: econ.confirmed_after_sms1 || 0, color: '#22c55e' },
{ label: 'Согласовано после 2-й SMS', value: econ.confirmed_after_sms2 || 0, color: '#14b8a6' },
{ label: 'Согласовано вручную', value: econ.confirmed_via_manual || 0, color: '#eab308' },
{ label: 'Ручное назначение даты', value: econ.manual_date_set_count || 0, color: '#f97316' },
{ label: 'Платное хранение', value: econ.paid_storage_count || 0, color: '#06b6d4' },
{ label: 'Отмена', value: econ.cancelled_count || 0, color: '#ef4444' },
{ label: 'Всего заказов', value: econ.total_groups || 0, color: '#94a3b8' },
{ label: 'SMS отправлено', value: econ.sms_sent || 0, color: '#3b82f6' },
{ label: 'Полная цепочка', value: econ.full_chain_client || 0, color: '#22c55e' },
{ label: 'Клиент выбрал дату', value: econ.client_date_no_driver || 0, color: '#14b8a6' },
{ label: 'Менеджер назначил дату', value: econ.manager_date_completed || 0, color: '#8b5cf6' },
{ 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 ─────────────────────────────────────────────────────────────────
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>
// ── Grid Layout ───────────────────────────────────────────────────────────
// Desktop: 12-col grid. Mobile: single column.
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>
<h2 style={{ fontSize: mobile ? '1rem' : '1.1rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.15rem' }}>Аналитика</h2>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Статистика по доставкам</p>
<h2 style={{ fontSize: mobile ? '1.1rem' : '1.4rem', fontWeight: 800, color: 'var(--color-text)', marginBottom: '0.1rem' }}>
Аналитика
</h2>
<p style={{ fontSize: mobile ? '0.72rem' : '0.82rem', color: 'var(--color-text-muted)' }}>
Статистика по доставкам {periodLabel}
</p>
</div>
<SegmentedTabs items={PERIOD_OPTIONS} activeKey={period} onChange={setPeriod} />
</div>
{/* KPI — centered on mobile */}
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : `repeat(auto-fit, minmax(${kpiMin}, 160px))`, gap: '0.4rem' }}>
{[
{ label: 'Всего', val: totalGroups },
{ label: 'Ожидает', val: sv.pending },
{ label: 'В работе', val: sv.in_progress },
{ label: 'Доставлено', val: sv.delivered },
{ label: 'Проблемы', val: sv.problem },
{ label: '% доставки', val: sv.delivery_rate != null ? sv.delivery_rate + '%' : '—' },
].map((kpi, i) => (
<Panel key={i} style={{ padding: mobile ? '0.4rem 0.6rem' : '0.5rem 0.75rem', textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '0.05rem' }}>{kpi.label}</div>
<div style={{ fontSize: mobile ? '1.1rem' : '1.3rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>{kpi.val ?? '—'}</div>
</Panel>
))}
{/* ── KPI Cards ─────────────────────────────────────────────────────────── */}
<div style={{
display: 'grid',
gridTemplateColumns: mobile ? '1fr 1fr' : 'repeat(auto-fit, minmax(130px, 1fr))',
gap: mobile ? '0.4rem' : '0.75rem',
}}>
<KpiCard label="Всего" value={totalGroups} mobile={mobile} />
<KpiCard label="Ожидает" value={sv.pending} color="#94a3b8" mobile={mobile} />
<KpiCard label="В работе" value={sv.in_progress} color="#3b82f6" mobile={mobile} />
<KpiCard label="Доставлено" value={sv.delivered} color="#22c55e" mobile={mobile} />
<KpiCard label="Вывезено" value={sv.picked_up} color="#14b8a6" mobile={mobile} />
<KpiCard label="Самовывоз" value={sv.picked_up_pickup} color="#f59e0b" mobile={mobile} />
<KpiCard label="Проблемы" value={sv.problem} color="#ef4444" mobile={mobile} />
<KpiCard label="% доставки" value={sv.delivery_rate != null ? sv.delivery_rate + '%' : '—'} color="var(--color-text)" mobile={mobile} />
</div>
{/* Pie + Line — stacked on mobile, side-by-side on desktop */}
<div style={{ display: 'grid', gridTemplateColumns: chartGridCols, gap: mobile ? '0.5rem' : '1rem' }}>
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По статусам</h3>
{/* ── Main Grid: Charts + Tables ───────────────────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: gridCols, gap: mobile ? '0.75rem' : '1.5rem' }}>
{/* 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 ? (
<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}>
<PieChart>
<Pie data={statusPieData} cx="50%" cy="50%"
innerRadius={mobile ? 30 : 40}
outerRadius={mobile ? 60 : 80}
innerRadius={mobile ? 30 : 50}
outerRadius={mobile ? 60 : 90}
dataKey="value" nameKey="name" paddingAngle={2}>
{statusPieData.map(entry => (
<Cell key={entry.status} fill={STATUS_COLORS[entry.status] || '#6b7280'} />
@ -221,155 +264,160 @@ export const AdminDashboard = () => {
)}
</Panel>
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>Тренд по дням</h3>
{/* Daily Trend — 8 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(8) }}>
<SectionHeader title="Тренд по дням" subtitle={periodLabel} mobile={mobile} />
{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}>
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border, #334155)" />
<XAxis dataKey="date" tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} />
<YAxis tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} width={mobile ? 25 : 35} />
<XAxis dataKey="date" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} />
<YAxis tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} width={mobile ? 25 : 40} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: fontSize.xs }} />
<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="picked_up" name="Вывезено" stroke="#14b8a6" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="problems" name="Проблемы" stroke="#ef4444" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
)}
</Panel>
</div>
{/* Status table */}
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>Все статусы</h3>
{statusPieData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
) : (
<div>
<div style={{
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 50px 40px' : '10px 1fr 70px 55px',
gap: '0 0.4rem', padding: '0.3rem 0.3rem', alignItems: 'center',
borderBottom: '1px solid var(--color-border)', fontSize: fontSize.xs,
color: 'var(--color-text-muted)', fontWeight: 600,
}}>
<div /><div>Статус</div><div style={{ textAlign: 'right' }}>Кол-во</div><div style={{ textAlign: 'right' }}>Доля</div>
</div>
{statusPieData.map(s => {
const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0;
return (
<div key={s.status} style={{
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 50px 40px' : '10px 1fr 70px 55px',
gap: '0 0.4rem', padding: '0.4rem 0.3rem', alignItems: 'center',
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,
{/* Status Table — 4 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
<SectionHeader title="Все статусы" subtitle={periodLabel} mobile={mobile} />
{statusPieData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
) : (
<div>
<div style={{
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 45px 40px' : '12px 1fr 60px 55px',
gap: '0 0.5rem', padding: '0.4rem 0.3rem', alignItems: 'center',
borderBottom: '1px solid var(--color-border)', fontSize: fontSize.xs,
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>
{statusPieData.map(s => {
const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0;
return (
<div key={s.status} style={{
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 45px 40px' : '12px 1fr 60px 55px',
gap: '0 0.5rem', padding: '0.5rem 0.3rem', alignItems: 'center',
borderBottom: '1px solid var(--color-border, rgba(51,65,85,0.4))',
}}>
<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' }}>
{pct}%
</span>
<div style={{ width: mobile ? '8px' : '12px', height: mobile ? '8px' : '12px', 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: 700, color: 'var(--color-text)' }}>{s.value}</div>
<div style={{ textAlign: 'right', fontSize: fontSize.s, color: 'var(--color-text-muted)' }}>{pct}%</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>
{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 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>
)}
</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>
)}
</Panel>
</Panel>
{/* SMS */}
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>SMS</h3>
<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: '1px' }}>{item.label}</div>
<div style={{ fontSize: mobile ? '1rem' : '1.1rem', fontWeight: 700, color: 'var(--color-text)' }}>{item.val}</div>
</div>
))}
{/* Drivers — 8 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(8) }}>
<SectionHeader title="По водителям" subtitle={periodLabel} mobile={mobile} />
{driverData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
) : (
<ResponsiveContainer width="100%" height={Math.max(180, driverData.length * (mobile ? 35 : 50))}>
<BarChart data={driverData} layout="vertical" margin={{ left: mobile ? 5 : 20, right: mobile ? 5 : 30 }}>
<XAxis type="number" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} width={mobile ? 80 : 130} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: fontSize.xs }} />
<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>
</Panel>
{/* 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',
pickup: '#f59e0b',
delivery: '#6366f1',
picked_up: '#14b8a6',
pending: '#94a3b8',
manual: '#eab308',
};
const CustomTooltip = ({ active, payload }) => {
@ -34,17 +37,30 @@ const CustomTooltip = ({ active, payload }) => {
const ProgressBar = ({ label, value, max, color, fontSize, mobile }) => {
const pct = max > 0 ? Math.max(0, (value / max) * 100) : 0;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<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: '1 1 auto', height: mobile ? '16px' : '20px', background: 'var(--color-border, rgba(51,65,85,0.4))', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<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' : '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>
<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>
);
};
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) {
return (
<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 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 avgDays = stats.avg_days_until_pickup != null ? Number(stats.avg_days_until_pickup) : null;
const dist = stats.delivery_type_dist || {};
const maxDay = Math.max(
Number(stats.pickup_today) || 0,
Number(stats.pickup_tomorrow) || 0,
Number(stats.pickup_day_after) || 0,
1
);
const pickupToday = Number(stats.pickup_today) || 0;
const pickupTomorrow = Number(stats.pickup_tomorrow) || 0;
const pickupDayAfter = Number(stats.pickup_day_after) || 0;
const pickupFirstHalf = Number(stats.pickup_first_half) || 0;
const pickupSecondHalf = Number(stats.pickup_second_half) || 0;
const pickupOnSaturday = Number(stats.pickup_on_saturday) || 0;
const maxHalf = Math.max(
Number(stats.pickup_first_half) || 0,
Number(stats.pickup_second_half) || 0,
1
);
const hasScheduledPickups = pickupToday + pickupTomorrow + pickupDayAfter > 0;
const hasTimeSlots = pickupFirstHalf + pickupSecondHalf > 0;
const maxDay = Math.max(pickupToday, pickupTomorrow, pickupDayAfter, 1);
const maxHalf = Math.max(pickupFirstHalf, pickupSecondHalf, 1);
const pieData = [
{ name: 'Самовывоз', value: Number(dist.pickup) || 0, fill: PICKUP_COLORS.pickup },
{ name: 'Доставка', value: Number(dist.delivery) || 0, fill: PICKUP_COLORS.delivery },
].filter(d => d.value > 0);
// Desktop: 3-col grid inside panel. Mobile: stacked.
const innerCols = mobile ? '1fr' : '1fr 1fr 1fr';
return (
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fs.l, fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}>
📦 Самовывоз
</h3>
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem' }}>
<SectionHeader title="📦 Самовывоз" subtitle={periodLabel} mobile={mobile} />
{/* 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: pickupRate + '%', color: '#f59e0b' },
{ label: 'Ср. дней до выдачи', val: avgDays !== null ? avgDays : '—', color: '#3b82f6' },
{ label: 'Всего', val: totalPickups, color: '#f59e0b' },
{ label: 'Завершено', val: pickedUp, color: '#14b8a6' },
{ label: 'Ожидает', val: pending, color: '#94a3b8' },
{ label: 'Доля', val: pickupRate + '%', color: '#f59e0b' },
].map((kpi, i) => (
<div key={i} style={{ textAlign: 'center' }}>
<div style={{ fontSize: fs.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>{kpi.label}</div>
<div style={{ fontSize: mobile ? '1rem' : '1.2rem', fontWeight: 700, color: kpi.color }}>{kpi.val}</div>
<div key={i} style={{ textAlign: 'center', padding: '0.4rem 0.2rem' }}>
<div style={{ fontSize: fs.xs, color: 'var(--color-text-muted)', marginBottom: '2px', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{kpi.label}</div>
<div style={{ fontSize: mobile ? '1.05rem' : '1.3rem', fontWeight: 800, color: kpi.color }}>{kpi.val}</div>
</div>
))}
</div>
{/* Distribution by day */}
<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_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>
{/* Content grid: status breakdown | schedule | donut */}
<div style={{ display: 'grid', gridTemplateColumns: innerCols, gap: mobile ? '0.75rem' : '1.5rem' }}>
{/* Half-day split */}
<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 && (
{/* Status breakdown */}
<div>
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>Доставка vs Самовывоз</div>
<ResponsiveContainer width="100%" height={mobile ? 140 : 170}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%"
innerRadius={mobile ? 30 : 40}
outerRadius={mobile ? 55 : 70}
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.2rem' }}>
{pieData.map((d, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: fs.xs }}>
<div style={{ width: '8px', height: '8px', 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 style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>По статусам</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<ProgressBar label="Завершено" value={pickedUp} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.picked_up} fontSize={fs} mobile={mobile} />
<ProgressBar label="Ожидает" value={pending} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.pending} fontSize={fs} mobile={mobile} />
<ProgressBar label="Ручное" value={manual} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.manual} fontSize={fs} mobile={mobile} />
</div>
{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)' }}>
<span style={{ fontSize: fs.s, color: 'var(--color-text-muted)' }}>Ср. дней до выдачи</span>
<span style={{ fontSize: fs.l, fontWeight: 700, color: '#3b82f6' }}>{avgDays}</span>
</div>
)}
</div>
{/* Schedule — only show if there are scheduled pickups */}
<div>
<div style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>Расписание</div>
{hasScheduledPickups ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem', marginBottom: '0.5rem' }}>
<ProgressBar label="Сегодня" value={pickupToday} max={maxDay} color={PICKUP_COLORS.today} fontSize={fs} mobile={mobile} />
<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>
)}
{/* 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>
);
};
};

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 day = d.getUTCDay();
return day === 0 || day === 6;
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
// 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);
while (isWeekend(next)) {
while (!isAllowedPickupDay(next, allowedDays)) {
next = addDaysKey(next, 1);
}
return next;
};
const getPickupSlots = (referenceDate = new Date()) => {
const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5]) => {
const todayKey = getCrimeaTodayKey(referenceDate);
const hour = getCrimeaHour(referenceDate);
const isTodayWorkday = !isWeekend(todayKey);
const isTodayWorkday = isAllowedPickupDay(todayKey, pickupDays);
const slots = [];
@ -77,7 +80,7 @@ const getPickupSlots = (referenceDate = new Date()) => {
}
const tomorrow = addDaysKey(todayKey, 1);
const tomorrowWorkday = !isWeekend(tomorrow) ? tomorrow : getNextWorkday(todayKey);
const tomorrowWorkday = isAllowedPickupDay(tomorrow, pickupDays) ? tomorrow : getNextPickupWorkday(todayKey, pickupDays);
slots.push({
id: `pickup-${tomorrowWorkday}-first`,
date: tomorrowWorkday,
@ -94,7 +97,7 @@ const getPickupSlots = (referenceDate = new Date()) => {
});
const dayAfter = addDaysKey(tomorrowWorkday, 1);
const dayAfterWorkday = !isWeekend(dayAfter) ? dayAfter : getNextWorkday(dayAfter);
const dayAfterWorkday = isAllowedPickupDay(dayAfter, pickupDays) ? dayAfter : getNextPickupWorkday(dayAfter, pickupDays);
slots.push({
id: `pickup-${dayAfterWorkday}-first`,
date: dayAfterWorkday,
@ -152,8 +155,9 @@ export const PickupSlotsPicker = ({
onSelectSlot,
selectedSlotId,
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) {
return (

View File

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

View File

@ -261,7 +261,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
}
return (
<div className="space-y-4">
<div className="space-y-4 fs-zone-table">
<Panel className="space-y-3 p-5">
<div className="space-y-4">
<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 [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(() => {
setShippedItems(initialShippedIds);
setComments(initialComments);
}, [initialShippedIds, initialComments]);
if (prevOrderId.current !== orderId) {
prevOrderId.current = orderId;
setShippedItems(initialShippedIds);
setComments(initialComments);
setJustSaved(false);
}
}, [orderId, initialShippedIds, initialComments]);
// Track last saved shipment data for visual display
React.useEffect(() => {
@ -170,7 +176,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
};
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 = () => {
if (isStatusFinal && onResetStatus) {
@ -222,7 +228,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
if (items.length === 0) {
return (
<Panel className="space-y-3 p-5">
<Panel className="space-y-3 p-5 fs-zone-card">
<strong>Состав заказа</strong>
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
</Panel>
@ -230,7 +236,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
}
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>
<strong>Отгрузка</strong>
@ -250,7 +256,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
Отгрузить всё
</Button>
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0 && !isStatusFinal}>
Сбросить
Сбросить отгрузку
</Button>
</div>

View File

@ -158,12 +158,12 @@ const renderRow = (group, onSelectSet) => (
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
</div>
<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>
{(group.hasDeliveryProblem || group.has_delivery_problem) && (
<span
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>
@ -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 }) => {
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 [sectionOrder, setSectionOrder] = React.useState(() => {
const custom = loadCustomOrder();

View File

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

View File

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

View File

@ -2,42 +2,60 @@
const DriverShipmentReport = ({ shipmentData }) => {
if (!Array.isArray(shipmentData) || shipmentData.length === 0) return null;
const deliveredItems = shipmentData.filter((i) => i.shipped);
const notDeliveredItems = shipmentData.filter((i) => !i.shipped);
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">
<svg className="h-5 w-5 text-[var(--color-warning)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<strong className="text-[var(--color-warning)]">Проблемы с доставкой позиций</strong>
<span className="text-lg">📋</span>
<strong>Отчёт об отгрузке</strong>
<Badge tone={notDeliveredItems.length > 0 ? "warning" : "accent"}>
{deliveredItems.length}/{shipmentData.length} доставлено
</Badge>
</div>
<p className="text-sm text-[var(--color-text-muted)]">
Не доставлено {shipmentData.length} {shipmentData.length === 1 ? "позиция" : shipmentData.length < 5 ? "позиции" : "позиций"}. Остальное — доставлено.
</p>
{notDeliveredItems.length > 0 && (
<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">
{shipmentData.map((item) => (
<div
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">
<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 ? (
<Badge tone="neutral">{[item.quantity, item.unit].filter(Boolean).join(" ")}</Badge>
) : null}
</div>
{item.comment ? (
<p className="mt-1 text-xs text-[var(--color-text-muted)]">Причина: {item.comment}</p>
) : (
<p className="mt-1 text-xs text-[var(--color-text-muted)] italic">Причина не указана</p>
{!item.shipped && item.comment && (
<p className="mt-1 ml-6 text-xs text-[var(--color-warning)]">Причина: {item.comment}</p>
)}
</div>
))}
</div>
</Panel>
);
};
import React from "react";
import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge";
@ -54,7 +72,11 @@ import {
getOrderGroupStatusTone,
DELIVERY_GROUP_STATUS_LABELS,
} from "../../services/orderGroupViews";
import { getErrorMessage, normalizeNom } from "../../utils/deliveryUtils";
import {
getErrorMessage,
normalizeNom,
} from "../../utils/deliveryUtils";
import { SmsStatusCard } from "./SmsStatusCard";
const fmtTime = (ts) => {
if (!ts) return "—";
@ -331,6 +353,12 @@ const normalizeDateForInput = (value) => {
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 "";
};
@ -594,11 +622,19 @@ export const OrderDetailPanel = ({
setIsSavingShipment(false);
}
}, [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 existingDeliveryDate = fromDateKey(order?.deliveryDate);
const fallbackDate = fromDateKey(minSelectableDateKey) || new Date();
const sourceDate = existingDeliveryDate && isFutureDeliveryDate(toDateKey(existingDeliveryDate))
const sourceDate = existingDeliveryDate
? existingDeliveryDate
: fallbackDate;
@ -613,7 +649,10 @@ export const OrderDetailPanel = ({
}),
[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(() => {
setSelectedDriverId(order?.assignedDriverId || "");
@ -621,9 +660,12 @@ export const OrderDetailPanel = ({
}, [order?.id, order?.assignedDriverId]);
React.useEffect(() => {
const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate);
const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate || order?.customerDate);
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);
const selectedDate = fromDateKey(selectedDateKey) || new Date();
setCurrentMonth(startOfMonth(selectedDate));
@ -686,7 +728,8 @@ export const OrderDetailPanel = ({
return;
}
if (!isFutureDeliveryDate(effectiveDate)) {
const canUsePastDate = ["logistician", "admin", "mega_admin", "manager"].includes(userRole);
if (!canUsePastDate && !isFutureDeliveryDate(effectiveDate)) {
setFormMessage(deliveryType === "pickup" ? "Выберите дату самовывоза позже сегодняшнего дня." : "Выберите дату доставки позже сегодняшнего дня.");
return;
}
@ -889,10 +932,7 @@ export const OrderDetailPanel = ({
<p className="text-xs text-[var(--color-text-muted)]">Обновлена</p>
<p className="font-medium !text-[var(--color-text)]">{formatDateTime(order.updatedAt)}</p>
</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") ? (
<div>
<p className="text-xs text-[var(--color-text-muted)]">Код выдачи</p>
@ -1053,6 +1093,7 @@ export const OrderDetailPanel = ({
/>
)}
<SmsStatusCard order={order} userRole={userRole} />
<StatusActionPanel
order={order}
@ -1087,7 +1128,7 @@ export const OrderDetailPanel = ({
/>
) : null}
{userRole === "driver" && order ? (
{["driver", "logistician", "admin", "mega_admin"].includes(userRole) && order ? (
<DriverShipmentPanel
order={order}
onShipmentChange={handleShipmentChange}
@ -1097,7 +1138,7 @@ export const OrderDetailPanel = ({
if (onChangeDeliveryStatus) {
onChangeDeliveryStatus({
orderGroupId: order.id,
status: "loaded",
status: "driver_assigned",
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось сбросить статус");
@ -1131,15 +1172,15 @@ export const OrderDetailPanel = ({
<div className="flex flex-wrap gap-2">
{(() => {
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";
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 = [];
} else {
// Primary button matches delivery type, secondary requires confirmation
if (isPickup) {
statusOptions = [
{ 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);
if (statusOptions.length === 0 && !canReturn) return null;
@ -1163,6 +1203,7 @@ export const OrderDetailPanel = ({
return statusOptions.map((statusOption) => {
const isSelected = pendingStatus?.value === statusOption.value;
const isMismatch = statusOption.mismatch;
const blockedBySchedule = statusOption.requiresSchedule && !hasDeliverySchedule;
return (
<Button
key={statusOption.value}
@ -1196,7 +1237,7 @@ export const OrderDetailPanel = ({
disabled={isSavingStatusChange}
onClick={() => {
setPendingStatus({
value: "loaded",
value: "driver_assigned",
label: "Вернуть в работу",
mismatch: false,
deliveryType: "delivery",
@ -1303,7 +1344,7 @@ export const OrderDetailPanel = ({
<CollapsibleOrderComposition order={order} />
</Panel>
{userRole !== "driver" && order?.deliveryLink ? (
{order?.deliveryLink ? (
<Panel className="space-y-3 p-5">
<strong>Ссылка на согласование</strong>
<p className="text-sm text-[var(--color-text-muted)]">

View File

@ -22,6 +22,8 @@ const NOTIF_LABELS = {
paid_storage_sending: "Отправляется…",
paid_storage_sent: "Платное хранение: отправлено",
draft: "Черновик",
confirmed: "Клиент согласовал дату",
completed: "Завершено",
};
const NOTIF_TONES = {
@ -36,6 +38,8 @@ const NOTIF_TONES = {
paid_storage_sending: "info",
paid_storage_sent: "accent",
draft: "neutral",
confirmed: "accent",
completed: "neutral",
};
// ── Helpers ──────────────────────────────────────────────────────────────────
@ -173,11 +177,8 @@ export const SmsStatusCard = ({ order, userRole }) => {
return (
<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>
<Badge tone={NOTIF_TONES[notifStatus] || "neutral"}>
{NOTIF_LABELS[notifStatus] || notifStatus}
</Badge>
</div>
{/* Timeline */}
@ -188,9 +189,9 @@ export const SmsStatusCard = ({ order, userRole }) => {
<div className="flex-1">
<div className="text-[var(--color-text)]">1-е SMS</div>
{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" ? (
<div className="text-[var(--color-text-muted)]">{fmtTime(smsSentAt)} · отправлено, ждём подтверждения…</div>
<div className="text-[var(--color-text-muted)]">{fmtTime(smsSentAt)} · отправлено, ждём ответ оператора…</div>
) : notifStatus === "link_ready" ? (
<div className="text-[var(--color-text-muted)]">в очереди на отправку</div>
) : (
@ -205,7 +206,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
<div className="flex-1">
<div className="text-[var(--color-text)]">2-е SMS</div>
{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 ? (
<div className="text-[var(--color-text-muted)]">
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
@ -260,6 +261,23 @@ export const SmsStatusCard = ({ order, userRole }) => {
</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 */}
{canManage && (
<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 { DELIVERY_GROUP_STATUS_LABELS } from "../../services/orderGroupViews";
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
const StatusActionPanel = ({
order,
userRole,
@ -18,6 +16,48 @@ const StatusActionPanel = ({
}
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 (
<Panel className="space-y-4 p-5">
@ -27,41 +67,71 @@ const StatusActionPanel = ({
Измените статус, если водитель забыл обновить или нужна корректировка.
</p>
</div>
{/* Status indicators: show current state clearly */}
<div className="flex flex-wrap gap-2">
{[
{ value: "pending_confirmation", label: "Ожидает согласования", manual: true },
{ value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" },
{ value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" },
{ value: "loaded", label: "Загружено", manual: true },
{ value: "delivered", label: "Доставлено", manual: true },
{ value: "picked_up", label: "Вывезено", manual: true },
{ value: "requires_address", label: "Требуется адрес", manual: true },
{ value: "problem", label: "Проблема", manual: true },
{ value: "cancelled", label: "Отменено", manual: true },
].map((statusOption) => {
{hasDeliverySchedule && (
<Badge tone="accent">✓ Дата согласована</Badge>
)}
{hasDriver && (
<Badge tone="accent">✓ Водитель: {order.assignedDriverName || "назначен"}</Badge>
)}
{!hasDeliverySchedule && (
<Badge tone="warning">⚠ Дата не указана</Badge>
)}
{!hasDriver && !isPickup && (
<Badge tone="warning">⚠ Водитель не назначен</Badge>
)}
</div>
<div className="flex flex-wrap gap-2">
{allStatuses.map((statusOption) => {
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 (
<div key={statusOption.value} className="relative group">
<Button
variant={isCurrent ? "primary" : "secondary"}
onClick={() => {
if (!isClickable) {
onConfirmStatus?.({ type: "hint", hint: statusOption.hint || "" });
return;
}
onConfirmStatus?.({ type: "status", status: statusOption.value });
}}
disabled={isSavingStatusChange}
>
{statusOption.label}
</Button>
</div>
<Button
key={statusOption.value}
variant={isCurrent ? "primary" : (statusOption.mismatch ? "ghost" : "secondary")}
onClick={() => {
if (blockedBySchedule) {
return;
}
if (blockedByDriver) {
onConfirmStatus?.({
type: "hint",
hint: "⚠ Назначьте водителя перед статусом «Доставлено»",
});
return;
}
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>
{!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>
);
};
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,
};
@ -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 || "Комментарий не задан.";

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 = () => {
const [orderGroups, setOrderGroups] = React.useState(() => []);
const [filters, setFilters] = React.useState({
query: "",
displayStatus: "all",
deliveryType: "",
const FILTERS_STORAGE_KEY = "supersam_order_filters";
const [filters, setFilters] = React.useState(() => {
try {
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 [isLoading, setIsLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState("");

View File

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

View File

@ -39,7 +39,7 @@ export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
"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 = {
morning: "Первая половина дня",
@ -143,9 +143,17 @@ export const isOrderGroupAgreedForDelivery = (group) => {
export const getOrderGroupDeliveryStatusLabel = (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) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
const statusCol = group?.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
const isManualRequired = notificationStatus === "manual_required";
@ -154,6 +162,12 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
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") {
return getOrderGroupDeliveryStatusLabel(deliveryStatus);
}
@ -163,12 +177,13 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
return notificationLabel;
}
return getOrderGroupStatusLabel(group?.status);
return getOrderGroupStatusLabel(statusCol);
};
export const getOrderGroupDisplayStatusValue = (group) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
const statusCol = group?.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
const isManualRequired = notificationStatus === "manual_required";
@ -177,11 +192,17 @@ export const getOrderGroupDisplayStatusValue = (group) => {
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") {
return `delivery:${deliveryStatus}`;
}
return `status:${group?.status || "unknown"}`;
return `status:${statusCol || "unknown"}`;
};
export const isOrderGroupVisibleToDriver = (group) => {
@ -483,10 +504,16 @@ export const buildOrderGroupBuckets = (groups) => {
export const getOrderGroupStatusTone = (group) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
const statusCol = group?.status;
// Highlight groups with delivery problems
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") {
return getOrderGroupDeliveryStatusTone(deliveryStatus);
}

View File

@ -142,12 +142,23 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
const extractCity = (addr) => {
if (!addr) return "";
// 1) explicit marker: г. Ялта, пгт. Куйбышево, etc.
const m = addr.match(/(?:г\.\s|гор\.\s|пос\.\s|с\.\s|дер\.\s|пгт\.\s|город\s|село\s|г\s)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\\s]|$)/i);
if (m) return m[1].trim();
// 2) known city name anywhere in address (case-insensitive)
// Word markers (город/село) require space after to avoid matching "Стройгородок"
// Dot markers (г./гор./etc) allow zero space (г.Ялта)
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();
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) Бахчисарайский р-н → Бахчисарай
const district = addr.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i);
@ -460,15 +471,22 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
return safeSupabaseCall(async () => {
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
.from("order_groups")
.select("delivery_status")
.select("delivery_status, delivery_type")
.eq("id", orderGroupId)
.single();
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
// Server-side RPC still enforces driver-assignment checks that block
// 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")
.update({
delivery_status: status,
status: statusSync,
paid_storage_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
@ -490,6 +509,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
.from("order_groups")
.update({
delivery_status: status,
status: statusSync,
paid_storage_at: null,
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];
};
export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => {
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,
@ -137,12 +154,18 @@ export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
return next;
};
// Skip Sunday (getUTCDay() === 0) — never offer Sunday delivery
const isSunday = (date: Date) => date.getUTCDay() === 0;
// Check if date is an allowed delivery day
// 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 (isSunday(next)) {
while (!isAllowedDay(next)) {
next = addDays(next, 1);
}
return next;

View File

@ -31,11 +31,31 @@ type ConfirmBody = {
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;
const date = new Date(`${value}T12:00:00Z`);
const weekday = date.getUTCDay();
return weekday === 0; // 0=Sunday — never allow Sunday delivery
const dow = date.getUTCDay();
// 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 = (
@ -45,6 +65,7 @@ const resolveRequestedSlot = (
available_slots?: string[] | null;
},
body: ConfirmBody,
deliveryDays: number[] = [1, 2, 3, 4, 5],
) => {
const deliveryType = body.deliveryType || "delivery";
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
@ -59,8 +80,8 @@ const resolveRequestedSlot = (
return { deliveryDate, deliveryTime, deliveryType };
}
// Reject Sunday for delivery (business rule: never deliver on Sunday)
if (isWeekendDate(deliveryDate)) {
// Reject non-delivery days for delivery (business schedule)
if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) {
return null;
}
@ -120,6 +141,7 @@ Deno.serve(async (request) => {
const tokenHash = await hashInvitationToken(body.token);
const supabase = createServiceClient();
const deliveryDays = await getDeliveryDays(supabase);
const ipHash = await hashText(getClientIp(request));
await requireRateLimit(supabase, {
@ -180,7 +202,7 @@ Deno.serve(async (request) => {
);
}
const requestedSlot = resolveRequestedSlot(invitation, body);
const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays);
if (!requestedSlot) {
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];
};
export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
const formatIsoDate = (date: Date) => date.toISOString().slice(0, 10);
export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => {
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 next = new Date(date);
next.setUTCDate(next.getUTCDate() + days);
return next;
};
const firstDay = formatIsoDate(addDays(now, 1));
const secondDay = formatIsoDate(addDays(now, 2));
// Check if date is an allowed delivery day
// 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 [
`${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 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;
const date = new Date(`${value}T12:00:00Z`);
const weekday = date.getUTCDay();
return weekday === 0; // 0=Sunday — never allow Sunday delivery
const dow = date.getUTCDay();
// 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 = (
@ -45,6 +65,7 @@ const resolveRequestedSlot = (
available_slots?: string[] | null;
},
body: ConfirmBody,
deliveryDays: number[] = [1, 2, 3, 4, 5],
) => {
const deliveryType = body.deliveryType || "delivery";
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
@ -59,8 +80,8 @@ const resolveRequestedSlot = (
return { deliveryDate, deliveryTime, deliveryType };
}
// Reject Sunday for delivery (business rule: never deliver on Sunday)
if (isWeekendDate(deliveryDate)) {
// Reject non-delivery days for delivery (business schedule)
if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) {
return null;
}
@ -120,6 +141,7 @@ Deno.serve(async (request) => {
const tokenHash = await hashInvitationToken(body.token);
const supabase = createServiceClient();
const deliveryDays = await getDeliveryDays(supabase);
const ipHash = await hashText(getClientIp(request));
await requireRateLimit(supabase, {
@ -180,7 +202,7 @@ Deno.serve(async (request) => {
);
}
const requestedSlot = resolveRequestedSlot(invitation, body);
const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays);
if (!requestedSlot) {
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)