]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
[ERP-43-J] Уедомление, напоминание о закрытии и открытии смены.
authorAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Tue, 3 Feb 2026 20:57:32 +0000 (23:57 +0300)
committerAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Tue, 3 Feb 2026 20:57:32 +0000 (23:57 +0300)
15 files changed:
.claude/skills/changelog/SKILL.md [new file with mode: 0644]
.claude/skills/spec-debate/README.md [new file with mode: 0644]
.claude/skills/spec-debate/SKILL.md [new file with mode: 0644]
.claude/skills/spec-debate/scripts/detect-models.sh [new file with mode: 0644]
.claude/skills/spec-debate/scripts/run-debate.sh [new file with mode: 0644]
.gitignore
erp24/config/params.php
erp24/config/web.php
erp24/controllers/ShiftReminderController.php [new file with mode: 0644]
erp24/migrations/m260127_105454_create_shift_reminder_shown_table.php [new file with mode: 0644]
erp24/models/ShiftReminderShown.php [new file with mode: 0644]
erp24/services/ShiftReminderService.php [new file with mode: 0644]
erp24/views/layouts/main.php
erp24/views/shift-reminder/_modal.php [new file with mode: 0644]
erp24/web/js/shift-reminder.js [new file with mode: 0644]

diff --git a/.claude/skills/changelog/SKILL.md b/.claude/skills/changelog/SKILL.md
new file mode 100644 (file)
index 0000000..4823064
--- /dev/null
@@ -0,0 +1,377 @@
+---
+name: changelog
+description: Генерирует summary изменений из git diff/log. Используй когда нужно создать CHANGELOG запись, получить обзор изменений, подготовить release notes или описание для PR.
+---
+
+# Changelog Generator
+
+Генерирует детальное структурированное summary изменений с цветовым выделением.
+
+## ⚡ ПЕРВЫЙ ШАГ: Запрос разрешений
+
+**ОБЯЗАТЕЛЬНО** перед началом работы запроси у пользователя разрешение на выполнение команд.
+
+### Шаблон запроса разрешений
+
+Используй `AskUserQuestion` tool с таким форматом:
+
+```text
+Для генерации changelog мне потребуется выполнить следующие команды:
+
+📋 **Git команды (только чтение):**
+• `git status` — статус рабочей директории
+• `git diff --stat HEAD` — статистика изменений
+• `git diff --name-status HEAD` — список изменённых файлов
+• `git diff HEAD` — содержимое изменений
+• `git log --oneline -10` — последние коммиты
+• `git branch --show-current` — текущая ветка
+
+📁 **Анализ файлов:**
+• Чтение изменённых файлов для понимания контекста
+
+Разрешить выполнение?
+```
+
+### Варианты ответа пользователя
+
+Предложи варианты через `AskUserQuestion`:
+
+| Вариант | Описание |
+| ------- | -------- |
+| Разрешить все | Выполнить полный анализ |
+| Только статус | Минимальный отчёт (git status + diff --stat) |
+| Показать команды | Вывести команды без выполнения |
+
+### Пример использования AskUserQuestion
+
+```json
+{
+  "questions": [{
+    "question": "Какой уровень анализа выполнить для changelog?",
+    "header": "Changelog",
+    "options": [
+      {
+        "label": "Полный анализ (Рекомендуется)",
+        "description": "git status, diff, log + анализ содержимого файлов"
+      },
+      {
+        "label": "Быстрый отчёт",
+        "description": "Только git status и diff --stat"
+      },
+      {
+        "label": "Показать план",
+        "description": "Показать какие команды будут выполнены"
+      }
+    ],
+    "multiSelect": false
+  }]
+}
+```
+
+### После получения разрешения
+
+1. **Полный анализ** → выполнить все команды из секции "Команды для сбора данных"
+2. **Быстрый отчёт** → только `git status -s` и `git diff --stat HEAD`
+3. **Показать план** → вывести список команд и спросить повторно
+
+---
+
+## Кликабельные ссылки на файлы
+
+**ОБЯЗАТЕЛЬНО** все пути к файлам оформляй как markdown-ссылки для IDE:
+
+### Формат ссылок
+
+```markdown
+# Файл (кликабельный)
+[filename.php](path/to/filename.php)
+
+# Файл с номером строки
+[filename.php:42](path/to/filename.php#L42)
+
+# Диапазон строк
+[filename.php:10-25](path/to/filename.php#L10-L25)
+
+# Папка
+[controllers/](controllers/)
+```
+
+### Примеры в changelog
+
+```text
+🟡 ИЗМЕНЕНО
+───────────────────────────────────────────────────────────────
+~ [OrderController.php](erp24/controllers/OrderController.php)    [+45 -12]
+  └─ Новые методы:
+     • [actionExport():52](erp24/controllers/OrderController.php#L52)
+     • [actionBulkUpdate():78](erp24/controllers/OrderController.php#L78)
+```
+
+### Правила форматирования ссылок
+
+| Элемент | Формат | Пример |
+| ------- | ------ | ------ |
+| Изменённый файл | `[name](path)` | `[User.php](models/User.php)` |
+| Новый метод | `[method:line](path#L)` | `[save():45](models/User.php#L45)` |
+| Удалённый файл | `~~[name](path)~~` | `~~[Old.php](old/Old.php)~~` |
+| Папка | `[name/](path/)` | `[services/](app/services/)` |
+
+---
+
+## Цветовая схема вывода
+
+При выводе summary ОБЯЗАТЕЛЬНО используй цветовое выделение:
+
+| Тип | Цвет/Формат | Пример |
+| --- | ----------- | ------ |
+| Добавлено | 🟢 **зелёный** | `+ новый файл` |
+| Изменено | 🟡 **жёлтый** | `~ модификация` |
+| Удалено | 🔴 **красный** | `- удалённый файл` |
+| Переименовано | 🔵 **синий** | `→ старое → новое` |
+| Критично | ⚠️ **предупреждение** | Миграции, безопасность |
+| Статистика | 📊 **инфо** | Строки, покрытие |
+
+## Формат детального вывода
+
+### Шаблон summary
+
+```text
+═══════════════════════════════════════════════════════════════
+                    📋 SUMMARY ИЗМЕНЕНИЙ
+═══════════════════════════════════════════════════════════════
+
+📅 Дата: YYYY-MM-DD HH:MM
+🌿 Ветка: feature/xxx → develop
+👤 Автор: name
+
+───────────────────────────────────────────────────────────────
+🟢 ДОБАВЛЕНО (N файлов)
+───────────────────────────────────────────────────────────────
++ [new-file.php](path/to/new-file.php)                    [+150 строк]
+  └─ Описание: Новый сервис для обработки заказов
+  └─ Классы: OrderProcessingService
+  └─ Методы: [process():15](path/to/new-file.php#L15), validate(), notify()
+
++ [another-file.ts](path/to/another-file.ts)              [+80 строк]
+  └─ Описание: React компонент формы
+  └─ Компоненты: OrderForm, OrderFormProps
+
+───────────────────────────────────────────────────────────────
+🟡 ИЗМЕНЕНО (N файлов)
+───────────────────────────────────────────────────────────────
+~ [modified-file.php](path/to/modified-file.php)          [+50 -20 строк]
+  └─ Что изменено:
+     • Добавлен [calculateDiscount():42](path/to/modified-file.php#L42)
+     • Рефакторинг [processOrder():78](path/to/modified-file.php#L78)
+     • Обновлены типы параметров
+  └─ Затронутые методы: calculate(), process(), validate()
+
+~ [config.php](path/to/config.php)                        [+5 -2 строк]
+  └─ Что изменено:
+     • Добавлен новый параметр 'cache_ttl'
+     • Изменено значение 'timeout': 30 → 60
+
+───────────────────────────────────────────────────────────────
+🔴 УДАЛЕНО (N файлов)
+───────────────────────────────────────────────────────────────
+- ~~[deprecated-file.php](path/to/deprecated-file.php)~~  [-200 строк]
+  └─ Причина: Заменён на NewService
+  └─ Зависимости: Проверить [Controller.php](path/to/Controller.php)
+
+───────────────────────────────────────────────────────────────
+🔵 ПЕРЕИМЕНОВАНО (N файлов)
+───────────────────────────────────────────────────────────────
+→ [old-name.php](old-name.php) → [new-name.php](new-name.php)
+  └─ Причина: Соответствие PSR-4
+
+───────────────────────────────────────────────────────────────
+⚠️  ТРЕБУЕТ ВНИМАНИЯ
+───────────────────────────────────────────────────────────────
+• 🗄️  Миграции: m240115_120000_add_orders_table.php
+     └─ Действие: php yii migrate
+     └─ Откат: php yii migrate/down 1
+
+• 🔐 Безопасность: изменения в auth/
+     └─ Проверить: права доступа, токены
+
+• ⚙️  Конфигурация: .env.example обновлён
+     └─ Добавить: NEW_API_KEY в .env
+
+───────────────────────────────────────────────────────────────
+📊 СТАТИСТИКА
+───────────────────────────────────────────────────────────────
+Файлов изменено:  12
+Строк добавлено:  +1,250  🟢
+Строк удалено:    -340    🔴
+Чистый прирост:   +910
+
+По категориям:
+  Controllers:    3 файла   (+200 -50)
+  Models:         4 файла   (+400 -100)
+  Services:       2 файла   (+300 -90)
+  Views:          2 файла   (+250 -80)
+  Tests:          1 файл    (+100 -20)
+
+═══════════════════════════════════════════════════════════════
+```
+
+## Команды для сбора данных
+
+### 1. Детальная информация об изменениях
+
+```bash
+# Статистика по файлам с цветом
+git diff --stat --color HEAD
+
+# Подробный diff для анализа ЧТО изменилось
+git diff HEAD -- "*.php" | head -100
+
+# Изменённые функции/методы (для PHP)
+git diff HEAD --function-context -- "*.php"
+
+# Список всех изменений с типом
+git diff --name-status HEAD
+```
+
+### 2. Анализ содержимого изменений
+
+```bash
+# Найти добавленные/удалённые методы
+git diff HEAD | grep -E "^\+.*function |^\-.*function "
+
+# Найти изменённые классы
+git diff HEAD | grep -E "^\+.*class |^\-.*class "
+
+# Найти новые use/import
+git diff HEAD | grep -E "^\+.*use |^\+.*import "
+```
+
+### 3. Для коммитов
+
+```bash
+# Детальный лог с изменениями
+git log --stat --oneline -5
+
+# Показать что изменено в каждом коммите
+git log -p --oneline -3
+
+# Группировка по автору
+git shortlog -sn --since="1 week ago"
+```
+
+## Workflow детального анализа
+
+### Шаг 1: Собрать сырые данные
+
+```bash
+# 1. Список файлов
+git diff --name-status HEAD > /tmp/files.txt
+
+# 2. Статистика строк
+git diff --numstat HEAD > /tmp/stats.txt
+
+# 3. Содержимое изменений для анализа
+git diff HEAD > /tmp/diff.txt
+```
+
+### Шаг 2: Проанализировать каждый файл
+
+Для каждого изменённого файла определить:
+
+1. **Тип файла** → категория (Controller, Model, Service, etc.)
+2. **Что добавлено** → новые методы, классы, свойства
+3. **Что изменено** → модифицированные методы, параметры
+4. **Что удалено** → удалённые методы, deprecated код
+5. **Зависимости** → что может сломаться
+
+### Шаг 3: Классифицировать по важности
+
+| Приоритет | Категория | Описание |
+| --------- | --------- | -------- |
+| 🔴 Critical | migrations, security, .env | Требует действий |
+| 🟠 High | API changes, breaking changes | Влияет на других |
+| 🟡 Medium | business logic, services | Основная работа |
+| 🟢 Low | docs, tests, refactoring | Улучшения |
+
+### Шаг 4: Сформировать вывод
+
+Использовать шаблон выше с заполненными данными.
+
+## Примеры детального вывода
+
+### Пример: Изменённый контроллер
+
+```text
+🟡 ИЗМЕНЕНО
+───────────────────────────────────────────────────────────────
+~ [OrderController.php](erp24/controllers/OrderController.php)    [+45 -12 строк]
+  │
+  ├─ Новые методы:
+  │  • [actionExport():52](erp24/controllers/OrderController.php#L52) — экспорт заказов в Excel
+  │  • [actionBulkUpdate():89](erp24/controllers/OrderController.php#L89) — массовое обновление
+  │
+  ├─ Изменённые методы:
+  │  • [actionIndex():15](erp24/controllers/OrderController.php#L15) — добавлена пагинация
+  │  • [actionView():34](erp24/controllers/OrderController.php#L34) — добавлено кеширование
+  │
+  ├─ Новые зависимости:
+  │  • use [ExportService](app/services/ExportService.php)
+  │  • use [CacheHelper](app/helpers/CacheHelper.php)
+  │
+  └─ Влияние:
+     • API: новые эндпоинты /order/export, /order/bulk-update
+     • Права: требуется permission 'order.export'
+```
+
+### Пример: Новая миграция
+
+```text
+⚠️  ТРЕБУЕТ ВНИМАНИЯ
+───────────────────────────────────────────────────────────────
+🗄️  Миграция: [m240128_100000_add_order_status_history.php](migrations/m240128_100000_add_order_status_history.php)
+
+  Создаёт таблицу: order_status_history
+  ┌─────────────────┬──────────┬─────────────────────┐
+  │ Поле            │ Тип      │ Описание            │
+  ├─────────────────┼──────────┼─────────────────────┤
+  │ id              │ int PK   │ ID записи           │
+  │ order_id        │ int FK   │ Связь с orders      │
+  │ old_status      │ int      │ Предыдущий статус   │
+  │ new_status      │ int      │ Новый статус        │
+  │ created_at      │ datetime │ Время изменения     │
+  │ created_by      │ int FK   │ Кто изменил         │
+  └─────────────────┴──────────┴─────────────────────┘
+
+  Действия:
+  ✅ Применить: php yii migrate
+  ↩️  Откатить: php yii migrate/down 1
+
+  ⚠️  Внимание: требуется ~30 сек на больших данных
+```
+
+## Интеграция с другими skills
+
+### После /changelog → /git-commit
+
+Summary используется для формирования commit message:
+
+```text
+feat(orders): add export and bulk update functionality
+
+- Add actionExport() for Excel export
+- Add actionBulkUpdate() for mass status changes
+- Improve actionIndex() with pagination and filters
+- Add order_status_history migration
+
+Affects: OrderController, ExportService
+Migration: m240128_100000_add_order_status_history
+```
+
+## Важные правила
+
+- **ВСЕГДА** читать реальный diff для понимания изменений
+- **ДЕТАЛИЗИРОВАТЬ** что именно изменилось (методы, параметры)
+- **ВЫДЕЛЯТЬ** критичные изменения (миграции, безопасность, API)
+- **ГРУППИРОВАТЬ** по категориям с emoji-маркерами
+- **УКАЗЫВАТЬ** влияние изменений на другие части системы
+- **ФОРМАТИРОВАТЬ** с использованием рамок и отступов для читаемости
diff --git a/.claude/skills/spec-debate/README.md b/.claude/skills/spec-debate/README.md
new file mode 100644 (file)
index 0000000..4cfba09
--- /dev/null
@@ -0,0 +1,148 @@
+# Spec Debate
+
+Interactive adversarial specification refinement using multiple LLM models.
+
+## Quick Start
+
+```bash
+# Interactive mode
+/spec-debate
+
+# Review existing spec
+/spec-debate ./docs/my-spec.md
+
+# Generate tech spec from PRD
+/spec-debate --from=./prd.md --to=tech-spec
+```
+
+## Features
+
+### Custom Focus
+
+Focus critique on specific aspects:
+
+```bash
+# Predefined focuses
+/spec-debate ./spec.md --focus=security
+/spec-debate ./spec.md --focus=performance
+
+# Custom focus (any text)
+/spec-debate ./spec.md --focus="API backward compatibility and versioning"
+/spec-debate ./prd.md --focus="retail analytics, POS integration, real-time reporting"
+```
+
+### Document Generation
+
+Generate new specifications based on existing documents:
+
+```bash
+# Tech spec from PRD
+/spec-debate --from=./prd-v3.md --to=tech-spec
+
+# API spec from tech spec
+/spec-debate --from=./tech-spec.md --to=api-spec
+
+# DB schema from tech spec
+/spec-debate --from=./tech-spec.md --to=db-schema
+
+# Deployment spec from tech spec
+/spec-debate --from=./tech-spec.md --to=deployment-spec
+```
+
+### Combined Mode
+
+Generate with specific focus:
+
+```bash
+/spec-debate --from=./prd.md --to=tech-spec --focus="database schema design"
+/spec-debate --from=./prd.md --to=tech-spec --focus="microservices boundaries"
+```
+
+### Custom Persona
+
+Models can critique from a specific professional perspective:
+
+```bash
+# Predefined personas
+/spec-debate ./spec.md --persona=security-engineer
+/spec-debate ./spec.md --persona=oncall-engineer
+
+# Custom persona
+/spec-debate ./spec.md --persona="retail domain expert with 10 years experience"
+/spec-debate ./spec.md --persona="ML engineer focused on data pipelines"
+```
+
+## Target Document Types
+
+| Type | Description | Typical Source |
+|------|-------------|----------------|
+| `prd` | Product Requirements Document | Concept/description |
+| `tech-spec` | Technical Specification | PRD |
+| `api-spec` | API Specification (REST/GraphQL) | PRD or Tech Spec |
+| `db-schema` | Database Schema Documentation | Tech Spec |
+| `deployment-spec` | Infrastructure/Deployment Spec | Tech Spec |
+
+## Predefined Focus Modes
+
+| Mode | Description |
+|------|-------------|
+| `security` | Auth, encryption, vulnerabilities |
+| `scalability` | Scaling, sharding, caching |
+| `performance` | Latency, throughput, query optimization |
+| `ux` | User journeys, error handling, accessibility |
+| `reliability` | Failure modes, circuit breakers, DR |
+| `cost` | Infrastructure expenses, resource efficiency |
+
+## Predefined Personas
+
+| Persona | Perspective |
+|---------|-------------|
+| `security-engineer` | Adversarial thinking, paranoid about edge cases |
+| `oncall-engineer` | Observability, error messages, 3am debugging |
+| `junior-developer` | Flags ambiguity and tribal knowledge |
+| `qa-engineer` | Test scenarios, acceptance criteria |
+| `site-reliability` | Deployment, monitoring, incident response |
+| `product-manager` | User value, success metrics |
+| `data-engineer` | Data models, ETL implications |
+| `mobile-developer` | API design from mobile perspective |
+| `accessibility-specialist` | WCAG compliance |
+| `legal-compliance` | GDPR, CCPA, regulatory |
+
+## All Arguments
+
+| Argument | Description |
+|----------|-------------|
+| `<path>` | Path to spec file to review |
+| `--focus="..."` | Custom critique focus |
+| `--from=<path>` | Source document for generation |
+| `--to=<type>` | Target document type |
+| `--persona="..."` | Custom persona |
+| `--models=<list>` | Comma-separated model list |
+
+## Examples
+
+```bash
+# Simple review
+/spec-debate ./docs/api-spec.md
+
+# Security-focused review
+/spec-debate ./api-spec.md --focus="authentication, authorization, input validation"
+
+# Generate tech spec from PRD with domain focus
+/spec-debate --from=./prd-transcribe-v3.md --to=tech-spec --focus="audio processing pipeline"
+
+# Review with custom persona
+/spec-debate ./tech-spec.md --persona="database administrator with PostgreSQL expertise"
+
+# Full example
+/spec-debate --from=./prd.md --to=tech-spec --focus="scalability" --persona=site-reliability
+```
+
+## How It Works
+
+1. **Parse arguments** - Extract flags and options
+2. **Find/generate spec** - Load existing or generate from source
+3. **Select models** - Uses OpenRouter models by default (5 models)
+4. **Run debate loop** - Each model critiques, Claude synthesizes
+5. **Iterate** - Repeat until consensus reached
+6. **Output** - Final spec written to `spec-output.md`
diff --git a/.claude/skills/spec-debate/SKILL.md b/.claude/skills/spec-debate/SKILL.md
new file mode 100644 (file)
index 0000000..cafe8bb
--- /dev/null
@@ -0,0 +1,397 @@
+---
+name: spec-debate
+description: Interactive wrapper for adversarial-spec with UI for selecting spec files, focus modes, personas, and models. Use when you want to run adversarial spec debate with custom options.
+allowed-tools: Bash, Read, Write, Glob, AskUserQuestion
+---
+
+# Spec Debate - Interactive Adversarial Specification
+
+Launch adversarial spec debates with interactive selection of all options.
+
+## Execution Flow
+
+### Step 0: Import Windows Environment Variables (WSL)
+
+Before checking providers, import API keys from Windows environment:
+
+```bash
+# Get OPENROUTER_API_KEY from Windows and export to current shell
+export OPENROUTER_API_KEY="$(cmd.exe /c echo %OPENROUTER_API_KEY% 2>/dev/null | tr -d '\r')"
+```
+
+### Step 1: Check Available Providers
+
+First, check which API keys are configured:
+
+```bash
+python3 ~/.claude/plugins/marketplaces/adversarial-spec/skills/adversarial-spec/scripts/debate.py providers
+```
+
+Parse output to determine available models.
+
+### Step 2: Find Spec Files
+
+Search for potential spec files in the project:
+
+```bash
+find . -maxdepth 3 \( -name "*.md" -o -name "*spec*" -o -name "*prd*" -o -name "*requirements*" \) -type f 2>/dev/null | head -20
+```
+
+### Step 3: Interactive Selection
+
+Use AskUserQuestion to gather all options in sequence:
+
+#### 3.1 Document Source
+
+**If `--from` argument provided, skip to 3.1.1 (Generation mode).**
+
+Ask user:
+```
+question: "How do you want to provide the specification?"
+header: "Source"
+options:
+  - label: "Select existing file"
+    description: "Choose from found spec files in the project"
+  - label: "Generate from document"
+    description: "Create new spec based on existing PRD/spec (e.g., tech-spec from PRD)"
+  - label: "Enter file path manually"
+    description: "Specify custom path to spec file"
+  - label: "Describe concept"
+    description: "I'll generate initial spec from your description"
+```
+
+#### 3.1.1 Generation Mode (if "Generate from document" selected or --from provided)
+
+First, ask for source document (if not provided via --from):
+```
+question: "Which document should be the source?"
+header: "Source Doc"
+options: [dynamically built from found spec/PRD files]
+```
+
+Then ask for target type (if not provided via --to):
+```
+question: "What type of document do you want to generate?"
+header: "Target Type"
+options:
+  - label: "Tech Spec (Recommended)"
+    description: "Technical specification for developers - architecture, components, APIs"
+  - label: "API Spec"
+    description: "REST/GraphQL API specification with endpoints, schemas, examples"
+  - label: "DB Schema"
+    description: "Database schema with tables, relations, indexes, migrations"
+  - label: "Deployment Spec"
+    description: "Infrastructure, containers, CI/CD, monitoring setup"
+```
+
+#### 3.2 Document Type
+
+Ask user:
+```
+question: "What type of document are you creating?"
+header: "Doc Type"
+options:
+  - label: "PRD (Recommended)"
+    description: "Product Requirements Document - for stakeholders, PMs, designers"
+  - label: "Tech Spec"
+    description: "Technical Specification - for developers and architects"
+```
+
+#### 3.3 Critique Focus Mode (Optional)
+
+**If `--focus` argument provided, skip this step and use the provided focus.**
+
+Ask user:
+```
+question: "Do you want to focus the critique on a specific area?"
+header: "Focus"
+options:
+  - label: "No specific focus (Recommended)"
+    description: "General comprehensive critique"
+  - label: "Custom focus"
+    description: "Enter your own focus area (e.g., 'API versioning', 'data privacy')"
+  - label: "Security"
+    description: "Auth, validation, encryption, vulnerabilities"
+  - label: "Scalability"
+    description: "Horizontal scaling, sharding, caching"
+```
+
+If "Custom focus" selected, ask:
+```
+question: "Describe your focus area (what aspects should critiques concentrate on?)"
+header: "Custom Focus"
+options: (use Other input)
+```
+
+If user wants different predefined focus, offer second question with remaining options:
+- `performance` - Latency, throughput, query optimization
+- `ux` - User journeys, error handling, accessibility
+- `reliability` - Failure modes, circuit breakers, disaster recovery
+- `cost` - Infrastructure expenses, resource efficiency
+
+#### 3.4 Model Persona (Optional)
+
+**If `--persona` argument provided, skip this step and use the provided persona.**
+
+Ask user:
+```
+question: "Should models critique from a specific professional perspective?"
+header: "Persona"
+options:
+  - label: "No persona (Recommended)"
+    description: "General critique without role-playing"
+  - label: "Custom persona"
+    description: "Enter your own persona (e.g., 'retail domain expert', 'ML engineer')"
+  - label: "Security Engineer"
+    description: "Adversarial thinking, paranoid about edge cases"
+  - label: "On-call Engineer"
+    description: "Observability, error messages, 3am debugging"
+```
+
+If "Custom persona" selected, ask:
+```
+question: "Describe the persona (what professional perspective should models adopt?)"
+header: "Custom Persona"
+options: (use Other input)
+```
+
+If user wants different predefined persona, offer second question with:
+- `junior-developer` - Flags ambiguity and tribal knowledge
+- `qa-engineer` - Test scenarios, acceptance criteria
+- `site-reliability` - Deployment, monitoring, incident response
+- `product-manager` - User value, success metrics
+- `data-engineer` - Data models, ETL implications
+- `mobile-developer` - API design from mobile perspective
+- `accessibility-specialist` - WCAG, screen reader support
+- `legal-compliance` - GDPR, CCPA, regulatory
+
+#### 3.5 Model Selection
+
+**If OPENROUTER_API_KEY is set, use these models by default (skip asking user):**
+```
+openrouter/deepseek/deepseek-v3.2,openrouter/google/gemini-3-pro-preview,openrouter/x-ai/grok-4.1-fast,openrouter/z-ai/glm-4.7,openrouter/openai/gpt-5.2
+```
+
+Otherwise, based on available providers from Step 1, build options dynamically.
+
+Use multiSelect to allow choosing multiple models:
+```
+question: "Which models should participate in the debate?"
+header: "Models"
+multiSelect: true
+options: [dynamically built based on available API keys]
+```
+
+**Model mapping by provider:**
+- OpenAI: `gpt-4o`, `o1`
+- Anthropic: `claude-sonnet-4-20250514`, `claude-opus-4-20250514`
+- Google: `gemini/gemini-2.0-flash`
+- xAI: `xai/grok-3`
+- Mistral: `mistral/mistral-large`
+- Groq: `groq/llama-3.3-70b-versatile`
+- Deepseek: `deepseek/deepseek-chat`
+- OpenRouter (default): `openrouter/deepseek/deepseek-v3.2`, `openrouter/google/gemini-3-pro-preview`, `openrouter/x-ai/grok-4.1-fast`, `openrouter/z-ai/glm-4.7`, `openrouter/openai/gpt-5.2`
+- Codex CLI: `codex/gpt-5.2-codex`
+- Gemini CLI: `gemini-cli/gemini-3-pro-preview`
+
+#### 3.6 Additional Options
+
+Ask user:
+```
+question: "Any additional options?"
+header: "Options"
+multiSelect: true
+options:
+  - label: "Preserve Intent"
+    description: "Require justification for any content removal"
+  - label: "Telegram notifications"
+    description: "Get real-time updates via Telegram bot"
+  - label: "Save session"
+    description: "Enable session persistence for resume"
+```
+
+### Step 4: Generate Initial Spec (if Generation Mode)
+
+**If `--from` was specified or "Generate from document" was selected:**
+
+1. Read the source document
+2. Generate initial spec based on target type:
+
+```
+For tech-spec from PRD:
+- Extract functional requirements
+- Design system architecture
+- Define components and their responsibilities
+- Specify APIs and data flows
+- Document technical constraints and decisions
+
+For api-spec:
+- Extract endpoints from requirements
+- Define request/response schemas
+- Document authentication and authorization
+- Specify error codes and handling
+
+For db-schema:
+- Extract entities from requirements
+- Design normalized schema
+- Define indexes and constraints
+- Document migrations strategy
+
+For deployment-spec:
+- Define container architecture
+- Specify resource requirements
+- Document CI/CD pipeline
+- Define monitoring and alerting
+```
+
+3. Write generated spec to memory (for debate)
+
+### Step 5: Build and Execute Command
+
+Construct the debate command from selections:
+
+```bash
+python3 ~/.claude/plugins/marketplaces/adversarial-spec/skills/adversarial-spec/scripts/debate.py critique \
+  --models MODEL_LIST \
+  --doc-type TYPE \
+  [--focus FOCUS] \
+  [--persona PERSONA] \
+  [--preserve-intent] \
+  [--telegram] \
+  [--session SESSION_NAME] \
+  <<'SPEC_EOF'
+<spec content here - either from file or generated>
+SPEC_EOF
+```
+
+**Note**: For custom focus/persona, pass the exact string provided by user:
+```bash
+--focus "API backward compatibility and versioning"
+--persona "retail domain expert with 10 years experience"
+```
+
+### Step 6: Run Debate Loop
+
+Follow the adversarial-spec process:
+1. Send spec to selected models
+2. Collect critiques
+3. Provide your own critique as Claude
+4. Synthesize and revise
+5. Repeat until all models agree
+
+### Step 7: Output Results
+
+When consensus reached:
+1. Display final spec
+2. Write to `spec-output.md`
+3. Show summary with rounds count, contributions
+
+## Quick Reference
+
+**Focus Modes:**
+| Mode | Description |
+|------|-------------|
+| security | Auth, encryption, vulnerabilities |
+| scalability | Scaling, sharding, caching |
+| performance | Latency, throughput |
+| ux | User journeys, accessibility |
+| reliability | Failure modes, DR |
+| cost | Infrastructure costs |
+
+**Personas:**
+| Persona | Perspective |
+|---------|-------------|
+| security-engineer | Adversarial thinking |
+| oncall-engineer | 3am debugging |
+| junior-developer | Ambiguity detection |
+| qa-engineer | Test scenarios |
+| site-reliability | Deployment, monitoring |
+| product-manager | User value, metrics |
+| data-engineer | Data models, ETL |
+| mobile-developer | Mobile API design |
+| accessibility-specialist | WCAG compliance |
+| legal-compliance | GDPR, CCPA |
+
+## Arguments
+
+`$ARGUMENTS` can contain:
+- Path to spec file: `/spec-debate ./docs/my-spec.md`
+- Description: `/spec-debate "Build a rate limiter service"`
+- Custom focus: `/spec-debate --focus="API backward compatibility and versioning"`
+- Generate from source: `/spec-debate --from=./prd.md --to=tech-spec`
+- Combined: `/spec-debate --from=./prd.md --to=tech-spec --focus="database schema design"`
+- Empty: Interactive mode with all questions
+
+### Argument Parsing
+
+Parse `$ARGUMENTS` for these flags:
+- `--focus="<custom focus>"` - Custom critique focus (overrides interactive selection)
+- `--from=<path>` - Source document to base new spec on
+- `--to=<type>` - Target document type: `prd`, `tech-spec`, `api-spec`, `db-schema`, `deployment-spec`
+- `--persona="<custom persona>"` - Custom persona (overrides interactive selection)
+- `--models=<model1,model2>` - Comma-separated list of models (overrides default)
+
+### Generation Modes
+
+When `--from` is specified, the workflow changes:
+
+#### Mode: Generate Tech Spec from PRD
+
+```bash
+/spec-debate --from=./prd-v3.md --to=tech-spec
+```
+
+1. Read source document (PRD)
+2. Generate initial tech spec based on PRD requirements
+3. Run adversarial debate on generated spec
+4. Output: refined tech spec that implements PRD
+
+#### Mode: Generate API Spec from PRD/Tech Spec
+
+```bash
+/spec-debate --from=./tech-spec.md --to=api-spec
+```
+
+Generates OpenAPI-style API specification.
+
+#### Mode: Generate DB Schema from Tech Spec
+
+```bash
+/spec-debate --from=./tech-spec.md --to=db-schema
+```
+
+Generates database schema documentation with tables, relations, indexes.
+
+#### Mode: Generate Deployment Spec
+
+```bash
+/spec-debate --from=./tech-spec.md --to=deployment-spec
+```
+
+Generates deployment/infrastructure specification.
+
+### Target Types
+
+| Type | Description | Typical Source |
+|------|-------------|----------------|
+| `prd` | Product Requirements Document | Concept/description |
+| `tech-spec` | Technical Specification | PRD |
+| `api-spec` | API Specification (REST/GraphQL) | PRD or Tech Spec |
+| `db-schema` | Database Schema Documentation | Tech Spec |
+| `deployment-spec` | Infrastructure/Deployment Spec | Tech Spec |
+
+### Custom Focus Examples
+
+```bash
+# Security-focused review
+/spec-debate ./api-spec.md --focus="authentication, authorization, input validation"
+
+# Performance-focused
+/spec-debate ./tech-spec.md --focus="query optimization, caching strategy, N+1 problems"
+
+# Domain-specific
+/spec-debate ./prd.md --focus="retail analytics, POS integration, real-time reporting"
+
+# Generate with focus
+/spec-debate --from=./prd.md --to=tech-spec --focus="microservices boundaries and data ownership"
+```
diff --git a/.claude/skills/spec-debate/scripts/detect-models.sh b/.claude/skills/spec-debate/scripts/detect-models.sh
new file mode 100644 (file)
index 0000000..f170b89
--- /dev/null
@@ -0,0 +1,122 @@
+#!/bin/bash
+# Detect available models based on configured API keys
+# Output: JSON with available models grouped by provider
+
+set -e
+
+# Colors for terminal output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# Check if running in JSON mode
+JSON_MODE=false
+if [[ "$1" == "--json" ]]; then
+    JSON_MODE=true
+fi
+
+declare -A PROVIDERS
+declare -a AVAILABLE_MODELS
+
+# Check each provider
+check_provider() {
+    local name="$1"
+    local env_var="$2"
+    local models="$3"
+
+    if [[ -n "${!env_var}" ]]; then
+        PROVIDERS[$name]="configured"
+        IFS=',' read -ra MODEL_ARRAY <<< "$models"
+        for model in "${MODEL_ARRAY[@]}"; do
+            AVAILABLE_MODELS+=("$model")
+        done
+        return 0
+    else
+        PROVIDERS[$name]="not configured"
+        return 1
+    fi
+}
+
+# Check CLI tools
+check_cli() {
+    local name="$1"
+    local cmd="$2"
+    local models="$3"
+
+    if command -v "$cmd" &> /dev/null; then
+        PROVIDERS[$name]="installed"
+        IFS=',' read -ra MODEL_ARRAY <<< "$models"
+        for model in "${MODEL_ARRAY[@]}"; do
+            AVAILABLE_MODELS+=("$model")
+        done
+        return 0
+    else
+        PROVIDERS[$name]="not installed"
+        return 1
+    fi
+}
+
+# Check all providers
+check_provider "OpenAI" "OPENAI_API_KEY" "gpt-4o,o1"
+check_provider "Anthropic" "ANTHROPIC_API_KEY" "claude-sonnet-4-20250514,claude-opus-4-20250514"
+check_provider "Google" "GEMINI_API_KEY" "gemini/gemini-2.0-flash,gemini/gemini-pro"
+check_provider "xAI" "XAI_API_KEY" "xai/grok-3,xai/grok-beta"
+check_provider "Mistral" "MISTRAL_API_KEY" "mistral/mistral-large,mistral/codestral"
+check_provider "Groq" "GROQ_API_KEY" "groq/llama-3.3-70b-versatile"
+check_provider "OpenRouter" "OPENROUTER_API_KEY" "openrouter/openai/gpt-4o,openrouter/anthropic/claude-3.5-sonnet"
+check_provider "Deepseek" "DEEPSEEK_API_KEY" "deepseek/deepseek-chat"
+check_provider "Zhipu" "ZHIPUAI_API_KEY" "zhipu/glm-4,zhipu/glm-4-plus"
+check_cli "Codex CLI" "codex" "codex/gpt-5.2-codex,codex/gpt-5.1-codex-max"
+check_cli "Gemini CLI" "gemini" "gemini-cli/gemini-3-pro-preview,gemini-cli/gemini-3-flash-preview"
+
+if $JSON_MODE; then
+    # JSON output
+    echo "{"
+    echo "  \"providers\": {"
+    first=true
+    for provider in "${!PROVIDERS[@]}"; do
+        if $first; then
+            first=false
+        else
+            echo ","
+        fi
+        printf "    \"%s\": \"%s\"" "$provider" "${PROVIDERS[$provider]}"
+    done
+    echo ""
+    echo "  },"
+    echo "  \"available_models\": ["
+    first=true
+    for model in "${AVAILABLE_MODELS[@]}"; do
+        if $first; then
+            first=false
+        else
+            echo ","
+        fi
+        printf "    \"%s\"" "$model"
+    done
+    echo ""
+    echo "  ]"
+    echo "}"
+else
+    # Human-readable output
+    echo "=== Available Providers ==="
+    echo ""
+    for provider in "${!PROVIDERS[@]}"; do
+        status="${PROVIDERS[$provider]}"
+        if [[ "$status" == "configured" || "$status" == "installed" ]]; then
+            echo -e "${GREEN}✓${NC} $provider: $status"
+        else
+            echo -e "${RED}✗${NC} $provider: $status"
+        fi
+    done
+    echo ""
+    echo "=== Available Models ==="
+    if [[ ${#AVAILABLE_MODELS[@]} -eq 0 ]]; then
+        echo -e "${YELLOW}No models available. Configure at least one API key.${NC}"
+    else
+        for model in "${AVAILABLE_MODELS[@]}"; do
+            echo "  - $model"
+        done
+    fi
+fi
diff --git a/.claude/skills/spec-debate/scripts/run-debate.sh b/.claude/skills/spec-debate/scripts/run-debate.sh
new file mode 100644 (file)
index 0000000..c500527
--- /dev/null
@@ -0,0 +1,124 @@
+#!/bin/bash
+# Wrapper script for running adversarial-spec debates
+# Usage: run-debate.sh [options] < spec.md
+#
+# Options:
+#   --models MODEL1,MODEL2    Comma-separated list of models
+#   --doc-type prd|tech       Document type (default: tech)
+#   --focus FOCUS             Critique focus area
+#   --persona PERSONA         Model persona
+#   --preserve-intent         Require justification for removals
+#   --session NAME            Session name for persistence
+#   --telegram                Enable Telegram notifications
+#   --round N                 Round number (default: 1)
+#   --press                   Anti-laziness check
+
+set -e
+
+DEBATE_SCRIPT="$HOME/.claude/plugins/marketplaces/adversarial-spec/scripts/debate.py"
+
+# Check if debate script exists
+if [[ ! -f "$DEBATE_SCRIPT" ]]; then
+    echo "Error: adversarial-spec plugin not found at $DEBATE_SCRIPT"
+    echo "Install with: /plugin install adversarial-spec"
+    exit 1
+fi
+
+# Parse arguments
+MODELS=""
+DOC_TYPE="tech"
+FOCUS=""
+PERSONA=""
+PRESERVE_INTENT=""
+SESSION=""
+TELEGRAM=""
+ROUND="1"
+PRESS=""
+CONTEXT_FILES=()
+
+while [[ $# -gt 0 ]]; do
+    case $1 in
+        --models|-m)
+            MODELS="$2"
+            shift 2
+            ;;
+        --doc-type|-d)
+            DOC_TYPE="$2"
+            shift 2
+            ;;
+        --focus|-f)
+            FOCUS="$2"
+            shift 2
+            ;;
+        --persona)
+            PERSONA="$2"
+            shift 2
+            ;;
+        --preserve-intent)
+            PRESERVE_INTENT="--preserve-intent"
+            shift
+            ;;
+        --session|-s)
+            SESSION="$2"
+            shift 2
+            ;;
+        --telegram|-t)
+            TELEGRAM="--telegram"
+            shift
+            ;;
+        --round|-r)
+            ROUND="$2"
+            shift 2
+            ;;
+        --press|-p)
+            PRESS="--press"
+            shift
+            ;;
+        --context|-c)
+            CONTEXT_FILES+=("--context" "$2")
+            shift 2
+            ;;
+        *)
+            echo "Unknown option: $1"
+            exit 1
+            ;;
+    esac
+done
+
+# Validate required arguments
+if [[ -z "$MODELS" ]]; then
+    echo "Error: --models is required"
+    echo "Usage: run-debate.sh --models gpt-4o,gemini/gemini-2.0-flash < spec.md"
+    exit 1
+fi
+
+# Build command
+CMD="python3 $DEBATE_SCRIPT critique"
+CMD="$CMD --models $MODELS"
+CMD="$CMD --doc-type $DOC_TYPE"
+CMD="$CMD --round $ROUND"
+
+[[ -n "$FOCUS" ]] && CMD="$CMD --focus $FOCUS"
+[[ -n "$PERSONA" ]] && CMD="$CMD --persona \"$PERSONA\""
+[[ -n "$PRESERVE_INTENT" ]] && CMD="$CMD $PRESERVE_INTENT"
+[[ -n "$SESSION" ]] && CMD="$CMD --session $SESSION"
+[[ -n "$TELEGRAM" ]] && CMD="$CMD $TELEGRAM"
+[[ -n "$PRESS" ]] && CMD="$CMD $PRESS"
+
+# Add context files
+for ctx in "${CONTEXT_FILES[@]}"; do
+    CMD="$CMD $ctx"
+done
+
+# Show command being executed
+echo "=== Running Debate ==="
+echo "Models: $MODELS"
+echo "Type: $DOC_TYPE"
+[[ -n "$FOCUS" ]] && echo "Focus: $FOCUS"
+[[ -n "$PERSONA" ]] && echo "Persona: $PERSONA"
+[[ -n "$SESSION" ]] && echo "Session: $SESSION"
+echo "======================"
+echo ""
+
+# Execute
+eval "$CMD"
index 74ddd0c7bfbd6e6fcba146dffeb7c9773cdef252..f24b13d2233fee00d712a1d83fc9869be9125b29 100644 (file)
@@ -45,3 +45,11 @@ hive-mind-prompt-*.txt
 
 # Auto Claude data directory
 .auto-claude/
+
+# Auto Claude generated files
+.auto-claude-security.json
+.auto-claude-status
+.claude_settings.json
+.worktrees/
+.security-key
+logs/security/
index 83f3828f7bcf1f1696a81c147a183837724b4d04..5ddba1b5080a805647ef142e01b37b206749c281 100644 (file)
@@ -33,4 +33,15 @@ return [
         'email_recipients' => array_filter(explode(',', getenv('ORDER_CONTROL_EMAIL_RECIPIENTS') ?: 'vladimir.fomichev@erp-flowers.ru,ekaterina.geldak@bazacvetov24.ru,irina.rogacheva@bazacvetov24.ru,alena.chelyshkina@bazacvetov24.ru')),
         'email_subject' => '[Контроль MP] Отчёт о расхождениях статусов заказов',
     ],
+
+    // Система обязательных напоминаний о сменах
+    // Временные окна для показа напоминаний (в формате HH:MM)
+    'SHIFT_REMINDER_TIME_WINDOWS' => ['07:50', '08:10', '19:50', '20:10'],
+
+    // Допустимые ключи напоминаний (whitelist для безопасности)
+    'SHIFT_REMINDER_ALLOWED_KEYS' => ['day_shift', 'night_shift'],
+
+    // ID групп администраторов, которым показываются напоминания
+    // Пустой массив = все пользователи с записями в timetable
+    'SHIFT_REMINDER_ADMIN_GROUP_IDS' => [],
 ];
index b618ca1d867c06612fb5531f7be85dbb9a14d8fd..dfa9b18a5344d090f8a8f668de6c01ce212b83c4 100644 (file)
@@ -115,6 +115,9 @@ $config = [
                 'wiki/<parent_cat_slug>/<article_slug>' => 'wiki/view',
             ],
         ],
+        'shiftReminder' => [
+            'class' => 'yii_app\services\ShiftReminderService',
+        ],
     ],
     'as beforeRequest' => [
         'class' => 'yii\filters\AccessControl',
diff --git a/erp24/controllers/ShiftReminderController.php b/erp24/controllers/ShiftReminderController.php
new file mode 100644 (file)
index 0000000..d85a9c4
--- /dev/null
@@ -0,0 +1,298 @@
+<?php
+
+namespace app\controllers;
+
+use Yii;
+use yii\web\Controller;
+use yii\filters\VerbFilter;
+use yii\filters\AccessControl;
+use yii\web\Response;
+use yii_app\services\ShiftReminderService;
+
+/**
+ * ShiftReminderController
+ *
+ * Контроллер для обработки запросов системы напоминаний о сменах.
+ * Предоставляет API для проверки необходимости показа напоминаний и подтверждения их просмотра.
+ *
+ * Endpoints:
+ * - POST /shift-reminder/check - Проверить, нужно ли показать напоминание
+ * - POST /shift-reminder/confirm - Подтвердить просмотр напоминания
+ */
+class ShiftReminderController extends Controller
+{
+    /**
+     * Хранилище для отслеживания rate limiting
+     * @var array
+     */
+    private static $rateLimitStorage = [];
+
+    /**
+     * {@inheritdoc}
+     */
+    public function behaviors()
+    {
+        return [
+            'verbs' => [
+                'class' => VerbFilter::class,
+                'actions' => [
+                    'check' => ['POST'],
+                    'confirm' => ['POST'],
+                ],
+            ],
+            'access' => [
+                'class' => AccessControl::class,
+                'rules' => [
+                    [
+                        'allow' => true,
+                        'roles' => ['@'], // Только авторизованные пользователи
+                    ],
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function beforeAction($action)
+    {
+        if (!parent::beforeAction($action)) {
+            return false;
+        }
+
+        // Rate limiting
+        $limits = [
+            'check' => 10,   // 10 запросов в минуту
+            'confirm' => 5,  // 5 запросов в минуту
+        ];
+
+        $actionId = $action->id;
+        if (isset($limits[$actionId])) {
+            if (!$this->checkRateLimit($actionId, $limits[$actionId])) {
+                Yii::$app->response->statusCode = 429;
+                Yii::$app->response->format = Response::FORMAT_JSON;
+                Yii::$app->response->data = [
+                    'success' => false,
+                    'error' => 'Rate limit exceeded. Please try again later.',
+                ];
+                return false;
+            }
+        }
+
+        // Устанавливаем формат ответа JSON для всех действий
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        return true;
+    }
+
+    /**
+     * Проверить rate limit для действия
+     *
+     * @param string $action Название действия
+     * @param int $limit Максимальное количество запросов в минуту
+     * @return bool true если лимит не превышен, false в противном случае
+     */
+    private function checkRateLimit($action, $limit)
+    {
+        $userId = Yii::$app->user->id;
+        $key = "shift_reminder_{$action}_{$userId}";
+        $now = time();
+
+        // Инициализируем хранилище для пользователя, если его нет
+        if (!isset(self::$rateLimitStorage[$key])) {
+            self::$rateLimitStorage[$key] = [];
+        }
+
+        // Удаляем запросы старше 60 секунд
+        self::$rateLimitStorage[$key] = array_filter(
+            self::$rateLimitStorage[$key],
+            function ($timestamp) use ($now) {
+                return ($now - $timestamp) < 60;
+            }
+        );
+
+        // Проверяем, не превышен ли лимит
+        if (count(self::$rateLimitStorage[$key]) >= $limit) {
+            return false;
+        }
+
+        // Добавляем текущий запрос
+        self::$rateLimitStorage[$key][] = $now;
+
+        return true;
+    }
+
+    /**
+     * Проверить, нужно ли показать напоминание пользователю
+     *
+     * POST /shift-reminder/check
+     *
+     * Запрос:
+     * {
+     *   "reminder_key": "day_shift"  // опционально, по умолчанию "day_shift"
+     * }
+     *
+     * Ответ (успех):
+     * {
+     *   "success": true,
+     *   "show_reminder": true,
+     *   "reminder_key": "day_shift",
+     *   "reference_date": "2024-01-27"
+     * }
+     *
+     * Ответ (не нужно показывать):
+     * {
+     *   "success": true,
+     *   "show_reminder": false
+     * }
+     *
+     * @return array
+     */
+    public function actionCheck()
+    {
+        try {
+            $request = Yii::$app->request;
+            $userId = Yii::$app->user->id;
+
+            // Получаем reminder_key из запроса или используем дефолтное значение
+            $reminderKey = $request->post('reminder_key', 'day_shift');
+
+            // Валидация reminder_key
+            if (!ShiftReminderService::isValidReminderKey($reminderKey)) {
+                return [
+                    'success' => false,
+                    'error' => 'Invalid reminder_key. Allowed values: ' . implode(', ', ShiftReminderService::getAllowedReminderKeys()),
+                ];
+            }
+
+            // Определяем автоматически правильный reminder_key на основе текущего периода
+            // Периоды смен:
+            // 07:50 - 08:09: закрытие ночной смены (night_shift)
+            // 08:10 - 19:49: дневная смена активна (day_shift)
+            // 19:50 - 20:09: закрытие дневной смены (day_shift)
+            // 20:10 - 07:49: ночная смена активна (night_shift)
+            $currentTime = date('H:i');
+            if ($currentTime >= '07:50' && $currentTime < '08:10') {
+                // Закрытие ночной смены
+                $reminderKey = 'night_shift';
+            } elseif ($currentTime >= '08:10' && $currentTime < '19:50') {
+                // Дневная смена активна
+                $reminderKey = 'day_shift';
+            } elseif ($currentTime >= '19:50' && $currentTime < '20:10') {
+                // Закрытие дневной смены
+                $reminderKey = 'day_shift';
+            } else {
+                // Ночная смена активна (20:10 - 07:49)
+                $reminderKey = 'night_shift';
+            }
+
+            // Проверяем, нужно ли показать напоминание
+            $shouldShow = ShiftReminderService::shouldShowReminder($userId, $reminderKey);
+
+            if ($shouldShow) {
+                return [
+                    'success' => true,
+                    'show_reminder' => true,
+                    'reminder_key' => $reminderKey,
+                    'reference_date' => ShiftReminderService::getReferenceDate(),
+                ];
+            }
+
+            return [
+                'success' => true,
+                'show_reminder' => false,
+            ];
+        } catch (\Exception $e) {
+            Yii::error('Error in ShiftReminderController::actionCheck: ' . $e->getMessage(), 'shift-reminder');
+
+            return [
+                'success' => false,
+                'error' => 'An error occurred while checking reminder status.',
+            ];
+        }
+    }
+
+    /**
+     * Подтвердить просмотр напоминания
+     *
+     * POST /shift-reminder/confirm
+     *
+     * Запрос:
+     * {
+     *   "reminder_key": "day_shift"
+                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         * }
+     *
+     * Ответ (успех):
+     * {
+     *   "success": true,
+     *   "message": "Reminder confirmed successfully.",
+     *   "reference_date": "2024-01-27"
+     * }
+     *
+     * Ответ (ошибка):
+     * {
+     *   "success": false,
+     *   "error": "Error message"
+     * }
+     *
+     * @return array
+     */
+    public function actionConfirm()
+    {
+        try {
+            $request = Yii::$app->request;
+            $userId = Yii::$app->user->id;
+
+            // Получаем reminder_key из запроса
+            $reminderKey = $request->post('reminder_key');
+
+            // Валидация входных данных
+            if (empty($reminderKey)) {
+                return [
+                    'success' => false,
+                    'error' => 'reminder_key is required.',
+                ];
+            }
+
+            // Валидация reminder_key
+            if (!ShiftReminderService::isValidReminderKey($reminderKey)) {
+                return [
+                    'success' => false,
+                    'error' => 'Invalid reminder_key. Allowed values: ' . implode(', ', ShiftReminderService::getAllowedReminderKeys()),
+                ];
+            }
+
+            // Подтверждаем напоминание
+            $success = ShiftReminderService::confirmReminder($userId, $reminderKey);
+
+            if ($success) {
+                $referenceDate = ShiftReminderService::getReferenceDate();
+
+                // Логируем подтверждение для аудита
+                Yii::info(
+                    "User {$userId} confirmed reminder '{$reminderKey}' for reference date {$referenceDate}",
+                    'shift-reminder'
+                );
+
+                return [
+                    'success' => true,
+                    'message' => 'Reminder confirmed successfully.',
+                    'reference_date' => $referenceDate,
+                ];
+            }
+
+            return [
+                'success' => false,
+                'error' => 'Failed to confirm reminder. Please try again.',
+            ];
+        } catch (\Exception $e) {
+            Yii::error('Error in ShiftReminderController::actionConfirm: ' . $e->getMessage(), 'shift-reminder');
+
+            return [
+                'success' => false,
+                'error' => 'An error occurred while confirming reminder.',
+            ];
+        }
+    }
+}
diff --git a/erp24/migrations/m260127_105454_create_shift_reminder_shown_table.php b/erp24/migrations/m260127_105454_create_shift_reminder_shown_table.php
new file mode 100644 (file)
index 0000000..d209420
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+
+use yii\db\Migration;
+
+/**
+ * Class m260127_105454_create_shift_reminder_shown_table
+ */
+class m260127_105454_create_shift_reminder_shown_table extends Migration
+{
+    const TABLE_NAME = 'erp24.shift_reminder_shown';
+    /**
+     * {@inheritdoc}
+     */
+    public function safeUp()
+    {
+        $this->createTable(self::TABLE_NAME, [
+            'id' => $this->primaryKey(),
+            'user_id' => $this->integer()->notNull()->comment('ID пользователя, которому было показано напоминание'),
+            'reminder_key' => $this->string(50)->notNull()->comment('Ключ напоминания (идентификатор типа напоминания)'),
+            'reference_date' => $this->date()->notNull()->comment('Календарная дата, к которой относится напоминание (для корректной обработки переходов через полночь)'),
+            'confirmed_at' => $this->timestamp()->null()->comment('Дата и время подтверждения напоминания пользователем'),
+            'created_at' => $this->timestamp()->notNull()->defaultExpression('CURRENT_TIMESTAMP')->comment('Дата создания записи'),
+        ]);
+
+        // Создаем уникальный составной индекс для предотвращения дублирования напоминаний
+        $this->createIndex(
+            'idx-shift_reminder_shown-user_reminder_date',
+            self::TABLE_NAME,
+            ['user_id', 'reminder_key', 'reference_date'],
+            true
+        );
+
+        // Создаем индекс для быстрого поиска по дате подтверждения
+        $this->createIndex(
+            'idx-shift_reminder_shown-confirmed_at',
+            self::TABLE_NAME,
+            'confirmed_at'
+        );
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function safeDown()
+    {
+        $this->dropTable(self::TABLE_NAME);
+    }
+}
diff --git a/erp24/models/ShiftReminderShown.php b/erp24/models/ShiftReminderShown.php
new file mode 100644 (file)
index 0000000..f22fd7f
--- /dev/null
@@ -0,0 +1,84 @@
+<?php
+
+namespace app\models;
+
+use Yii;
+use yii\db\ActiveRecord;
+
+/**
+ * ShiftReminderShown ActiveRecord model
+ *
+ * Модель для отслеживания показанных и подтвержденных напоминаний о сменах.
+ * Использует reference_date для корректной обработки переходов через полночь.
+ *
+ * @property int $id
+ * @property int $user_id ID пользователя, которому было показано напоминание
+ * @property string $reminder_key Ключ напоминания (идентификатор типа напоминания)
+ * @property string $reference_date Календарная дата, к которой относится напоминание
+ * @property string|null $confirmed_at Дата и время подтверждения напоминания пользователем
+ * @property string $created_at Дата создания записи
+ *
+ * @package app\models
+ */
+class ShiftReminderShown extends ActiveRecord
+{
+    /**
+     * @inheritdoc
+     */
+    public static function tableName()
+    {
+        return 'shift_reminder_shown';
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function rules()
+    {
+        return [
+            [['user_id', 'reminder_key', 'reference_date'], 'required'],
+            [['user_id'], 'integer'],
+            [['reference_date', 'confirmed_at', 'created_at'], 'safe'],
+            [['reminder_key'], 'string', 'max' => 50],
+            [['reference_date'], 'date', 'format' => 'php:Y-m-d'],
+        ];
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function attributeLabels()
+    {
+        return [
+            'id' => 'ID',
+            'user_id' => 'User ID',
+            'reminder_key' => 'Reminder Key',
+            'reference_date' => 'Reference Date',
+            'confirmed_at' => 'Confirmed At',
+            'created_at' => 'Created At',
+        ];
+    }
+
+    /**
+     * Проверяет, было ли напоминание подтверждено пользователем
+     *
+     * @return bool true если напоминание подтверждено, false в противном случае
+     */
+    public function isConfirmed()
+    {
+        return $this->confirmed_at !== null;
+    }
+
+    /**
+     * Отмечает напоминание как подтвержденное и сохраняет запись
+     *
+     * Устанавливает текущее время в поле confirmed_at и сохраняет модель.
+     *
+     * @return bool true если сохранение прошло успешно, false в противном случае
+     */
+    public function markAsConfirmed()
+    {
+        $this->confirmed_at = date('Y-m-d H:i:s');
+        return $this->save(false);
+    }
+}
diff --git a/erp24/services/ShiftReminderService.php b/erp24/services/ShiftReminderService.php
new file mode 100644 (file)
index 0000000..c0a3a10
--- /dev/null
@@ -0,0 +1,311 @@
+<?php
+
+namespace yii_app\services;
+
+use Yii;
+use app\models\ShiftReminderShown;
+use yii_app\records\Admin;
+use yii_app\records\Timetable;
+use yii_app\records\TimetableFactModel;
+
+/**
+ * ShiftReminderService
+ *
+ * Сервис для управления напоминаниями о сменах с корректной обработкой перехода через полночь.
+ * Критическая особенность: время 00:00-06:00 относится к предыдущему календарному дню,
+ * чтобы подтверждение в 01:00 не блокировало показ напоминания в 07:50 того же календарного дня.
+ */
+class ShiftReminderService
+{
+    /**
+     * Получить reference_date для текущего времени с учетом логики перехода дня
+     *
+     * Логика day rollover:
+     * - Время 00:00-05:59 → reference_date = предыдущий календарный день
+     * - Время 06:00-23:59 → reference_date = текущий календарный день
+     *
+     * Это гарантирует, что подтверждение напоминания в 01:00 (которое относится к предыдущему дню)
+     * не блокирует показ напоминания в 07:50 (которое относится к текущему дню).
+     *
+     * @param string|null $currentTime Текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время.
+     * @return string Дата в формате 'Y-m-d'
+     */
+    public static function getReferenceDate($currentTime = null)
+    {
+        if ($currentTime === null) {
+            $currentTime = date('Y-m-d H:i:s');
+        }
+
+        $dateTime = new \DateTime($currentTime);
+        $hour = (int)$dateTime->format('H');
+
+        // Если время между 00:00 и 05:59, используем предыдущий день
+        if ($hour >= 0 && $hour < 6) {
+            $dateTime->modify('-1 day');
+        }
+
+        return $dateTime->format('Y-m-d');
+    }
+
+    /**
+     * Проверить, находится ли текущее время в одном из временных окон для показа напоминаний
+     *
+     * @param string|null $currentTime Текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время.
+     * @param array|null $windows Массив временных окон в формате ['HH:MM', ...]. Если null, используются окна из конфигурации.
+     * @return bool true если текущее время находится в одном из окон, false в противном случае
+     */
+    public static function isInTimeWindow($currentTime = null, $windows = null)
+    {
+        if ($currentTime === null) {
+            $currentTime = date('Y-m-d H:i:s');
+        }
+
+        if ($windows === null) {
+            $windows = Yii::$app->params['SHIFT_REMINDER_TIME_WINDOWS'] ?? ['07:50', '08:10', '19:50', '20:10'];
+        }
+
+        $dateTime = new \DateTime($currentTime);
+        $currentTimeStr = $dateTime->format('H:i');
+
+        foreach ($windows as $window) {
+            // Проверяем точное совпадение времени (минута в минуту)
+            if ($currentTimeStr === $window) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Определить, нужно ли показать напоминание пользователю
+     *
+     * Проверяет следующие условия:
+     * 1. Действие со сменой ещё не выполнено в timetable_fact
+     * 2. Напоминание не было подтверждено для текущего reminder_key и reference_date
+     * 3. Пользователь принадлежит к группе администраторов (проверка через timetable)
+     *
+     * Напоминание показывается в любое время периода, пока не подтверждено.
+     * Контроллер определяет правильный reminder_key на основе текущего времени.
+     *
+     * @param int $userId ID пользователя
+     * @param string $reminderKey Ключ напоминания (day_shift или night_shift)
+     * @param string|null $currentTime Текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время.
+     * @return bool true если нужно показать напоминание, false в противном случае
+     */
+    public static function shouldShowReminder($userId, $reminderKey, $currentTime = null)
+    {
+        // Проверка 1: Проверяем статус смены в timetable_fact
+        // Если действие уже выполнено (смена открыта/закрыта) - не показываем напоминание
+        if (!self::isShiftActionNeeded($userId, $currentTime)) {
+            return false;
+        }
+
+        // Проверка 2: Проверяем, не было ли уже подтверждено напоминание для этого reference_date
+        $referenceDate = self::getReferenceDate($currentTime);
+
+        $existingReminder = ShiftReminderShown::find()
+            ->where([
+                'user_id' => $userId,
+                'reminder_key' => $reminderKey,
+                'reference_date' => $referenceDate,
+            ])
+            ->andWhere(['IS NOT', 'confirmed_at', null])
+            ->one();
+
+        if ($existingReminder !== null) {
+            return false;
+        }
+
+        // Проверка 3: Пользователь должен быть администратором
+        if (!self::isUserAdministrator($userId, $currentTime)) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Проверить, нужно ли выполнить действие со сменой (открытие/закрытие)
+     *
+     * Логика:
+     * - Период закрытия (07:50-08:09, 19:50-20:09): проверяем, есть ли открытая смена для закрытия
+     * - Период открытия (08:10-19:49, 20:10-07:49): проверяем, не открыта ли уже смена
+     *
+     * @param int $userId ID пользователя
+     * @param string|null $currentTime Текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время.
+     * @return bool true если действие нужно выполнить, false если уже выполнено
+     */
+    public static function isShiftActionNeeded($userId, $currentTime = null)
+    {
+        if ($currentTime === null) {
+            $currentTime = date('Y-m-d H:i:s');
+        }
+
+        $dateTime = new \DateTime($currentTime);
+        $currentTimeStr = $dateTime->format('H:i');
+        $referenceDate = self::getReferenceDate($currentTime);
+
+        // Определяем, это период закрытия или открытия
+        // Периоды закрытия: 07:50-08:09 (закрытие ночной), 19:50-20:09 (закрытие дневной)
+        $isClosingPeriod = ($currentTimeStr >= '07:50' && $currentTimeStr < '08:10')
+                        || ($currentTimeStr >= '19:50' && $currentTimeStr < '20:10');
+
+        // Получаем последнюю открытую смену пользователя
+        $openShift = TimetableFactModel::getLast($userId, $referenceDate, true);
+
+        if ($isClosingPeriod) {
+            // Период закрытия: нужно напомнить, если есть открытая смена (is_opening=true, is_close=false)
+            if ($openShift === null) {
+                // Нет открытой смены - нечего закрывать
+                return false;
+            }
+            if ($openShift->is_close) {
+                // Смена уже закрыта
+                return false;
+            }
+            // Есть открытая смена, которую нужно закрыть
+            return true;
+        } else {
+            // Период открытия: нужно напомнить, если смена ещё не открыта
+            if ($openShift !== null && !$openShift->is_close) {
+                // Смена уже открыта и не закрыта - не нужно напоминание об открытии
+                return false;
+            }
+            // Смена не открыта - нужно напомнить об открытии
+            return true;
+        }
+    }
+
+    /**
+     * Проверить, является ли пользователь администратором на текущую дату
+     *
+     * Интеграция с системой timetable: проверяем, есть ли у пользователя
+     * запланированная рабочая смена на reference_date.
+     *
+     * @param int $userId ID пользователя
+     * @param string|null $currentTime Текущее время. Если null, используется текущее время.
+     * @return bool true если пользователь является администратором, false в противном случае
+     */
+    public static function isUserAdministrator($userId, $currentTime = null)
+    {
+        // Получаем администратора
+        $admin = Admin::findOne($userId);
+        if ($admin === null) {
+            return false;
+        }
+
+        // Проверяем, принадлежит ли пользователь к административной группе
+        // Группы администраторов обычно имеют определенные ID
+        // Получаем их из конфигурации или используем дефолтные значения
+        $adminGroupIds = Yii::$app->params['SHIFT_REMINDER_ADMIN_GROUP_IDS'] ?? [];
+
+        if (!empty($adminGroupIds) && !in_array($admin->group_id, $adminGroupIds)) {
+            return false;
+        }
+
+        // Дополнительная проверка: есть ли у пользователя смена в расписании на reference_date
+        $referenceDate = self::getReferenceDate($currentTime);
+
+        $hasTimetable = Timetable::find()
+            ->where([
+                'admin_id' => $userId,
+                'date' => $referenceDate,
+            ])
+            ->andWhere([
+                'slot_type_id' => [
+                    1, // Timetable::TIMESLOT_WORK - работа
+                    5, // Timetable::TIMESLOT_INTERNSHIP - стажировка
+                    8  // Другой тип административной работы
+                ],
+            ])
+            ->andWhere(['tabel' => 0])
+            ->exists();
+
+        return $hasTimetable;
+    }
+
+    /**
+     * Создать или получить запись о показанном напоминании
+     *
+     * @param int $userId ID пользователя
+     * @param string $reminderKey Ключ напоминания
+     * @param string|null $currentTime Текущее время. Если null, используется текущее время.
+     * @return ShiftReminderShown|null Модель напоминания или null в случае ошибки
+     */
+    public static function createReminderRecord($userId, $reminderKey, $currentTime = null)
+    {
+        $referenceDate = self::getReferenceDate($currentTime);
+
+        // Проверяем, существует ли уже запись
+        $reminder = ShiftReminderShown::find()
+            ->where([
+                'user_id' => $userId,
+                'reminder_key' => $reminderKey,
+                'reference_date' => $referenceDate,
+            ])
+            ->one();
+
+        if ($reminder !== null) {
+            return $reminder;
+        }
+
+        // Создаем новую запись
+        $reminder = new ShiftReminderShown();
+        $reminder->user_id = $userId;
+        $reminder->reminder_key = $reminderKey;
+        $reminder->reference_date = $referenceDate;
+
+        if (!$reminder->save()) {
+            Yii::error('Failed to create shift reminder record: ' . json_encode($reminder->errors), 'shift-reminder');
+            return null;
+        }
+
+        return $reminder;
+    }
+
+    /**
+     * Подтвердить напоминание
+     *
+     * @param int $userId ID пользователя
+     * @param string $reminderKey Ключ напоминания
+     * @param string|null $currentTime Текущее время. Если null, используется текущее время.
+     * @return bool true если подтверждение прошло успешно, false в противном случае
+     */
+    public static function confirmReminder($userId, $reminderKey, $currentTime = null)
+    {
+        $reminder = self::createReminderRecord($userId, $reminderKey, $currentTime);
+
+        if ($reminder === null) {
+            return false;
+        }
+
+        if ($reminder->isConfirmed()) {
+            // Уже подтверждено
+            return true;
+        }
+
+        return $reminder->markAsConfirmed();
+    }
+
+    /**
+     * Получить список допустимых ключей напоминаний (whitelist)
+     *
+     * @return array Массив допустимых reminder_key
+     */
+    public static function getAllowedReminderKeys()
+    {
+        return Yii::$app->params['SHIFT_REMINDER_ALLOWED_KEYS'] ?? ['day_shift', 'night_shift'];
+    }
+
+    /**
+     * Проверить, является ли ключ напоминания допустимым
+     *
+     * @param string $reminderKey Ключ напоминания для проверки
+     * @return bool true если ключ допустим, false в противном случае
+     */
+    public static function isValidReminderKey($reminderKey)
+    {
+        return in_array($reminderKey, self::getAllowedReminderKeys(), true);
+    }
+}
index 9131424f0f39d442a4e8af727c218b69772b5413..8da450fa82ef572bb04b591c0c8e37d6eaf8c739 100755 (executable)
@@ -10,6 +10,9 @@ app\assets\CachemenutopAsset::register($this);
 app\assets\JQueryPluginsAsset::register($this); /** @TODO удалить после переписывания основного функционала */
 
 use yii\widgets\Breadcrumbs;
+
+// Register Shift Reminder JavaScript
+$this->registerJsFile('/js/shift-reminder.js', ['position' => \yii\web\View::POS_END]);
 ?>
 <?php $this->beginPage() ?>
 <!DOCTYPE html>
@@ -84,6 +87,9 @@ use yii\widgets\Breadcrumbs;
             </div>
         </div>
 
+        <!-- Shift Reminder Modal -->
+        <?= $this->render('//shift-reminder/_modal') ?>
+
     <?php $this->endBody(); ?>
     </body>
 </html>
diff --git a/erp24/views/shift-reminder/_modal.php b/erp24/views/shift-reminder/_modal.php
new file mode 100644 (file)
index 0000000..fdff20e
--- /dev/null
@@ -0,0 +1,204 @@
+<?php
+/**
+ * Shift Reminder Modal Template
+ *
+ * Fullscreen modal overlay that displays shift reminders to administrators.
+ * This modal blocks all user interaction until confirmed.
+ *
+ * Features:
+ * - Fullscreen overlay (z-index: 99999)
+ * - No close button (user must confirm)
+ * - Dark mode support
+ * - CSRF token included for confirmation
+ * - Accessible via keyboard (Enter key to confirm)
+ *
+ * @var \yii\web\View $this
+ * @var string $message Optional message to display (default provided)
+ * @var string $reminderKey Reminder key (day_shift or night_shift)
+ */
+
+use yii\helpers\Html;
+
+$message = $message ?? 'Пожалуйста, подтвердите, что вы ознакомились с напоминанием о смене.';
+$reminderKey = $reminderKey ?? '';
+?>
+
+<!-- Shift Reminder Fullscreen Modal -->
+<div id="shift-reminder-overlay" class="shift-reminder-overlay" style="display: none;">
+    <div class="shift-reminder-content">
+        <!-- Warning Icon -->
+        <div class="shift-reminder-icon">
+            <svg width="80" height="80" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+                <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" fill="currentColor"/>
+            </svg>
+        </div>
+
+        <!-- Title -->
+        <h2 class="shift-reminder-title">Напоминание о смене</h2>
+
+        <!-- Message -->
+        <p class="shift-reminder-message" id="shift-reminder-message-text">
+            <?= Html::encode($message) ?>
+        </p>
+
+        <!-- CSRF Token (hidden) -->
+        <?= Html::hiddenInput(
+            Yii::$app->request->csrfParam,
+            Yii::$app->request->csrfToken,
+            ['id' => 'shift-reminder-csrf-token']
+        ) ?>
+
+        <!-- Hidden field for reminder key -->
+        <?= Html::hiddenInput(
+            'reminder_key',
+            $reminderKey,
+            ['id' => 'shift-reminder-key']
+        ) ?>
+
+        <!-- Confirm Button -->
+        <button
+            id="shift-reminder-confirm-btn"
+            class="btn btn-primary btn-lg"
+            type="button"
+            aria-label="Подтвердить напоминание">
+            Подтвердить
+        </button>
+    </div>
+</div>
+
+<!-- Embedded Styles for Fullscreen Modal -->
+<style>
+    /* Fullscreen overlay */
+    .shift-reminder-overlay {
+        position: fixed;
+        top: 0;
+        left: 0;
+        right: 0;
+        bottom: 0;
+        background: rgba(0, 0, 0, 0.9);
+        z-index: 99999;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        animation: shift-reminder-fade-in 0.3s ease-in;
+    }
+
+    /* Modal content container */
+    .shift-reminder-content {
+        background: #fff;
+        padding: 40px;
+        border-radius: 10px;
+        text-align: center;
+        max-width: 500px;
+        box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
+        position: relative;
+    }
+
+    /* Dark mode support */
+    .dark-mode .shift-reminder-content {
+        background: #2d3035;
+        color: #fff;
+    }
+
+    /* Warning icon */
+    .shift-reminder-icon {
+        color: #f39c12;
+        margin-bottom: 20px;
+    }
+
+    .dark-mode .shift-reminder-icon {
+        color: #f39c12;
+    }
+
+    /* Title styling */
+    .shift-reminder-title {
+        font-size: 24px;
+        font-weight: bold;
+        margin-bottom: 20px;
+        color: #333;
+    }
+
+    .dark-mode .shift-reminder-title {
+        color: #fff;
+    }
+
+    /* Message text */
+    .shift-reminder-message {
+        font-size: 16px;
+        margin-bottom: 30px;
+        line-height: 1.5;
+        color: #666;
+    }
+
+    .dark-mode .shift-reminder-message {
+        color: #ccc;
+    }
+
+    /* Confirm button */
+    #shift-reminder-confirm-btn {
+        font-size: 18px;
+        padding: 12px 40px;
+        min-width: 200px;
+        cursor: pointer;
+        border: none;
+        transition: all 0.2s ease;
+    }
+
+    #shift-reminder-confirm-btn:hover {
+        transform: scale(1.05);
+    }
+
+    #shift-reminder-confirm-btn:active {
+        transform: scale(0.98);
+    }
+
+    #shift-reminder-confirm-btn:disabled {
+        opacity: 0.6;
+        cursor: not-allowed;
+    }
+
+    /* Fade-in animation */
+    @keyframes shift-reminder-fade-in {
+        from {
+            opacity: 0;
+        }
+        to {
+            opacity: 1;
+        }
+    }
+
+    /* Responsive design */
+    @media (max-width: 600px) {
+        .shift-reminder-content {
+            padding: 30px 20px;
+            max-width: 90%;
+            margin: 0 15px;
+        }
+
+        .shift-reminder-title {
+            font-size: 20px;
+        }
+
+        .shift-reminder-message {
+            font-size: 14px;
+        }
+
+        #shift-reminder-confirm-btn {
+            font-size: 16px;
+            padding: 10px 30px;
+            min-width: 150px;
+        }
+
+        .shift-reminder-icon svg {
+            width: 60px;
+            height: 60px;
+        }
+    }
+
+    /* Print: hide the reminder modal */
+    @media print {
+        .shift-reminder-overlay {
+            display: none !important;
+        }
+    }
+</style>
diff --git a/erp24/web/js/shift-reminder.js b/erp24/web/js/shift-reminder.js
new file mode 100644 (file)
index 0000000..6fd5449
--- /dev/null
@@ -0,0 +1,490 @@
+/**
+ * Shift Reminder System
+ *
+ * Polls backend every 30 seconds to check if reminder should be shown.
+ * Displays fullscreen modal with audio alert when reminder is needed.
+ * Implements day rollover logic and multi-tab coordination.
+ */
+
+(function() {
+    'use strict';
+
+    // CSRF token extraction
+    const csrfParam = $('meta[name=csrf-param]').attr('content');
+    const csrfToken = $('meta[name=csrf-token]').attr('content');
+
+    // Configuration
+    const CONFIG = {
+        pollInterval: 30000, // 30 seconds
+        maxPollInterval: 240000, // 4 minutes (for exponential backoff)
+        retryIntervals: [30000, 60000, 120000, 240000], // Exponential backoff
+        localStorageKey: 'shift_reminder_shown',
+        audioFrequency: 800, // Hz
+        audioDuration: 500 // ms
+    };
+
+    // State management
+    let state = {
+        pollTimer: null,
+        currentInterval: CONFIG.pollInterval,
+        retryCount: 0,
+        isModalVisible: false,
+        audioContext: null,
+        bcChannel: null
+    };
+
+    /**
+     * Initialize the shift reminder system
+     */
+    function init() {
+        // Initialize BroadcastChannel for multi-tab coordination
+        if (typeof BroadcastChannel !== 'undefined') {
+            state.bcChannel = new BroadcastChannel('shift_reminder_channel');
+            state.bcChannel.onmessage = handleBroadcastMessage;
+        }
+
+        // Listen to localStorage changes (fallback for browsers without BroadcastChannel)
+        window.addEventListener('storage', handleStorageChange);
+
+        // Initialize Web Audio API (lazy loading to respect autoplay policies)
+        initAudioContext();
+
+        // Start polling
+        startPolling();
+
+        // Check immediately on load
+        checkReminder();
+    }
+
+    /**
+     * Initialize Web Audio API context
+     */
+    function initAudioContext() {
+        try {
+            const AudioContext = window.AudioContext || window.webkitAudioContext;
+            if (AudioContext) {
+                state.audioContext = new AudioContext();
+            }
+        } catch (e) {
+            console.warn('Web Audio API not supported:', e);
+        }
+    }
+
+    /**
+     * Start polling for reminders
+     */
+    function startPolling() {
+        if (state.pollTimer) {
+            clearInterval(state.pollTimer);
+        }
+
+        state.pollTimer = setInterval(() => {
+            checkReminder();
+        }, state.currentInterval);
+    }
+
+    /**
+     * Stop polling
+     */
+    function stopPolling() {
+        if (state.pollTimer) {
+            clearInterval(state.pollTimer);
+            state.pollTimer = null;
+        }
+    }
+
+    /**
+     * Check if reminder should be shown
+     */
+    function checkReminder() {
+        // Don't check if modal is already visible
+        if (state.isModalVisible) {
+            return;
+        }
+
+        // Check if another tab already handled this reminder
+        if (isReminderHandledByOtherTab()) {
+            return;
+        }
+
+        $.ajax({
+            url: '/shift-reminder/check',
+            type: 'POST',
+            dataType: 'json',
+            data: {
+                [csrfParam]: csrfToken
+            },
+            success: function(response) {
+                // Reset retry count on successful request
+                state.retryCount = 0;
+                state.currentInterval = CONFIG.pollInterval;
+
+                if (response.success && response.show_reminder) {
+                    showReminderModal(response);
+                }
+            },
+            error: function(xhr, status, error) {
+                handleRequestError(xhr, status, error);
+            }
+        });
+    }
+
+    /**
+     * Handle AJAX request errors with exponential backoff
+     */
+    function handleRequestError(xhr, status, error) {
+        if (xhr.status === 429) {
+            // Rate limit exceeded - increase polling interval
+            state.currentInterval = Math.min(
+                state.currentInterval * 2,
+                CONFIG.maxPollInterval
+            );
+            stopPolling();
+            startPolling();
+            return;
+        }
+
+        // Network error - implement exponential backoff
+        if (state.retryCount < CONFIG.retryIntervals.length - 1) {
+            state.retryCount++;
+        }
+
+        state.currentInterval = CONFIG.retryIntervals[state.retryCount];
+        stopPolling();
+        startPolling();
+    }
+
+    /**
+     * Check if reminder was already handled by another tab
+     */
+    function isReminderHandledByOtherTab() {
+        try {
+            const stored = localStorage.getItem(CONFIG.localStorageKey);
+            if (!stored) {
+                return false;
+            }
+
+            const data = JSON.parse(stored);
+            const now = new Date().getTime();
+
+            // Check if confirmation was within last 5 minutes
+            if (data.timestamp && (now - data.timestamp) < 300000) {
+                return true;
+            }
+
+            // Clean up old data
+            localStorage.removeItem(CONFIG.localStorageKey);
+            return false;
+        } catch (e) {
+            console.error('Error checking localStorage:', e);
+            return false;
+        }
+    }
+
+    /**
+     * Show fullscreen reminder modal
+     */
+    function showReminderModal(data) {
+        if (state.isModalVisible) {
+            return;
+        }
+
+        state.isModalVisible = true;
+
+        // Create modal HTML
+        const modalHtml = `
+            <div id="shift-reminder-modal" class="shift-reminder-overlay">
+                <div class="shift-reminder-content">
+                    <div class="shift-reminder-icon">
+                        <i class="fa fa-bell fa-3x"></i>
+                    </div>
+                    <h2 class="shift-reminder-title">Напоминание о смене</h2>
+                    <p class="shift-reminder-message">
+                        ${data.message || 'Пожалуйста, подтвердите, что вы ознакомились с напоминанием о смене.'}
+                    </p>
+                    <button id="shift-reminder-confirm-btn" class="btn btn-primary btn-lg">
+                        Подтвердить
+                    </button>
+                </div>
+            </div>
+        `;
+
+        // Add modal to DOM
+        $('body').append(modalHtml);
+
+        // Add modal styles
+        addModalStyles();
+
+        // Play audio alert
+        playAudioAlert();
+
+        // Notify other tabs
+        broadcastMessage({
+            type: 'modal_shown',
+            timestamp: new Date().getTime()
+        });
+
+        // Handle confirmation
+        $('#shift-reminder-confirm-btn').on('click', function() {
+            confirmReminder(data.reminder_key);
+        });
+    }
+
+    /**
+     * Add CSS styles for fullscreen modal
+     */
+    function addModalStyles() {
+        if ($('#shift-reminder-styles').length > 0) {
+            return;
+        }
+
+        const styles = `
+            <style id="shift-reminder-styles">
+                .shift-reminder-overlay {
+                    position: fixed;
+                    top: 0;
+                    left: 0;
+                    right: 0;
+                    bottom: 0;
+                    background: rgba(0, 0, 0, 0.9);
+                    z-index: 99999;
+                    display: flex;
+                    align-items: center;
+                    justify-content: center;
+                    animation: shift-reminder-fade-in 0.3s ease-in;
+                }
+
+                .shift-reminder-content {
+                    background: #fff;
+                    padding: 40px;
+                    border-radius: 10px;
+                    text-align: center;
+                    max-width: 500px;
+                    box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
+                }
+
+                .dark-mode .shift-reminder-content {
+                    background: #2d3035;
+                    color: #fff;
+                }
+
+                .shift-reminder-icon {
+                    color: #f39c12;
+                    margin-bottom: 20px;
+                }
+
+                .shift-reminder-title {
+                    font-size: 24px;
+                    font-weight: bold;
+                    margin-bottom: 20px;
+                }
+
+                .shift-reminder-message {
+                    font-size: 16px;
+                    margin-bottom: 30px;
+                    line-height: 1.5;
+                }
+
+                #shift-reminder-confirm-btn {
+                    font-size: 18px;
+                    padding: 12px 40px;
+                    min-width: 200px;
+                }
+
+                @keyframes shift-reminder-fade-in {
+                    from {
+                        opacity: 0;
+                    }
+                    to {
+                        opacity: 1;
+                    }
+                }
+            </style>
+        `;
+
+        $('head').append(styles);
+    }
+
+    /**
+     * Play audio alert using Web Audio API
+     */
+    function playAudioAlert() {
+        if (!state.audioContext) {
+            console.warn('Audio context not available');
+            return;
+        }
+
+        try {
+            // Resume audio context if suspended (autoplay policy)
+            if (state.audioContext.state === 'suspended') {
+                state.audioContext.resume().then(() => {
+                    playBeep();
+                }).catch((e) => {
+                    console.warn('Could not resume audio context:', e);
+                });
+            } else {
+                playBeep();
+            }
+        } catch (e) {
+            console.error('Error playing audio alert:', e);
+        }
+    }
+
+    /**
+     * Play beep sound
+     */
+    function playBeep() {
+        try {
+            const oscillator = state.audioContext.createOscillator();
+            const gainNode = state.audioContext.createGain();
+
+            oscillator.connect(gainNode);
+            gainNode.connect(state.audioContext.destination);
+
+            oscillator.frequency.value = CONFIG.audioFrequency;
+            oscillator.type = 'sine';
+
+            gainNode.gain.setValueAtTime(0.3, state.audioContext.currentTime);
+            gainNode.gain.exponentialRampToValueAtTime(
+                0.01,
+                state.audioContext.currentTime + CONFIG.audioDuration / 1000
+            );
+
+            oscillator.start(state.audioContext.currentTime);
+            oscillator.stop(state.audioContext.currentTime + CONFIG.audioDuration / 1000);
+        } catch (e) {
+            console.error('Error playing beep:', e);
+        }
+    }
+
+    /**
+     * Confirm reminder viewing
+     */
+    function confirmReminder(reminderKey) {
+        // Disable button to prevent double-clicks
+        $('#shift-reminder-confirm-btn').prop('disabled', true).text('Подтверждение...');
+
+        $.ajax({
+            url: '/shift-reminder/confirm',
+            type: 'POST',
+            dataType: 'json',
+            data: {
+                reminder_key: reminderKey,
+                [csrfParam]: csrfToken
+            },
+            success: function(response) {
+                if (response.success) {
+                    // Store confirmation in localStorage for multi-tab coordination
+                    try {
+                        localStorage.setItem(CONFIG.localStorageKey, JSON.stringify({
+                            reminder_key: reminderKey,
+                            timestamp: new Date().getTime()
+                        }));
+                    } catch (e) {
+                        console.error('Error storing confirmation:', e);
+                    }
+
+                    // Notify other tabs
+                    broadcastMessage({
+                        type: 'confirmed',
+                        reminder_key: reminderKey,
+                        timestamp: new Date().getTime()
+                    });
+
+                    // Close modal
+                    closeModal();
+                } else {
+                    alert('Ошибка подтверждения: ' + (response.message || 'Неизвестная ошибка'));
+                    $('#shift-reminder-confirm-btn').prop('disabled', false).text('Подтвердить');
+                }
+            },
+            error: function(xhr, status, error) {
+                let errorMessage = 'Ошибка при подтверждении. Попробуйте еще раз.';
+
+                if (xhr.status === 400 && xhr.responseJSON && xhr.responseJSON.message) {
+                    errorMessage = xhr.responseJSON.message;
+                } else if (xhr.status === 429) {
+                    errorMessage = 'Превышен лимит запросов. Пожалуйста, подождите немного.';
+                }
+
+                alert(errorMessage);
+                $('#shift-reminder-confirm-btn').prop('disabled', false).text('Подтвердить');
+            }
+        });
+    }
+
+    /**
+     * Close reminder modal
+     */
+    function closeModal() {
+        $('#shift-reminder-modal').fadeOut(300, function() {
+            $(this).remove();
+            state.isModalVisible = false;
+        });
+    }
+
+    /**
+     * Broadcast message to other tabs
+     */
+    function broadcastMessage(message) {
+        if (state.bcChannel) {
+            try {
+                state.bcChannel.postMessage(message);
+            } catch (e) {
+                console.error('Error broadcasting message:', e);
+            }
+        }
+    }
+
+    /**
+     * Handle messages from other tabs (BroadcastChannel)
+     */
+    function handleBroadcastMessage(event) {
+        const data = event.data;
+
+        if (data.type === 'confirmed' || data.type === 'modal_shown') {
+            // Another tab confirmed or showed modal - close ours if visible
+            if (state.isModalVisible) {
+                closeModal();
+            }
+        }
+    }
+
+    /**
+     * Handle localStorage changes (fallback for browsers without BroadcastChannel)
+     */
+    function handleStorageChange(event) {
+        if (event.key === CONFIG.localStorageKey && event.newValue) {
+            // Another tab confirmed - close our modal if visible
+            if (state.isModalVisible) {
+                closeModal();
+            }
+        }
+    }
+
+    /**
+     * Cleanup on page unload
+     */
+    function cleanup() {
+        stopPolling();
+
+        if (state.bcChannel) {
+            state.bcChannel.close();
+        }
+
+        if (state.audioContext) {
+            state.audioContext.close();
+        }
+    }
+
+    // Initialize on DOM ready
+    $(document).ready(function() {
+        init();
+    });
+
+    // Cleanup on page unload
+    $(window).on('beforeunload', function() {
+        cleanup();
+    });
+
+})();