From 026def350a856093edddebf10c04834b5ccd7991 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 8 Jan 2026 08:54:52 +0100 Subject: [PATCH 1/3] Initial commit with task details Adding CLAUDE.md with task information for AI processing. This file will be removed when the task is complete. Issue: https://github.com/xierongchuan/TaskMateTelegramBot/issues/31 --- CLAUDE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index bf715c8..be8f009 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -362,3 +362,16 @@ Copyright: © 2023-2025 谢榕川 All rights reserved - [swagger.yaml](swagger.yaml) - OpenAPI спецификация - [docs/](docs/) - Дополнительная документация - [check-scheduler.md](check-scheduler.md) - Проверка планировщика задач + +--- + +Issue to solve: https://github.com/xierongchuan/TaskMateTelegramBot/issues/31 +Your prepared branch: issue-31-e50894ec22be +Your prepared working directory: /tmp/gh-issue-solver-1767858884685 +Your forked repository: konard/TaskMateTelegramBot +Original repository (upstream): xierongchuan/TaskMateTelegramBot + +Proceed. + + +Run timestamp: 2026-01-08T07:54:52.013Z \ No newline at end of file From ce3e5346739dc46d113faeb18ac741da8478fb44 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 8 Jan 2026 09:15:44 +0100 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=D0=9F=D0=B5=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D0=BE=D0=B4=20UI=20=D0=BD=D0=B0=20Material=20Design=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Внедрение принципов Material Design 3 в Telegram бот интерфейс: ## Новый дизайн-система MaterialDesign3Trait - Централизованная система иконок с семантическими emoji - Типографика с вспомогательными методами (md3Headline, md3Card) - Персонализированные приветствия по времени суток - Плюрализация для русского языка ## Обновленные компоненты - KeyboardTrait: MD3 паттерны кнопок, новые методы клавиатур - TaskNotificationService: чистое MD3 форматирование сообщений - StartConversation: приветствия с контекстом времени - OpenShiftConversation: улучшенный UX для фото и выбора - CloseShiftConversation: чистый формат уведомлений - ViewTasksCommand/ViewShiftsCommand/ViewDealershipsCommand: MD3 списки - TaskResponseHandler: лаконичная обратная связь ## Принципы MD3 - Визуальная иерархия через типографику - Семантическая иконография - Немедленная обратная связь через toast - Консистентные паттерны форматирования 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../Commands/Manager/ViewShiftsCommand.php | 32 +- app/Bot/Commands/Manager/ViewTasksCommand.php | 22 +- .../Commands/Owner/ViewDealershipsCommand.php | 25 +- .../Employee/CloseShiftConversation.php | 128 ++-- .../Employee/OpenShiftConversation.php | 244 +++--- .../Conversations/Guest/StartConversation.php | 84 ++- app/Bot/Handlers/TaskResponseHandler.php | 41 +- app/Services/TaskNotificationService.php | 203 +++-- app/Traits/KeyboardTrait.php | 235 +++++- app/Traits/MaterialDesign3Trait.php | 693 ++++++++++++++++++ 10 files changed, 1407 insertions(+), 300 deletions(-) create mode 100644 app/Traits/MaterialDesign3Trait.php diff --git a/app/Bot/Commands/Manager/ViewShiftsCommand.php b/app/Bot/Commands/Manager/ViewShiftsCommand.php index b45098c..252d2d6 100644 --- a/app/Bot/Commands/Manager/ViewShiftsCommand.php +++ b/app/Bot/Commands/Manager/ViewShiftsCommand.php @@ -7,14 +7,18 @@ use App\Bot\Abstracts\BaseCommandHandler; use App\Models\User; use App\Models\Shift; +use App\Traits\MaterialDesign3Trait; use SergiX44\Nutgram\Nutgram; use Carbon\Carbon; /** - * Command for managers to view shifts + * Command for managers to view shifts. + * MD3: List presentation with status indicators. */ class ViewShiftsCommand extends BaseCommandHandler { + use MaterialDesign3Trait; + protected string $command = 'viewshifts'; protected ?string $description = 'Просмотр смен'; @@ -37,33 +41,37 @@ protected function execute(Nutgram $bot, User $user): void ->with('user') ->get(); - $message = "📊 *Смены сегодня*\n\n"; + $lines = []; + $lines[] = '📊 *Смены сегодня*'; if ($todayShifts->isEmpty() && $completedShifts->isEmpty()) { - $message .= "Нет смен на сегодня.\n"; + $lines[] = ''; + $lines[] = 'Нет смен на сегодня'; } else { if ($todayShifts->isNotEmpty()) { - $message .= "*Активные смены:*\n"; + $lines[] = ''; + $lines[] = '*Активные:*'; foreach ($todayShifts as $shift) { $startTime = $shift->actual_start->format('H:i'); - $status = $shift->status === 'late' ? '🔴 Опоздание' : '🟢 Вовремя'; - $message .= "• {$shift->user->name} ({$startTime}) - {$status}\n"; + $status = $shift->status === 'late' ? '🔴' : '🟢'; + $lines[] = "{$status} {$shift->user->name} · {$startTime}"; } - $message .= "\n"; } if ($completedShifts->isNotEmpty()) { - $message .= "*Завершённые смены:*\n"; + $lines[] = ''; + $lines[] = '*Завершённые:*'; foreach ($completedShifts as $shift) { $startTime = $shift->actual_start->format('H:i'); - $endTime = $shift->actual_end?->format('H:i') ?? 'N/A'; - $message .= "• {$shift->user->name} ({$startTime} - {$endTime})\n"; + $endTime = $shift->actual_end?->format('H:i') ?? '—'; + $lines[] = "✓ {$shift->user->name} · {$startTime}–{$endTime}"; } } } - $message .= "\n💡 Для полного функционала используйте веб-админку."; + $lines[] = ''; + $lines[] = '💡 Полный функционал в веб-админке'; - $bot->sendMessage($message, parse_mode: 'Markdown'); + $bot->sendMessage(implode("\n", $lines), parse_mode: 'Markdown'); } } diff --git a/app/Bot/Commands/Manager/ViewTasksCommand.php b/app/Bot/Commands/Manager/ViewTasksCommand.php index cf607fa..f33890b 100644 --- a/app/Bot/Commands/Manager/ViewTasksCommand.php +++ b/app/Bot/Commands/Manager/ViewTasksCommand.php @@ -7,13 +7,17 @@ use App\Bot\Abstracts\BaseCommandHandler; use App\Models\User; use App\Models\Task; +use App\Traits\MaterialDesign3Trait; use SergiX44\Nutgram\Nutgram; /** - * Command for managers to view tasks + * Command for managers to view tasks. + * MD3: Task list with status summary chips. */ class ViewTasksCommand extends BaseCommandHandler { + use MaterialDesign3Trait; + protected string $command = 'viewtasks'; protected ?string $description = 'Просмотр задач'; @@ -30,13 +34,16 @@ protected function execute(Nutgram $bot, User $user): void ->take(10) ->get(); - $message = "📋 *Задачи*\n\n"; + $lines = []; + $lines[] = '📋 *Задачи*'; if ($tasks->isEmpty()) { - $message .= "Нет активных задач.\n"; + $lines[] = ''; + $lines[] = 'Нет активных задач'; } else { foreach ($tasks as $task) { - $message .= "*{$task->title}*\n"; + $lines[] = ''; + $lines[] = "*{$task->title}*"; // Count statuses $completed = 0; @@ -59,12 +66,13 @@ protected function execute(Nutgram $bot, User $user): void } $total = $task->assignments->count(); - $message .= "Назначено: {$total} | ✅ {$completed} | 👁️ {$acknowledged} | ⏸️ {$pending}\n\n"; + $lines[] = "👥 {$total} · ✅ {$completed} · 👁️ {$acknowledged} · ⏳ {$pending}"; } } - $message .= "💡 Для управления задачами используйте веб-админку."; + $lines[] = ''; + $lines[] = '💡 Управление в веб-админке'; - $bot->sendMessage($message, parse_mode: 'Markdown'); + $bot->sendMessage(implode("\n", $lines), parse_mode: 'Markdown'); } } diff --git a/app/Bot/Commands/Owner/ViewDealershipsCommand.php b/app/Bot/Commands/Owner/ViewDealershipsCommand.php index 5a58f4a..5bbfc5f 100644 --- a/app/Bot/Commands/Owner/ViewDealershipsCommand.php +++ b/app/Bot/Commands/Owner/ViewDealershipsCommand.php @@ -7,13 +7,17 @@ use App\Bot\Abstracts\BaseCommandHandler; use App\Models\User; use App\Models\AutoDealership; +use App\Traits\MaterialDesign3Trait; use SergiX44\Nutgram\Nutgram; /** - * Command for owners to view dealerships + * Command for owners to view dealerships. + * MD3: Card list with location and stats. */ class ViewDealershipsCommand extends BaseCommandHandler { + use MaterialDesign3Trait; + protected string $command = 'viewdealerships'; protected ?string $description = 'Просмотр салонов'; @@ -22,20 +26,25 @@ protected function execute(Nutgram $bot, User $user): void // Get all dealerships $dealerships = AutoDealership::withCount('users')->get(); - $message = "🏢 *Автосалоны*\n\n"; + $lines = []; + $lines[] = '🏢 *Автосалоны*'; if ($dealerships->isEmpty()) { - $message .= "Нет автосалонов в системе.\n"; + $lines[] = ''; + $lines[] = 'Нет салонов в системе'; } else { foreach ($dealerships as $dealership) { - $message .= "*{$dealership->name}*\n"; - $message .= "📍 {$dealership->address}\n"; - $message .= "👥 Сотрудников: {$dealership->users_count}\n\n"; + $lines[] = ''; + $lines[] = "*{$dealership->name}*"; + $lines[] = "📍 {$dealership->address}"; + $lines[] = "👥 {$dealership->users_count} " . + $this->pluralizeRu($dealership->users_count, 'сотрудник', 'сотрудника', 'сотрудников'); } } - $message .= "💡 Для управления салонами используйте веб-админку."; + $lines[] = ''; + $lines[] = '💡 Управление в веб-админке'; - $bot->sendMessage($message, parse_mode: 'Markdown'); + $bot->sendMessage(implode("\n", $lines), parse_mode: 'Markdown'); } } diff --git a/app/Bot/Conversations/Employee/CloseShiftConversation.php b/app/Bot/Conversations/Employee/CloseShiftConversation.php index 1d7822c..e3901d4 100644 --- a/app/Bot/Conversations/Employee/CloseShiftConversation.php +++ b/app/Bot/Conversations/Employee/CloseShiftConversation.php @@ -9,21 +9,30 @@ use App\Models\Task; use App\Models\User; use App\Services\ShiftService; +use App\Traits\MaterialDesign3Trait; use Carbon\Carbon; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use SergiX44\Nutgram\Nutgram; /** - * Conversation for closing a shift with photo upload and task logging + * Conversation for closing a shift with photo upload and task logging. + * + * Implements Material Design 3 principles: + * - Step-by-step dialog with progress indicators + * - Clear status feedback cards + * - Semantic messaging patterns */ class CloseShiftConversation extends BaseConversation { + use MaterialDesign3Trait; + protected ?string $photoPath = null; protected ?Shift $shift = null; /** - * Start: Check for open shift and request photo + * Start: Check for open shift and request photo. + * MD3: Status card with current shift info. */ public function start(Nutgram $bot): void { @@ -34,7 +43,7 @@ public function start(Nutgram $bot): void // Validate user belongs to a dealership if (!$shiftService->validateUserDealership($user)) { $bot->sendMessage( - '⚠️ Вы не привязаны к дилерскому центру. Обратитесь к администратору.', + '⚠️ Не привязаны к салону. Обратитесь к администратору.', reply_markup: static::employeeMenu() ); $this->end(); @@ -45,31 +54,30 @@ public function start(Nutgram $bot): void $openShift = $shiftService->getUserOpenShift($user); if (!$openShift) { - $bot->sendMessage('⚠️ У вас нет открытой смены.', reply_markup: static::employeeMenu()); + $bot->sendMessage('⚠️ Нет открытой смены', reply_markup: static::employeeMenu()); $this->end(); return; } $this->shift = $openShift; - // Show shift info before requesting photo - $message = "🕐 Текущая смена открыта в " . $openShift->shift_start->format('H:i d.m.Y') . "\n\n"; + // Build shift info message with MD3 card pattern + $lines = []; + $lines[] = '🔒 *Закрытие смены*'; + $lines[] = ''; + $lines[] = '🕐 Открыта: ' . $openShift->shift_start->format('H:i d.m.Y'); + if ($openShift->status === 'late') { - $message .= "⚠️ Смена открыта с опозданием на {$openShift->late_minutes} минут.\n\n"; + $lines[] = '⚠️ Опоздание: ' . $openShift->late_minutes . ' мин.'; } - $message .= "📸 Пожалуйста, загрузите фото экрана компьютера с текущим временем для закрытия смены."; + + $lines[] = ''; + $lines[] = '📷 Загрузите фото экрана.'; $bot->sendMessage( - $message, - reply_markup: \SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardMarkup::make() - ->addRow(\SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardButton::make( - text: '⏭️ Пропустить фото', - callback_data: 'skip_photo' - )) - ->addRow(\SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardButton::make( - text: '❌ Отменить', - callback_data: 'cancel' - )) + implode("\n", $lines), + parse_mode: 'markdown', + reply_markup: static::photoUploadKeyboard('skip_photo', 'cancel') ); $this->next('handlePhoto'); @@ -79,7 +87,8 @@ public function start(Nutgram $bot): void } /** - * Handle photo upload + * Handle photo upload. + * MD3: Validation with clear next step. */ public function handlePhoto(Nutgram $bot): void { @@ -94,7 +103,7 @@ public function handlePhoto(Nutgram $bot): void // Handle cancel button if ($bot->callbackQuery() && $bot->callbackQuery()->data === 'cancel') { $bot->answerCallbackQuery(); - $bot->sendMessage('❌ Закрытие смены отменено.', reply_markup: static::employeeMenu()); + $bot->sendMessage('❌ Отменено', reply_markup: static::employeeMenu()); $this->end(); return; } @@ -103,17 +112,8 @@ public function handlePhoto(Nutgram $bot): void if (!$photo || empty($photo)) { $bot->sendMessage( - '⚠️ Пожалуйста, отправьте фото.\n\n' . - 'Или нажмите кнопку "Пропустить фото" или "Отменить".', - reply_markup: \SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardMarkup::make() - ->addRow(\SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardButton::make( - text: '⏭️ Пропустить фото', - callback_data: 'skip_photo' - )) - ->addRow(\SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardButton::make( - text: '❌ Отменить', - callback_data: 'cancel' - )) + '⚠️ Отправьте фото или пропустите.', + reply_markup: static::photoUploadKeyboard('skip_photo', 'cancel') ); $this->next('handlePhoto'); return; @@ -141,7 +141,7 @@ public function handlePhoto(Nutgram $bot): void // Store as UploadedFile for compatibility with ShiftService $this->photoPath = $tempPath; - $bot->sendMessage('✅ Фото получено. Закрываю смену...'); + $bot->sendMessage('✓ Фото получено. Закрываем смену...'); $this->closeShift($bot); } catch (\Throwable $e) { @@ -150,7 +150,8 @@ public function handlePhoto(Nutgram $bot): void } /** - * Close the shift using ShiftService + * Close the shift using ShiftService. + * MD3: Success card with summary statistics. */ private function closeShift(Nutgram $bot): void { @@ -159,7 +160,7 @@ private function closeShift(Nutgram $bot): void $shiftService = app(ShiftService::class); if (!$this->shift) { - $bot->sendMessage('⚠️ Ошибка: смена не найдена.', reply_markup: static::employeeMenu()); + $bot->sendMessage('⚠️ Смена не найдена', reply_markup: static::employeeMenu()); $this->end(); return; } @@ -181,7 +182,7 @@ private function closeShift(Nutgram $bot): void // Use ShiftService to close the shift $shift = $shiftService->getUserOpenShift($user); if (!$shift) { - $bot->sendMessage('У вас нет открытой смены.'); + $bot->sendMessage('⚠️ Нет открытой смены'); return; } $updatedShift = $shiftService->closeShift($shift, $closingPhoto); @@ -196,9 +197,16 @@ private function closeShift(Nutgram $bot): void $hours = floor($duration / 60); $minutes = $duration % 60; - $message = '✅ Смена закрыта в ' . $now->format('H:i d.m.Y') . "\n\n"; - $message .= "🕐 Продолжительность: {$hours}ч {$minutes}м\n"; - $message .= "📊 Статус: " . ($updatedShift->status === 'late' ? 'Опоздание' : 'Нормально') . "\n"; + // Build success message with MD3 card pattern + $lines = []; + $lines[] = '✅ *Смена закрыта*'; + $lines[] = '🕐 ' . $now->format('H:i d.m.Y'); + $lines[] = ''; + $lines[] = "⏱️ Продолжительность: {$hours}ч {$minutes}м"; + + if ($updatedShift->status === 'late') { + $lines[] = '⚠️ Было опоздание'; + } // Find incomplete tasks using dealership context $incompleteTasks = Task::whereHas('assignments', function ($query) use ($user) { @@ -218,24 +226,28 @@ private function closeShift(Nutgram $bot): void ->get(); if ($incompleteTasks->isNotEmpty()) { - $message .= "\n\n⚠️ *Незавершённых задач: " . $incompleteTasks->count() . "*\n\n"; + $count = $incompleteTasks->count(); + $taskWord = $this->pluralizeRu($count, 'задача', 'задачи', 'задач'); + $lines[] = ''; + $lines[] = "⚠️ *Незавершено: {$count} {$taskWord}*"; - // Log incomplete tasks + // List incomplete tasks foreach ($incompleteTasks as $task) { - $message .= "• {$task->title}"; + $taskLine = "• {$task->title}"; if ($task->deadline) { - $message .= " (Дедлайн: " . $task->deadline->format('d.m H:i') . ")"; + $taskLine .= " ⏰ {$task->deadline->format('d.m H:i')}"; } - $message .= "\n"; + $lines[] = $taskLine; } // Notify managers about incomplete tasks $this->notifyManagersAboutIncompleteTasks($bot, $user, $incompleteTasks); } else { - $message .= "\n\n✅ Все задачи выполнены!"; + $lines[] = ''; + $lines[] = '✅ Все задачи выполнены'; } - $bot->sendMessage($message, parse_mode: 'Markdown', reply_markup: static::employeeMenu()); + $bot->sendMessage(implode("\n", $lines), parse_mode: 'Markdown', reply_markup: static::employeeMenu()); \Illuminate\Support\Facades\Log::info( "Shift closed by user #{$user->id} in dealership #{$this->shift->dealership_id}, " . @@ -253,7 +265,8 @@ private function closeShift(Nutgram $bot): void } /** - * Notify managers about incomplete tasks when shift closes + * Notify managers about incomplete tasks when shift closes. + * MD3: Alert notification to managers. */ private function notifyManagersAboutIncompleteTasks(Nutgram $bot, User $user, $incompleteTasks): void { @@ -265,23 +278,28 @@ private function notifyManagersAboutIncompleteTasks(Nutgram $bot, User $user, $i ->get(); foreach ($managers as $manager) { - $message = "⚠️ *Смена закрыта с незавершёнными задачами*\n\n"; - $message .= "👤 Сотрудник: {$user->full_name}\n"; - $message .= "🕐 Время закрытия: " . Carbon::now()->format('H:i d.m.Y') . "\n"; - $message .= "📋 Незавершённых задач: {$incompleteTasks->count()}\n\n"; - $message .= "*Список незавершённых задач:*\n"; + $count = $incompleteTasks->count(); + $taskWord = $this->pluralizeRu($count, 'задача', 'задачи', 'задач'); + + $lines = []; + $lines[] = '⚠️ *Незавершённые задачи*'; + $lines[] = ''; + $lines[] = "👤 {$user->full_name}"; + $lines[] = '🕐 ' . Carbon::now()->format('H:i d.m.Y'); + $lines[] = ''; + $lines[] = "*{$count} {$taskWord}:*"; foreach ($incompleteTasks as $task) { - $message .= "• {$task->title}"; + $taskLine = "• {$task->title}"; if ($task->deadline) { - $message .= " (⏰ {$task->deadline->format('d.m H:i')})"; + $taskLine .= " ⏰ {$task->deadline->format('d.m H:i')}"; } - $message .= "\n"; + $lines[] = $taskLine; } try { $bot->sendMessage( - text: $message, + text: implode("\n", $lines), chat_id: $manager->telegram_id, parse_mode: 'Markdown' ); diff --git a/app/Bot/Conversations/Employee/OpenShiftConversation.php b/app/Bot/Conversations/Employee/OpenShiftConversation.php index 0f5c2e0..418904a 100644 --- a/app/Bot/Conversations/Employee/OpenShiftConversation.php +++ b/app/Bot/Conversations/Employee/OpenShiftConversation.php @@ -9,23 +9,32 @@ use App\Models\User; use App\Models\ShiftReplacement; use App\Services\ShiftService; +use App\Traits\MaterialDesign3Trait; use Carbon\Carbon; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use SergiX44\Nutgram\Nutgram; /** - * Conversation for opening a shift with photo upload and optional replacement + * Conversation for opening a shift with photo upload and optional replacement. + * + * Implements Material Design 3 principles: + * - Step-by-step dialog flow with clear progress + * - Semantic feedback for each action + * - Consistent iconography and messaging patterns */ class OpenShiftConversation extends BaseConversation { + use MaterialDesign3Trait; + protected ?string $photoPath = null; protected ?bool $isReplacement = null; protected ?int $replacedUserId = null; protected ?string $replacementReason = null; /** - * Start: Ask for photo of computer screen with current time + * Start: Ask for photo of computer screen with current time. + * MD3: Step-by-step dialog with clear instructions. */ public function start(Nutgram $bot): void { @@ -36,7 +45,7 @@ public function start(Nutgram $bot): void // Validate user belongs to a dealership if (!$shiftService->validateUserDealership($user)) { $bot->sendMessage( - '⚠️ Вы не привязаны к дилерскому центру. Обратитесь к администратору.' + '⚠️ Не привязаны к салону. Обратитесь к администратору.' ); $this->end(); return; @@ -46,21 +55,26 @@ public function start(Nutgram $bot): void $openShift = $shiftService->getUserOpenShift($user); if ($openShift) { - $bot->sendMessage( - '⚠️ У вас уже есть открытая смена с ' . - $openShift->shift_start->format('H:i d.m.Y') - ); + $message = implode("\n", [ + '⚠️ *Смена уже открыта*', + '', + '🕐 С ' . $openShift->shift_start->format('H:i d.m.Y'), + ]); + $bot->sendMessage($message, parse_mode: 'markdown'); $this->end(); return; } + $message = implode("\n", [ + '📷 *Открытие смены*', + '', + 'Загрузите фото экрана с текущим временем.', + ]); + $bot->sendMessage( - '📸 Пожалуйста, загрузите фото экрана компьютера с текущим временем для открытия смены.', - reply_markup: static::inlineConfirmDecline('skip_photo', 'cancel') - ->addRow(\SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardButton::make( - text: '❌ Отменить', - callback_data: 'cancel' - )) + $message, + parse_mode: 'markdown', + reply_markup: static::photoUploadKeyboard('skip_photo', 'cancel') ); $this->next('handlePhoto'); @@ -70,15 +84,28 @@ public function start(Nutgram $bot): void } /** - * Handle photo upload + * Handle photo upload. + * MD3: Validation feedback with next step guidance. */ public function handlePhoto(Nutgram $bot): void { try { + // Handle skip button + if ($bot->callbackQuery() && $bot->callbackQuery()->data === 'skip_photo') { + $bot->answerCallbackQuery(); + // Ask replacement question without photo + $bot->sendMessage( + '❓ Вы заменяете другого сотрудника?', + reply_markup: static::yesNoKeyboard() + ); + $this->next('handleReplacementQuestion'); + return; + } + // Handle cancel button if ($bot->callbackQuery() && $bot->callbackQuery()->data === 'cancel') { $bot->answerCallbackQuery(); - $bot->sendMessage('❌ Открытие смены отменено.', reply_markup: static::employeeMenu()); + $bot->sendMessage('❌ Отменено', reply_markup: static::employeeMenu()); $this->end(); return; } @@ -87,9 +114,8 @@ public function handlePhoto(Nutgram $bot): void if (!$photo || empty($photo)) { $bot->sendMessage( - '⚠️ Пожалуйста, отправьте фото.\n\n' . - 'Или нажмите кнопку "Отменить" для выхода.', - reply_markup: static::inlineConfirmDecline('skip_photo', 'cancel') + '⚠️ Отправьте фото или пропустите.', + reply_markup: static::photoUploadKeyboard('skip_photo', 'cancel') ); $this->next('handlePhoto'); return; @@ -119,9 +145,9 @@ public function handlePhoto(Nutgram $bot): void // Ask if replacing another employee $bot->sendMessage( - '✅ Фото получено.\n\n' . + '✅ Фото получено' . "\n\n" . '❓ Вы заменяете другого сотрудника?', - reply_markup: static::yesNoKeyboard('Да', 'Нет') + reply_markup: static::yesNoKeyboard() ); $this->next('handleReplacementQuestion'); @@ -131,7 +157,8 @@ public function handlePhoto(Nutgram $bot): void } /** - * Handle replacement question + * Handle replacement question. + * MD3: Binary choice dialog with clear navigation. */ public function handleReplacementQuestion(Nutgram $bot): void { @@ -140,8 +167,8 @@ public function handleReplacementQuestion(Nutgram $bot): void if ($bot->callbackQuery()) { $bot->answerCallbackQuery(); $bot->sendMessage( - '⚠️ Пожалуйста, используйте кнопки ниже для ответа.', - reply_markup: static::yesNoKeyboard('Да', 'Нет') + '⚠️ Используйте кнопки ниже.', + reply_markup: static::yesNoKeyboard() ); $this->next('handleReplacementQuestion'); return; @@ -151,14 +178,15 @@ public function handleReplacementQuestion(Nutgram $bot): void if (!$answer) { $bot->sendMessage( - '⚠️ Пожалуйста, выберите "Да" или "Нет"', - reply_markup: static::yesNoKeyboard('Да', 'Нет') + '⚠️ Выберите ответ', + reply_markup: static::yesNoKeyboard() ); $this->next('handleReplacementQuestion'); return; } - if ($answer === 'Да') { + // Check for yes variants (with or without checkmark) + if ($answer === '✓ Да' || $answer === 'Да') { $this->isReplacement = true; // Get list of employees from same dealership @@ -170,46 +198,36 @@ public function handleReplacementQuestion(Nutgram $bot): void if ($employees->isEmpty()) { $bot->sendMessage( - '⚠️ Не найдено других сотрудников в вашем салоне.', + '⚠️ Нет других сотрудников в салоне.', reply_markup: static::removeKeyboard() ); $this->createShift($bot); return; } - // Create inline keyboard with employee list - $buttons = []; - foreach ($employees as $employee) { - $buttons[] = [ - \SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardButton::make( - text: $employee->full_name, - callback_data: 'employee_' . $employee->id - ) - ]; - } - - $keyboard = \SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardMarkup::make(); - foreach ($buttons as $row) { - $keyboard->addRow(...$row); - } + // Create employee list for selection keyboard + $employeeList = $employees->map(fn($e) => [ + 'id' => $e->id, + 'name' => $e->full_name + ])->toArray(); // First remove the reply keyboard, then show inline keyboard - $bot->sendMessage('✅ Понятно', reply_markup: static::removeKeyboard()); + $bot->sendMessage('✓', reply_markup: static::removeKeyboard()); $bot->sendMessage( - '👤 Выберите сотрудника, которого вы заменяете:', - reply_markup: $keyboard + '👤 Кого заменяете?', + reply_markup: static::employeeSelectionKeyboard($employeeList) ); $this->next('handleEmployeeSelection'); } elseif ($answer === 'Нет') { $this->isReplacement = false; // Remove the reply keyboard before creating shift - $bot->sendMessage('✅ Понятно, открываем смену...', reply_markup: static::removeKeyboard()); + $bot->sendMessage('✓ Открываем смену...', reply_markup: static::removeKeyboard()); $this->createShift($bot); } else { $bot->sendMessage( - '⚠️ Пожалуйста, выберите "Да" или "Нет"', - reply_markup: static::yesNoKeyboard('Да', 'Нет') + '⚠️ Выберите ответ', + reply_markup: static::yesNoKeyboard() ); $this->next('handleReplacementQuestion'); } @@ -219,7 +237,8 @@ public function handleReplacementQuestion(Nutgram $bot): void } /** - * Handle employee selection + * Handle employee selection. + * MD3: List selection with feedback. */ public function handleEmployeeSelection(Nutgram $bot): void { @@ -227,16 +246,16 @@ public function handleEmployeeSelection(Nutgram $bot): void $callbackData = $bot->callbackQuery()?->data; if (!$callbackData || !str_starts_with($callbackData, 'employee_')) { - $bot->sendMessage('⚠️ Ошибка выбора сотрудника. Попробуйте снова.'); + $bot->sendMessage('⚠️ Ошибка выбора. Попробуйте снова.'); $this->end(); return; } $this->replacedUserId = (int) str_replace('employee_', '', $callbackData); - $bot->answerCallbackQuery(); + $bot->answerCallbackQuery('✓'); $bot->sendMessage( - '✍️ Укажите причину замещения:', + '✍️ Укажите причину замены:', reply_markup: static::removeKeyboard() ); @@ -247,7 +266,8 @@ public function handleEmployeeSelection(Nutgram $bot): void } /** - * Handle replacement reason + * Handle replacement reason. + * MD3: Text input with validation. */ public function handleReplacementReason(Nutgram $bot): void { @@ -255,7 +275,7 @@ public function handleReplacementReason(Nutgram $bot): void $reason = $bot->message()?->text; if (!$reason || trim($reason) === '') { - $bot->sendMessage('⚠️ Пожалуйста, укажите причину замещения.'); + $bot->sendMessage('⚠️ Укажите причину замены.'); $this->next('handleReplacementReason'); return; } @@ -269,7 +289,8 @@ public function handleReplacementReason(Nutgram $bot): void } /** - * Create shift record using ShiftService + * Create shift record using ShiftService. + * MD3: Success card with comprehensive status display. */ private function createShift(Nutgram $bot): void { @@ -277,19 +298,18 @@ private function createShift(Nutgram $bot): void $user = $this->getAuthenticatedUser(); $shiftService = app(ShiftService::class); - // Create UploadedFile from the temporary photo path - if (!$this->photoPath || !file_exists($this->photoPath)) { - throw new \RuntimeException('Photo file not found'); + // Create UploadedFile from the temporary photo path if available + $uploadedFile = null; + if ($this->photoPath && file_exists($this->photoPath)) { + $uploadedFile = new UploadedFile( + $this->photoPath, + 'shift_opening_photo.jpg', + 'image/jpeg', + null, + true + ); } - $uploadedFile = new UploadedFile( - $this->photoPath, - 'shift_opening_photo.jpg', - 'image/jpeg', - null, - true - ); - // Get replacement user if needed $replacingUser = null; if ($this->isReplacement && $this->replacedUserId) { @@ -297,9 +317,7 @@ private function createShift(Nutgram $bot): void // Validate replacement user belongs to the same dealership if (!$shiftService->validateUserDealership($replacingUser, $user->dealership_id)) { - $bot->sendMessage( - '⚠️ Выбранный сотрудник не принадлежит вашему дилерскому центру.' - ); + $bot->sendMessage('⚠️ Сотрудник не из вашего салона.'); $this->end(); return; } @@ -314,29 +332,40 @@ private function createShift(Nutgram $bot): void ); // Clean up temporary file - if (file_exists($this->photoPath)) { + if ($this->photoPath && file_exists($this->photoPath)) { unlink($this->photoPath); } - // Send welcome message and tasks + // Build success message with MD3 card pattern $now = Carbon::now(); - $message = "✅ Смена открыта в " . $now->format('H:i d.m.Y') . "\n\n"; - $message .= "👋 Приветствие!\n\n"; + $lines = []; - if ($this->isReplacement) { - $message .= "📝 Вы заменяете: {$replacingUser->full_name}\n"; - $message .= "💬 Причина: {$this->replacementReason}\n\n"; - } + // Success header + $lines[] = '✅ *Смена открыта*'; + $lines[] = '🕐 ' . $now->format('H:i d.m.Y'); - // Add shift status information + // Late status warning if ($shift->status === 'late') { - $message .= "⚠️ Смена открыта с опозданием на {$shift->late_minutes} минут.\n\n"; + $lines[] = ''; + $lines[] = '⚠️ Опоздание: ' . $shift->late_minutes . ' ' . + $this->pluralizeRu($shift->late_minutes, 'минута', 'минуты', 'минут'); + } + + // Replacement info + if ($this->isReplacement && $replacingUser) { + $lines[] = ''; + $lines[] = '📝 Замена: ' . $replacingUser->full_name; + $lines[] = '💬 ' . $this->replacementReason; } - $message .= "🕐 Планируемое время: " . $shift->scheduled_start->format('H:i') . " - " . - $shift->scheduled_end->format('H:i') . "\n\n"; + // Schedule info + if ($shift->scheduled_start && $shift->scheduled_end) { + $lines[] = ''; + $lines[] = '📅 График: ' . $shift->scheduled_start->format('H:i') . ' – ' . + $shift->scheduled_end->format('H:i'); + } - $bot->sendMessage($message, reply_markup: static::employeeMenu()); + $bot->sendMessage(implode("\n", $lines), parse_mode: 'markdown', reply_markup: static::employeeMenu()); // Send pending tasks $this->sendPendingTasks($bot, $user); @@ -353,7 +382,8 @@ private function createShift(Nutgram $bot): void /** - * Send pending tasks to the employee + * Send pending tasks to the employee. + * MD3: List presentation with count summary. */ private function sendPendingTasks(Nutgram $bot, User $user): void { @@ -373,11 +403,13 @@ private function sendPendingTasks(Nutgram $bot, User $user): void ->get(); if ($tasks->isEmpty()) { - $bot->sendMessage('✅ У вас нет активных задач.'); + $bot->sendMessage('✅ Нет активных задач'); return; } - $bot->sendMessage("📋 У вас {$tasks->count()} активных задач:"); + $count = $tasks->count(); + $taskWord = $this->pluralizeRu($count, 'задача', 'задачи', 'задач'); + $bot->sendMessage("📋 *{$count} {$taskWord}*", parse_mode: 'markdown'); foreach ($tasks as $task) { $this->sendTaskNotification($bot, $task, $user); @@ -388,42 +420,38 @@ private function sendPendingTasks(Nutgram $bot, User $user): void } /** - * Send task notification + * Send task notification. + * MD3: Task card with action button. */ private function sendTaskNotification(Nutgram $bot, \App\Models\Task $task, User $user): void { - $message = "📌 *{$task->title}*\n\n"; + $lines = []; + + // Title + $lines[] = "📌 *{$task->title}*"; + // Description if ($task->description) { - $message .= "{$task->description}\n\n"; + $lines[] = ''; + $lines[] = $task->description; } + // Comment if ($task->comment) { - $message .= "💬 Комментарий: {$task->comment}\n\n"; + $lines[] = ''; + $lines[] = "💬 {$task->comment}"; } + // Deadline if ($task->deadline) { - $message .= "⏰ Дедлайн: " . $task->deadline_for_bot . "\n"; + $lines[] = ''; + $lines[] = "⏰ Дедлайн: {$task->deadline_for_bot}"; } - // Create response keyboard based on response_type - $keyboard = match ($task->response_type) { - 'acknowledge' => \SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardMarkup::make() - ->addRow(\SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardButton::make( - text: '✅ OK', - callback_data: 'task_ok_' . $task->id - )), - 'complete' => \SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardMarkup::make() - ->addRow( - \SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardButton::make( - text: '✅ Выполнено', - callback_data: 'task_done_' . $task->id - ) - ), - default => null, - }; - - $bot->sendMessage($message, parse_mode: 'Markdown', reply_markup: $keyboard); + // Get keyboard using trait method + $keyboard = static::getTaskKeyboard($task->response_type, $task->id); + + $bot->sendMessage(implode("\n", $lines), parse_mode: 'Markdown', reply_markup: $keyboard); } /** diff --git a/app/Bot/Conversations/Guest/StartConversation.php b/app/Bot/Conversations/Guest/StartConversation.php index 1ba5d4a..da8ee80 100644 --- a/app/Bot/Conversations/Guest/StartConversation.php +++ b/app/Bot/Conversations/Guest/StartConversation.php @@ -7,25 +7,41 @@ use App\Bot\Abstracts\BaseConversation; use App\Enums\Role; use App\Models\User; +use App\Traits\MaterialDesign3Trait; use SergiX44\Nutgram\Nutgram; use Illuminate\Support\Facades\Log; /** * Conversation for user authentication via phone number. * Users must be pre-registered through API endpoints. + * + * Implements Material Design 3 principles: + * - Clear visual hierarchy in messages + * - Semantic iconography for actions + * - Personalized greeting patterns */ - class StartConversation extends BaseConversation { + use MaterialDesign3Trait; + protected ?string $step = 'askContact'; /** * Ask for user contact for authentication. + * MD3: Form input pattern with clear instructions. */ public function askContact(Nutgram $bot) { + $message = implode("\n", [ + '🔐 *Вход в систему*', + '', + 'Для входа поделитесь номером телефона.', + '', + 'ℹ️ Аккаунт должен быть создан администратором.', + ]); + $bot->sendMessage( - text: '🔐 *Вход в систему*\\n\\nДля входа пожалуйста, поделитесь своим номером телефона:\\n\\nℹ️ *Важно:* Ваш аккаунт должен быть предварительно создан администратором.', + text: $message, reply_markup: static::contactRequestKeyboard(), parse_mode: 'markdown' ); @@ -35,6 +51,7 @@ public function askContact(Nutgram $bot) /** * Process contact and authenticate user. + * MD3: Form validation with clear feedback. */ public function getContact(Nutgram $bot) { @@ -43,7 +60,7 @@ public function getContact(Nutgram $bot) if (!$contact?->phone_number) { $bot->sendMessage( - '❌ Не удалось получить номер телефона. Пожалуйста, попробуйте ещё раз.', + '❌ Номер не получен. Попробуйте ещё раз.', reply_markup: static::contactRequestKeyboard() ); $this->next('getContact'); @@ -54,7 +71,7 @@ public function getContact(Nutgram $bot) if (!$telegramUserId) { Log::error('Не удалось получить Telegram ID для пользователя'); $bot->sendMessage( - '❌ Произошла ошибка. Пожалуйста, попробуйте ещё раз.', + '❌ Ошибка авторизации. Попробуйте ещё раз.', reply_markup: static::removeKeyboard() ); $this->end(); @@ -67,7 +84,7 @@ public function getContact(Nutgram $bot) if (!$this->isValidPhoneNumber($normalizedPhone)) { $bot->sendMessage( - '❌ Неверный формат номера телефона. Пожалуйста, используйте корректный номер.', + '❌ Неверный формат номера. Используйте корректный номер.', reply_markup: static::contactRequestKeyboard() ); $this->next('getContact'); @@ -91,8 +108,17 @@ public function getContact(Nutgram $bot) 'new_phone' => $phoneNumber ]); + $message = implode("\n", [ + '⚠️ *Аккаунт привязан*', + '', + 'Этот Telegram привязан к номеру:', + $existingTelegramUser->phone, + '', + 'Свяжитесь с администратором.', + ]); + $bot->sendMessage( - '⚠️ Этот Telegram аккаунт уже привязан к другому номеру телефона (' . $existingTelegramUser->phone . ').\\n\\nПожалуйста, свяжитесь с администратором для решения этой проблемы.', + $message, reply_markup: static::removeKeyboard(), parse_mode: 'markdown' ); @@ -114,8 +140,18 @@ public function getContact(Nutgram $bot) 'phone' => $phoneNumber ]); + $message = implode("\n", [ + '❌ *Аккаунт не найден*', + '', + 'Номер не зарегистрирован в системе.', + '', + '📞 Свяжитесь с администратором', + '• Предоставьте номер телефона', + '• Войдите после создания аккаунта', + ]); + $bot->sendMessage( - '❌ *Аккаунт не найден*\\n\\nВаш номер телефона не найден в нашей системе.\\n\\n📞 *Свяжитесь с администратором* для создания учетной записи:\\n• Предоставьте свой номер телефона\\n• После создания аккаунта попробуйте войти снова', + $message, reply_markup: static::removeKeyboard(), parse_mode: 'markdown' ); @@ -132,8 +168,16 @@ public function getContact(Nutgram $bot) 'new_telegram_id' => $telegramUserId ]); + $message = implode("\n", [ + '⚠️ *Номер уже привязан*', + '', + 'Этот номер используется в другом Telegram.', + '', + 'Свяжитесь с администратором.', + ]); + $bot->sendMessage( - '⚠️ Этот номер телефона уже привязан к другому Telegram аккаунту.\\n\\nПожалуйста, свяжитесь с администратором для решения этой проблемы.', + $message, reply_markup: static::removeKeyboard(), parse_mode: 'markdown' ); @@ -158,6 +202,7 @@ public function getContact(Nutgram $bot) /** * Handle successful user login. + * MD3: Success feedback with personalized greeting. */ private function handleSuccessfulLogin(Nutgram $bot, User $user): void { @@ -184,18 +229,27 @@ private function handleSuccessfulLogin(Nutgram $bot, User $user): void /** * Generate personalized welcome message. + * MD3: Expressive personalization with time-based greetings. */ private function generateWelcomeMessage(User $user, string $roleLabel): string { - $greeting = match(date('H')) { - 0, 1, 2, 3, 4, 5 => '🌙 Доброй ночи', - 6, 7, 8, 9, 10, 11 => '☀️ Доброе утро', - 12, 13, 14, 15, 16, 17 => '🌤️ Добрый день', - 18, 19, 20, 21 => '🌆 Добрый вечер', - default => '👋 Добро пожаловать' + $hour = (int) date('H'); + + // MD3 time-based greeting with expressive icons + $greeting = match (true) { + $hour >= 5 && $hour < 12 => ['🌅', 'Доброе утро'], + $hour >= 12 && $hour < 17 => ['☀️', 'Добрый день'], + $hour >= 17 && $hour < 22 => ['🌆', 'Добрый вечер'], + default => ['🌙', 'Доброй ночи'], }; - return "{$greeting}, {$roleLabel} *{$user->full_name}*!\\n\\n✅ Вы успешно вошли в систему.\\n\\nВыберите действие в меню ниже:"; + return implode("\n", [ + "{$greeting[0]} {$greeting[1]}, *{$user->full_name}*!", + '', + "✅ Вход выполнен · {$roleLabel}", + '', + 'Выберите действие:', + ]); } /** diff --git a/app/Bot/Handlers/TaskResponseHandler.php b/app/Bot/Handlers/TaskResponseHandler.php index 3844d98..3d600be 100644 --- a/app/Bot/Handlers/TaskResponseHandler.php +++ b/app/Bot/Handlers/TaskResponseHandler.php @@ -12,12 +12,18 @@ use SergiX44\Nutgram\Nutgram; /** - * Handler for task response callbacks (OK, Done) + * Handler for task response callbacks (OK, Done). + * + * MD3 Feedback patterns: + * - Immediate visual feedback via toast (answerCallbackQuery) + * - Button removal on success (clear completed action) + * - Error state with helpful guidance */ class TaskResponseHandler { /** - * Handle task OK response (for notification-type tasks) + * Handle task OK response (for notification-type tasks). + * MD3: Acknowledge action with immediate feedback. */ public static function handleOk(Nutgram $bot): void { @@ -28,7 +34,7 @@ public static function handleOk(Nutgram $bot): void $user = auth()->user(); if (!$user) { Log::warning('Task OK callback: User not authenticated', ['callback_data' => $callbackData]); - $bot->answerCallbackQuery('⚠️ Ошибка аутентификации. Пожалуйста, войдите снова через /start', show_alert: true); + $bot->answerCallbackQuery('⚠️ Войдите через /start', show_alert: true); return; } @@ -42,7 +48,7 @@ public static function handleOk(Nutgram $bot): void // Check if task is active if (!$task->is_active) { Log::info('Task OK callback: Task is not active', ['task_id' => $taskId, 'user_id' => $user->id]); - $bot->answerCallbackQuery('⚠️ Эта задача больше не активна', show_alert: true); + $bot->answerCallbackQuery('⚠️ Задача неактивна', show_alert: true); return; } @@ -54,7 +60,7 @@ public static function handleOk(Nutgram $bot): void 'user_id' => $user->id, 'task_title' => $task->title ]); - $bot->answerCallbackQuery('⚠️ Вы не назначены на эту задачу', show_alert: true); + $bot->answerCallbackQuery('⚠️ Вы не назначены', show_alert: true); return; } @@ -64,7 +70,7 @@ public static function handleOk(Nutgram $bot): void ->first(); if ($existingResponse && $existingResponse->status === 'acknowledged') { - $bot->answerCallbackQuery('ℹ️ Вы уже ответили на эту задачу'); + $bot->answerCallbackQuery('ℹ️ Уже отмечено'); return; } @@ -80,7 +86,8 @@ public static function handleOk(Nutgram $bot): void ] ); - $bot->answerCallbackQuery('✅ Принято'); + // MD3: Clear success feedback + $bot->answerCallbackQuery('✓ Принято'); $bot->editMessageReplyMarkup( chat_id: $bot->chatId(), message_id: $bot->messageId(), @@ -99,12 +106,13 @@ public static function handleOk(Nutgram $bot): void 'user_id' => auth()->user()?->id, 'callback_data' => $bot->callbackQuery()?->data ]); - $bot->answerCallbackQuery('⚠️ Произошла ошибка при обработке ответа', show_alert: true); + $bot->answerCallbackQuery('⚠️ Ошибка. Попробуйте ещё раз', show_alert: true); } } /** - * Handle task done response + * Handle task done response. + * MD3: Completion action with success feedback. */ public static function handleDone(Nutgram $bot): void { @@ -115,7 +123,7 @@ public static function handleDone(Nutgram $bot): void $user = auth()->user(); if (!$user) { Log::warning('Task Done callback: User not authenticated', ['callback_data' => $callbackData]); - $bot->answerCallbackQuery('⚠️ Ошибка аутентификации. Пожалуйста, войдите снова через /start', show_alert: true); + $bot->answerCallbackQuery('⚠️ Войдите через /start', show_alert: true); return; } @@ -129,7 +137,7 @@ public static function handleDone(Nutgram $bot): void // Check if task is active if (!$task->is_active) { Log::info('Task Done callback: Task is not active', ['task_id' => $taskId, 'user_id' => $user->id]); - $bot->answerCallbackQuery('⚠️ Эта задача больше не активна', show_alert: true); + $bot->answerCallbackQuery('⚠️ Задача неактивна', show_alert: true); return; } @@ -141,7 +149,7 @@ public static function handleDone(Nutgram $bot): void 'user_id' => $user->id, 'task_title' => $task->title ]); - $bot->answerCallbackQuery('⚠️ Вы не назначены на эту задачу', show_alert: true); + $bot->answerCallbackQuery('⚠️ Вы не назначены', show_alert: true); return; } @@ -152,7 +160,7 @@ public static function handleDone(Nutgram $bot): void ->exists(); if ($alreadyCompleted) { - $bot->answerCallbackQuery('ℹ️ Эта задача уже была выполнена другим сотрудником'); + $bot->answerCallbackQuery('ℹ️ Уже выполнено другим'); $bot->editMessageReplyMarkup( chat_id: $bot->chatId(), message_id: $bot->messageId(), @@ -168,7 +176,7 @@ public static function handleDone(Nutgram $bot): void ->first(); if ($existingResponse && $existingResponse->status === 'completed') { - $bot->answerCallbackQuery('ℹ️ Вы уже отметили эту задачу как выполненную'); + $bot->answerCallbackQuery('ℹ️ Уже отмечено'); return; } @@ -184,7 +192,8 @@ public static function handleDone(Nutgram $bot): void ] ); - $bot->answerCallbackQuery('✅ Задача отмечена как выполненная'); + // MD3: Success feedback + $bot->answerCallbackQuery('✓ Выполнено'); $bot->editMessageReplyMarkup( chat_id: $bot->chatId(), message_id: $bot->messageId(), @@ -205,7 +214,7 @@ public static function handleDone(Nutgram $bot): void 'user_id' => auth()->user()?->id, 'callback_data' => $bot->callbackQuery()?->data ]); - $bot->answerCallbackQuery('⚠️ Произошла ошибка при обработке ответа', show_alert: true); + $bot->answerCallbackQuery('⚠️ Ошибка. Попробуйте ещё раз', show_alert: true); } } } diff --git a/app/Services/TaskNotificationService.php b/app/Services/TaskNotificationService.php index 9273208..7276b63 100644 --- a/app/Services/TaskNotificationService.php +++ b/app/Services/TaskNotificationService.php @@ -8,15 +8,28 @@ use App\Models\Task; use App\Models\TaskNotification; use App\Models\User; +use App\Traits\KeyboardTrait; +use App\Traits\MaterialDesign3Trait; use Carbon\Carbon; use Illuminate\Support\Facades\Log; use SergiX44\Nutgram\Nutgram; /** - * Service for sending task notifications to employees + * Service for sending task notifications to employees. + * + * Implements Material Design 3 principles for message formatting: + * - Clear visual hierarchy using MD3 typography patterns + * - Semantic icon usage for quick visual scanning + * - Consistent spacing and section organization + * - Accessible color semantics (success, warning, error states) + * + * @see https://m3.material.io/ */ class TaskNotificationService { + use KeyboardTrait; + use MaterialDesign3Trait; + public function __construct( private Nutgram $bot ) { @@ -59,7 +72,7 @@ public function sendTaskToUser(Task $task, User $user): bool } $message = $this->formatTaskMessage($task, 'regular'); - $keyboard = $this->getTaskKeyboard($task); + $keyboard = $this->buildTaskKeyboard($task); $this->bot->sendMessage( text: $message, @@ -130,7 +143,7 @@ public function sendUpcomingDeadlineNotification(Task $task, User $user, int $of } $message = $this->formatUpcomingDeadlineMessage($task, $offset); - $keyboard = $this->getTaskKeyboard($task); + $keyboard = $this->buildTaskKeyboard($task); $this->bot->sendMessage( text: $message, @@ -172,7 +185,7 @@ public function sendOverdueNotification(Task $task, User $user): bool } $message = $this->formatOverdueMessage($task); - $keyboard = $this->getTaskKeyboard($task); + $keyboard = $this->buildTaskKeyboard($task); $this->bot->sendMessage( text: $message, @@ -214,7 +227,7 @@ public function sendHourOverdueNotification(Task $task, User $user, int $offset } $message = $this->formatHourOverdueMessage($task, $offset); - $keyboard = $this->getTaskKeyboard($task); + $keyboard = $this->buildTaskKeyboard($task); $this->bot->sendMessage( text: $message, @@ -506,113 +519,209 @@ private function getNotificationOffset(Task $task, string $channelType): ?int return null; } - // ... existing format methods ... + // ═══════════════════════════════════════════════════════════════════ + // MESSAGE FORMATTING (MD3 Card Patterns) + // ═══════════════════════════════════════════════════════════════════ + /** + * Format a regular task notification message. + * MD3: Card pattern with clear hierarchy - title, body, metadata. + */ private function formatTaskMessage(Task $task, string $type = 'regular'): string { - $message = "📌 *{$task->title}*\n\n"; + $lines = []; + + // Header: Pin icon + title (MD3 headline) + $lines[] = "📌 *{$task->title}*"; + // Body: Description (MD3 body text) if ($task->description) { - $message .= "{$task->description}\n\n"; + $lines[] = ''; + $lines[] = $task->description; } + // Supporting text: Comment if ($task->comment) { - $message .= "💬 Комментарий: {$task->comment}\n\n"; + $lines[] = ''; + $lines[] = "💬 {$task->comment}"; } + // Metadata section (MD3 supporting text) + $metadata = []; + if ($task->deadline) { - $message .= "⏰ Дедлайн: " . $task->deadline_for_bot . "\n"; + $metadata[] = "⏰ Дедлайн: {$task->deadline_for_bot}"; } if ($task->tags && is_array($task->tags) && !empty($task->tags)) { - $message .= "🏷️ Теги: " . implode(', ', $task->tags) . "\n"; + $metadata[] = "🏷️ " . implode(' · ', $task->tags); + } + + if (!empty($metadata)) { + $lines[] = ''; + $lines = array_merge($lines, $metadata); } - return $message; + return implode("\n", $lines); } + /** + * Format upcoming deadline reminder message. + * MD3: Alert card pattern with urgency emphasis. + */ private function formatUpcomingDeadlineMessage(Task $task, int $offset = 30): string { - $message = "⏰ *НАПОМИНАНИЕ О ДЕДЛАЙНЕ*\n\n📌 *{$task->title}*\n\n"; + $lines = []; + + // Alert header (MD3 elevated card header) + $lines[] = "⏰ *НАПОМИНАНИЕ*"; + $lines[] = ''; + // Task title + $lines[] = "📌 *{$task->title}*"; + + // Description if ($task->description) { - $message .= "{$task->description}\n\n"; + $lines[] = ''; + $lines[] = $task->description; } + // Comment if ($task->comment) { - $message .= "💬 Комментарий: {$task->comment}\n\n"; + $lines[] = ''; + $lines[] = "💬 {$task->comment}"; } - $message .= "🚨 Дедлайн через {$offset} минут!\n"; - $message .= "⏰ Время дедлайна: " . $task->deadline_for_bot . "\n"; + // Urgency section (MD3 warning state) + $lines[] = ''; + $timeText = $this->formatTimeOffset($offset); + $lines[] = "🚨 Дедлайн {$timeText}!"; + $lines[] = "⏰ {$task->deadline_for_bot}"; + // Tags if ($task->tags && is_array($task->tags) && !empty($task->tags)) { - $message .= "🏷️ Теги: " . implode(', ', $task->tags) . "\n"; + $lines[] = ''; + $lines[] = "🏷️ " . implode(' · ', $task->tags); } - return $message; + return implode("\n", $lines); } + /** + * Format overdue notification message. + * MD3: Error card pattern with critical emphasis. + */ private function formatOverdueMessage(Task $task): string { - $message = "⚠️ *СРОК ВЫПОЛНЕНИЯ ИСТЁК*\n\n📌 *{$task->title}*\n\n"; + $lines = []; + + // Critical alert header (MD3 error state) + $lines[] = "⚠️ *СРОК ИСТЁК*"; + $lines[] = ''; + + // Task title + $lines[] = "📌 *{$task->title}*"; + // Description if ($task->description) { - $message .= "{$task->description}\n\n"; + $lines[] = ''; + $lines[] = $task->description; } + // Comment if ($task->comment) { - $message .= "💬 Комментарий: {$task->comment}\n\n"; + $lines[] = ''; + $lines[] = "💬 {$task->comment}"; } - $message .= "🚨 Дедлайн был: " . $task->deadline_for_bot . "\n"; - $message .= "⏱️ Просрочено на: " . $this->getOverdueTime($task->deadline) . "\n"; + // Overdue details + $lines[] = ''; + $lines[] = "🚨 Дедлайн был: {$task->deadline_for_bot}"; + $lines[] = "⏱️ Просрочено: {$this->getOverdueTime($task->deadline)}"; + // Tags if ($task->tags && is_array($task->tags) && !empty($task->tags)) { - $message .= "🏷️ Теги: " . implode(', ', $task->tags) . "\n"; + $lines[] = ''; + $lines[] = "🏷️ " . implode(' · ', $task->tags); } - return $message; + return implode("\n", $lines); } + /** + * Format hour overdue notification message. + * MD3: Critical alert card with maximum emphasis. + */ private function formatHourOverdueMessage(Task $task, int $offset = 60): string { - $message = "🚨 *ЗАДАЧА ПРОСРОЧЕНА НА " . ($offset >= 60 ? round($offset/60, 1) . " ЧАС(А)" : "{$offset} МИНУТ") . "*\n\n📌 *{$task->title}*\n\n"; + $lines = []; + + // Critical header with time indicator + $overdueText = $offset >= 60 + ? round($offset / 60, 1) . ' ' . $this->pluralize((int) round($offset / 60), 'час', 'часа', 'часов') + : $offset . ' ' . $this->pluralize($offset, 'минута', 'минуты', 'минут'); + + $lines[] = "🚨 *ПРОСРОЧЕНО: {$overdueText}*"; + $lines[] = ''; + // Task title + $lines[] = "📌 *{$task->title}*"; + + // Description if ($task->description) { - $message .= "{$task->description}\n\n"; + $lines[] = ''; + $lines[] = $task->description; } + // Comment if ($task->comment) { - $message .= "💬 Комментарий: {$task->comment}\n\n"; + $lines[] = ''; + $lines[] = "💬 {$task->comment}"; } - $message .= "🚨 Дедлайн был: " . $task->deadline_for_bot . "\n"; - $message .= "⏱️ Просрочено на: " . $this->getOverdueTime($task->deadline) . "\n"; - $message .= "❗️ Срочно выполните задачу!\n"; + // Overdue details with call to action + $lines[] = ''; + $lines[] = "⏰ Дедлайн был: {$task->deadline_for_bot}"; + $lines[] = "⏱️ Просрочено: {$this->getOverdueTime($task->deadline)}"; + $lines[] = ''; + $lines[] = "❗ Требуется немедленное выполнение"; + // Tags if ($task->tags && is_array($task->tags) && !empty($task->tags)) { - $message .= "🏷️ Теги: " . implode(', ', $task->tags) . "\n"; + $lines[] = ''; + $lines[] = "🏷️ " . implode(' · ', $task->tags); } - return $message; + return implode("\n", $lines); + } + + /** + * Format time offset for display. + * MD3: Relative time formatting. + */ + private function formatTimeOffset(int $minutes): string + { + if ($minutes >= 60) { + $hours = floor($minutes / 60); + $mins = $minutes % 60; + $text = "через {$hours} " . $this->pluralize((int) $hours, 'час', 'часа', 'часов'); + if ($mins > 0) { + $text .= " {$mins} " . $this->pluralize($mins, 'минуту', 'минуты', 'минут'); + } + return $text; + } + return "через {$minutes} " . $this->pluralize($minutes, 'минуту', 'минуты', 'минут'); } - private function getTaskKeyboard(Task $task): ?\SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardMarkup + /** + * Get task response keyboard. + * MD3: Action buttons following button guidelines. + */ + private function buildTaskKeyboard(Task $task): ?\SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardMarkup { return match ($task->response_type) { - 'acknowledge' => \SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardMarkup::make() - ->addRow(\SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardButton::make( - text: '✅ OK', - callback_data: 'task_ok_' . $task->id - )), - 'complete' => \SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardMarkup::make() - ->addRow( - \SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardButton::make( - text: '✅ Выполнено', - callback_data: 'task_done_' . $task->id - ) - ), + 'acknowledge' => static::taskAcknowledgeButton($task->id), + 'complete' => static::taskCompleteButton($task->id), default => null, }; } diff --git a/app/Traits/KeyboardTrait.php b/app/Traits/KeyboardTrait.php index e8ee0b0..eb63f1a 100644 --- a/app/Traits/KeyboardTrait.php +++ b/app/Traits/KeyboardTrait.php @@ -10,10 +10,27 @@ use SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardMarkup; use SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardButton; +/** + * Keyboard layouts following Material Design 3 principles. + * + * MD3 Button Guidelines Applied: + * - Primary actions use filled style with leading icons (high emphasis) + * - Secondary actions use outlined/tonal style (medium emphasis) + * - Tertiary actions use text style (low emphasis) + * - Clear visual hierarchy between action types + * - Consistent iconography and spacing + * + * @see https://m3.material.io/components/buttons + */ trait KeyboardTrait { + // ═══════════════════════════════════════════════════════════════════ + // ROLE-BASED NAVIGATION MENUS (MD3 Navigation Rail pattern) + // ═══════════════════════════════════════════════════════════════════ + /** - * Клавиатура сотрудника (Employee) + * Employee navigation menu. + * MD3: Bottom navigation pattern with primary actions. */ public static function employeeMenu(): ReplyKeyboardMarkup { @@ -22,11 +39,11 @@ public static function employeeMenu(): ReplyKeyboardMarkup KeyboardButton::make('🔓 Открыть смену'), KeyboardButton::make('🔒 Закрыть смену') ); - // TODO: Add task response buttons when implemented } /** - * Клавиатура управляющего (Manager) + * Manager navigation menu. + * MD3: Navigation with overview actions. */ public static function managerMenu(): ReplyKeyboardMarkup { @@ -35,23 +52,24 @@ public static function managerMenu(): ReplyKeyboardMarkup KeyboardButton::make('📊 Смены'), KeyboardButton::make('📋 Задачи') ); - // TODO: Add more manager functions via web admin panel } /** - * Клавиатура наблюдателя (Observer) + * Observer navigation menu. + * MD3: Read-only navigation pattern. */ public static function observerMenu(): ReplyKeyboardMarkup { return ReplyKeyboardMarkup::make(resize_keyboard: true) ->addRow( - KeyboardButton::make('👀 Просмотр смен'), + KeyboardButton::make('📊 Просмотр смен'), KeyboardButton::make('📋 Просмотр задач') ); } /** - * Клавиатура владельца (Owner) + * Owner navigation menu. + * MD3: Full navigation with all sections. */ public static function ownerMenu(): ReplyKeyboardMarkup { @@ -64,32 +82,42 @@ public static function ownerMenu(): ReplyKeyboardMarkup KeyboardButton::make('📊 Смены'), KeyboardButton::make('📋 Задачи') ); - // TODO: Full access to all features } + // ═══════════════════════════════════════════════════════════════════ + // CONTACT & AUTHENTICATION (MD3 Form Inputs) + // ═══════════════════════════════════════════════════════════════════ + /** - * Кнопка запроса контакта (на одну кнопку) — удобно если нужен только контакт + * Contact request keyboard for authentication. + * MD3: Filled button style for primary CTA. */ - public static function contactRequestKeyboard(string $label = 'Отправить номер'): ReplyKeyboardMarkup + public static function contactRequestKeyboard(string $label = '📱 Поделиться номером'): ReplyKeyboardMarkup { return ReplyKeyboardMarkup::make(resize_keyboard: true, one_time_keyboard: true) ->addRow(KeyboardButton::make($label, request_contact: true)); } + // ═══════════════════════════════════════════════════════════════════ + // CONFIRMATION DIALOGS (MD3 Dialog patterns) + // ═══════════════════════════════════════════════════════════════════ + /** - * Inline клавиатура: Подтвердить выдачу (callback_data задаются) + * Single confirm action button. + * MD3: Filled button for single primary action. */ public static function inlineConfirmIssued( string $confirmData = 'confirm', ): InlineKeyboardMarkup { return InlineKeyboardMarkup::make() ->addRow( - InlineKeyboardButton::make(text: '✅ Выдано', callback_data: $confirmData), + InlineKeyboardButton::make(text: '✓ Выдано', callback_data: $confirmData), ); } /** - * Inline клавиатура: Подтвердить выдачу полной суммы / Выдать иную сумму + * Amount confirmation with options. + * MD3: Primary action first, secondary action second. */ public static function inlineConfirmIssuedWithAmount( string $confirmFullData = 'confirm_full', @@ -97,15 +125,16 @@ public static function inlineConfirmIssuedWithAmount( ): InlineKeyboardMarkup { return InlineKeyboardMarkup::make() ->addRow( - InlineKeyboardButton::make(text: '✅ Выдать полную сумму', callback_data: $confirmFullData), + InlineKeyboardButton::make(text: '✓ Полная сумма', callback_data: $confirmFullData), ) ->addRow( - InlineKeyboardButton::make(text: '💰 Выдать иную сумму', callback_data: $confirmDifferentData), + InlineKeyboardButton::make(text: '💰 Другая сумма', callback_data: $confirmDifferentData), ); } /** - * Inline клавиатура: Подтвердить / Отменить (callback_data задаются) + * Confirm/Decline dialog. + * MD3: Alert dialog pattern - confirm left, cancel right. */ public static function inlineConfirmDecline( string $confirmData = 'confirm', @@ -113,13 +142,14 @@ public static function inlineConfirmDecline( ): InlineKeyboardMarkup { return InlineKeyboardMarkup::make() ->addRow( - InlineKeyboardButton::make(text: '✅ Подтвердить', callback_data: $confirmData), - InlineKeyboardButton::make(text: '❌ Отменить', callback_data: $declineData), + InlineKeyboardButton::make(text: '✓ Подтвердить', callback_data: $confirmData), + InlineKeyboardButton::make(text: 'Отмена', callback_data: $declineData), ); } /** - * Inline клавиатура: Подтвердить / Подтвердить с комментом / Отменить (callback_data задаются) + * Confirm/Comment/Decline dialog with three actions. + * MD3: Multi-action dialog pattern. */ public static function inlineConfirmCommentDecline( string $confirmData = 'confirm', @@ -128,19 +158,91 @@ public static function inlineConfirmCommentDecline( ): InlineKeyboardMarkup { return InlineKeyboardMarkup::make() ->addRow( - InlineKeyboardButton::make(text: '✅ Подтвердить', callback_data: $confirmData), - InlineKeyboardButton::make(text: '❌ Отменить', callback_data: $declineData), + InlineKeyboardButton::make(text: '✓ Подтвердить', callback_data: $confirmData), + InlineKeyboardButton::make(text: 'Отмена', callback_data: $declineData), ) ->addRow( InlineKeyboardButton::make( - text: '💬 Подтвердить с комментарием', + text: '💬 С комментарием', callback_data: $confirmWithCommentData ), ); } + // ═══════════════════════════════════════════════════════════════════ + // TASK RESPONSE BUTTONS (MD3 Action Chips) + // ═══════════════════════════════════════════════════════════════════ + /** - * ReplyKeyboardRemove — убрать reply keyboard + * Task acknowledgment button (OK response type). + * MD3: Filled tonal button for acknowledge action. + */ + public static function taskAcknowledgeButton(int $taskId): InlineKeyboardMarkup + { + return InlineKeyboardMarkup::make() + ->addRow( + InlineKeyboardButton::make( + text: '✓ Принято', + callback_data: 'task_ok_' . $taskId + ) + ); + } + + /** + * Task completion button (complete response type). + * MD3: Filled button for primary completion action. + */ + public static function taskCompleteButton(int $taskId): InlineKeyboardMarkup + { + return InlineKeyboardMarkup::make() + ->addRow( + InlineKeyboardButton::make( + text: '✓ Выполнено', + callback_data: 'task_done_' . $taskId + ) + ); + } + + /** + * Get task response keyboard based on response type. + * MD3: Contextual action buttons. + */ + public static function getTaskKeyboard(string $responseType, int $taskId): ?InlineKeyboardMarkup + { + return match ($responseType) { + 'acknowledge' => static::taskAcknowledgeButton($taskId), + 'complete' => static::taskCompleteButton($taskId), + default => null, + }; + } + + // ═══════════════════════════════════════════════════════════════════ + // PHOTO UPLOAD FLOW (MD3 Step-by-step dialogs) + // ═══════════════════════════════════════════════════════════════════ + + /** + * Photo upload dialog with skip and cancel options. + * MD3: Dialog with primary, secondary, and tertiary actions. + */ + public static function photoUploadKeyboard( + string $skipData = 'skip_photo', + string $cancelData = 'cancel' + ): InlineKeyboardMarkup { + return InlineKeyboardMarkup::make() + ->addRow( + InlineKeyboardButton::make(text: '⏭️ Пропустить', callback_data: $skipData), + ) + ->addRow( + InlineKeyboardButton::make(text: 'Отмена', callback_data: $cancelData), + ); + } + + // ═══════════════════════════════════════════════════════════════════ + // UTILITY KEYBOARDS + // ═══════════════════════════════════════════════════════════════════ + + /** + * Remove reply keyboard. */ public static function removeKeyboard(): ReplyKeyboardRemove { @@ -148,11 +250,8 @@ public static function removeKeyboard(): ReplyKeyboardRemove } /** - * Сгенерировать inline-клавиатуру из массива: - * $buttons = [ - * [ ['text'=>'A','data'=>'a'], ['text'=>'B','data'=>'b'] ], - * [ ['text'=>'C','data'=>'c'] ] - * ]; + * Generate inline keyboard from array structure. + * MD3: Dynamic keyboard generation following button guidelines. */ public static function inlineFromArray(array $buttons): InlineKeyboardMarkup { @@ -168,9 +267,10 @@ public static function inlineFromArray(array $buttons): InlineKeyboardMarkup } /** - * Быстрая reply клавиатура с Yes/No (удобно для простых вопросов) + * Yes/No quick response keyboard. + * MD3: Binary choice dialog pattern. */ - public static function yesNoKeyboard(string $yes = 'Да', string $no = 'Нет'): ReplyKeyboardMarkup + public static function yesNoKeyboard(string $yes = '✓ Да', string $no = 'Нет'): ReplyKeyboardMarkup { return ReplyKeyboardMarkup::make(resize_keyboard: true, one_time_keyboard: true) ->addRow( @@ -180,11 +280,82 @@ public static function yesNoKeyboard(string $yes = 'Да', string $no = 'Нет' } /** - * Inline keyboard with cancel button + * Single cancel button. + * MD3: Text button for dismissive action. */ - public static function cancelKeyboard(string $text = '❌ Отменить', string $data = 'cancel'): InlineKeyboardMarkup + public static function cancelKeyboard(string $text = 'Отмена', string $data = 'cancel'): InlineKeyboardMarkup { return InlineKeyboardMarkup::make() ->addRow(InlineKeyboardButton::make(text: $text, callback_data: $data)); } + + // ═══════════════════════════════════════════════════════════════════ + // EMPLOYEE SELECTION (MD3 Selection List) + // ═══════════════════════════════════════════════════════════════════ + + /** + * Generate employee selection keyboard. + * MD3: List selection pattern with consistent styling. + */ + public static function employeeSelectionKeyboard(array $employees): InlineKeyboardMarkup + { + $keyboard = InlineKeyboardMarkup::make(); + + foreach ($employees as $employee) { + $keyboard->addRow( + InlineKeyboardButton::make( + text: '👤 ' . $employee['name'], + callback_data: 'employee_' . $employee['id'] + ) + ); + } + + return $keyboard; + } + + // ═══════════════════════════════════════════════════════════════════ + // PAGINATION (MD3 Navigation Pattern) + // ═══════════════════════════════════════════════════════════════════ + + /** + * Generate pagination keyboard. + * MD3: Pagination with clear navigation controls. + */ + public static function paginationKeyboard( + int $currentPage, + int $totalPages, + string $prefix = 'page' + ): ?InlineKeyboardMarkup { + if ($totalPages <= 1) { + return null; + } + + $keyboard = InlineKeyboardMarkup::make(); + $buttons = []; + + // Previous button + if ($currentPage > 1) { + $buttons[] = InlineKeyboardButton::make( + text: '◀️', + callback_data: "{$prefix}_" . ($currentPage - 1) + ); + } + + // Page indicator + $buttons[] = InlineKeyboardButton::make( + text: "{$currentPage}/{$totalPages}", + callback_data: 'noop' + ); + + // Next button + if ($currentPage < $totalPages) { + $buttons[] = InlineKeyboardButton::make( + text: '▶️', + callback_data: "{$prefix}_" . ($currentPage + 1) + ); + } + + $keyboard->row($buttons); + return $keyboard; + } } diff --git a/app/Traits/MaterialDesign3Trait.php b/app/Traits/MaterialDesign3Trait.php new file mode 100644 index 0000000..05f4fe1 --- /dev/null +++ b/app/Traits/MaterialDesign3Trait.php @@ -0,0 +1,693 @@ + $metadata Key-value pairs for metadata + */ + protected static function md3Card( + string $icon, + string $title, + ?string $subtitle = null, + ?string $body = null, + array $metadata = [] + ): string { + $lines = []; + + // Header with icon and title + $lines[] = "{$icon} *{$title}*"; + + // Optional subtitle + if ($subtitle) { + $lines[] = $subtitle; + } + + // Body content with proper spacing + if ($body) { + $lines[] = ''; + $lines[] = $body; + } + + // Metadata section + if (!empty($metadata)) { + $lines[] = ''; + foreach ($metadata as $key => $value) { + $lines[] = "{$key}: {$value}"; + } + } + + return implode("\n", $lines); + } + + /** + * Format an alert/notification card with emphasis. + * MD3: Elevated card pattern for important messages. + */ + protected static function md3AlertCard( + string $icon, + string $title, + string $message, + string $level = 'info' + ): string { + $lines = []; + + // Alert header with emphasis + $lines[] = "{$icon} *{$title}*"; + $lines[] = ''; + $lines[] = $message; + + return implode("\n", $lines); + } + + /** + * Format a list item. + * MD3: List item pattern with leading icon. + */ + protected static function md3ListItem(string $text, ?string $icon = null, ?string $trailing = null): string + { + $prefix = $icon ? "{$icon} " : '• '; + $suffix = $trailing ? " {$trailing}" : ''; + return "{$prefix}{$text}{$suffix}"; + } + + // ═══════════════════════════════════════════════════════════════════ + // STATUS & FEEDBACK PATTERNS (MD3 States) + // ═══════════════════════════════════════════════════════════════════ + + /** + * Format a success message. + * MD3: Success state with primary color emphasis. + */ + protected static function md3Success(string $message): string + { + return static::$iconSuccess . ' ' . $message; + } + + /** + * Format an error message. + * MD3: Error state with error color emphasis. + */ + protected static function md3Error(string $message): string + { + return static::$iconError . ' ' . $message; + } + + /** + * Format a warning message. + * MD3: Warning state with caution color emphasis. + */ + protected static function md3Warning(string $message): string + { + return static::$iconWarning . ' ' . $message; + } + + /** + * Format an info message. + * MD3: Info state with neutral emphasis. + */ + protected static function md3Info(string $message): string + { + return static::$iconInfo . ' ' . $message; + } + + /** + * Format a hint/tip message. + * MD3: Supporting text pattern. + */ + protected static function md3Tip(string $message): string + { + return static::$iconTip . ' ' . $message; + } + + // ═══════════════════════════════════════════════════════════════════ + // TIME-BASED GREETINGS (MD3 Personalization) + // ═══════════════════════════════════════════════════════════════════ + + /** + * Get appropriate greeting based on time of day. + * MD3: Personalized, expressive communication. + */ + protected static function md3Greeting(): array + { + $hour = (int) date('H'); + + return match (true) { + $hour >= 5 && $hour < 12 => [ + 'icon' => static::$iconMorning, + 'text' => 'Доброе утро' + ], + $hour >= 12 && $hour < 17 => [ + 'icon' => static::$iconDay, + 'text' => 'Добрый день' + ], + $hour >= 17 && $hour < 22 => [ + 'icon' => static::$iconEvening, + 'text' => 'Добрый вечер' + ], + default => [ + 'icon' => static::$iconNight, + 'text' => 'Доброй ночи' + ], + }; + } + + /** + * Format a personalized greeting message. + */ + protected static function md3GreetingMessage(string $name, ?string $role = null): string + { + $greeting = static::md3Greeting(); + $roleText = $role ? ", {$role}" : ''; + + return "{$greeting['icon']} {$greeting['text']}{$roleText} *{$name}*!"; + } + + // ═══════════════════════════════════════════════════════════════════ + // BUTTON LABEL PATTERNS (MD3 Buttons) + // ═══════════════════════════════════════════════════════════════════ + + /** + * Format a primary action button label. + * MD3: Filled button - high emphasis. + */ + protected static function md3ButtonPrimary(string $icon, string $label): string + { + return "{$icon} {$label}"; + } + + /** + * Format a secondary action button label. + * MD3: Outlined/tonal button - medium emphasis. + */ + protected static function md3ButtonSecondary(string $label): string + { + return $label; + } + + /** + * Format a text button label. + * MD3: Text button - low emphasis. + */ + protected static function md3ButtonText(string $label): string + { + return $label; + } + + // ═══════════════════════════════════════════════════════════════════ + // SEMANTIC ACTION LABELS (MD3 Action Patterns) + // ═══════════════════════════════════════════════════════════════════ + + // Primary Actions (Filled buttons) + protected static string $labelConfirm = '✓ Подтвердить'; + protected static string $labelComplete = '✓ Выполнено'; + protected static string $labelAccept = '✓ Принять'; + protected static string $labelSend = '↗ Отправить'; + + // Secondary Actions (Outlined buttons) + protected static string $labelCancel = 'Отмена'; + protected static string $labelDecline = 'Отклонить'; + protected static string $labelSkip = 'Пропустить'; + protected static string $labelBack = '← Назад'; + + // Shift Actions + protected static string $labelOpenShift = '🔓 Открыть смену'; + protected static string $labelCloseShift = '🔒 Закрыть смену'; + + // Navigation Actions + protected static string $labelViewShifts = '📊 Смены'; + protected static string $labelViewTasks = '📋 Задачи'; + protected static string $labelViewDealerships = '🏢 Салоны'; + protected static string $labelViewEmployees = '👥 Сотрудники'; + protected static string $labelViewStats = '📊 Статистика'; + + // Task Actions + protected static string $labelTaskOk = '✓ Принято'; + protected static string $labelTaskDone = '✓ Выполнено'; + protected static string $labelAddComment = '💬 Добавить комментарий'; + + // ═══════════════════════════════════════════════════════════════════ + // DATA FORMATTING (MD3 Presentation) + // ═══════════════════════════════════════════════════════════════════ + + /** + * Format a date in a human-readable way. + * MD3: Clear, localized date presentation. + */ + protected static function md3FormatDate(string $date, string $format = 'd.m.Y'): string + { + return date($format, strtotime($date)); + } + + /** + * Format a time value. + * MD3: Clear time presentation. + */ + protected static function md3FormatTime(string $time, string $format = 'H:i'): string + { + return date($format, strtotime($time)); + } + + /** + * Format a datetime with both date and time. + */ + protected static function md3FormatDateTime(string $datetime): string + { + return date('H:i d.m.Y', strtotime($datetime)); + } + + /** + * Format duration in hours and minutes. + */ + protected static function md3FormatDuration(int $minutes): string + { + $hours = floor($minutes / 60); + $mins = $minutes % 60; + + if ($hours > 0 && $mins > 0) { + return "{$hours}ч {$mins}м"; + } elseif ($hours > 0) { + return "{$hours}ч"; + } else { + return "{$mins}м"; + } + } + + /** + * Format a relative time (e.g., "через 30 минут", "2 часа назад"). + */ + protected static function md3FormatRelativeTime(int $minutes, bool $future = true): string + { + $hours = floor(abs($minutes) / 60); + $mins = abs($minutes) % 60; + + $timeStr = ''; + if ($hours > 0) { + $timeStr = $hours . ' ' . static::pluralizeRu($hours, 'час', 'часа', 'часов'); + if ($mins > 0) { + $timeStr .= ' ' . $mins . ' ' . static::pluralizeRu($mins, 'минута', 'минуты', 'минут'); + } + } else { + $timeStr = $mins . ' ' . static::pluralizeRu($mins, 'минута', 'минуты', 'минут'); + } + + return $future ? "через {$timeStr}" : "{$timeStr} назад"; + } + + /** + * Russian pluralization helper. + */ + protected static function pluralizeRu(int $number, string $one, string $few, string $many): string + { + $mod10 = $number % 10; + $mod100 = $number % 100; + + if ($mod10 === 1 && $mod100 !== 11) { + return $one; + } + + if ($mod10 >= 2 && $mod10 <= 4 && ($mod100 < 10 || $mod100 >= 20)) { + return $few; + } + + return $many; + } + + // ═══════════════════════════════════════════════════════════════════ + // STATUS BADGES (MD3 Chips/Badges) + // ═══════════════════════════════════════════════════════════════════ + + /** + * Format a status badge. + * MD3: Filter chip / status indicator pattern. + */ + protected static function md3StatusBadge(string $status): string + { + return match (strtolower($status)) { + 'active', 'open', 'online' => static::$iconOnline . ' Активно', + 'completed', 'done', 'closed' => static::$iconSuccess . ' Завершено', + 'pending', 'waiting' => static::$iconPending . ' В ожидании', + 'overdue', 'late' => static::$iconBusy . ' Просрочено', + 'cancelled', 'rejected' => static::$iconError . ' Отменено', + default => $status, + }; + } + + /** + * Format a priority indicator. + * MD3: Visual emphasis based on priority level. + */ + protected static function md3PriorityBadge(string $priority): string + { + return match (strtolower($priority)) { + 'high', 'urgent', 'critical' => static::$iconUrgent . ' Срочно', + 'medium', 'normal' => static::$iconPending . ' Обычный', + 'low' => static::$iconInfo . ' Низкий', + default => $priority, + }; + } + + // ═══════════════════════════════════════════════════════════════════ + // COMPLEX MESSAGE BUILDERS (MD3 Templates) + // ═══════════════════════════════════════════════════════════════════ + + /** + * Build a task notification message following MD3 patterns. + */ + protected static function md3TaskNotification( + string $title, + ?string $description = null, + ?string $comment = null, + ?string $deadline = null, + ?array $tags = null + ): string { + $lines = []; + + // Header + $lines[] = static::$iconPin . ' *' . $title . '*'; + + // Description + if ($description) { + $lines[] = ''; + $lines[] = $description; + } + + // Comment + if ($comment) { + $lines[] = ''; + $lines[] = static::$iconComment . ' ' . $comment; + } + + // Metadata section + $metadataLines = []; + + if ($deadline) { + $metadataLines[] = static::$iconDeadline . ' Дедлайн: ' . $deadline; + } + + if ($tags && !empty($tags)) { + $metadataLines[] = static::$iconTag . ' ' . implode(', ', $tags); + } + + if (!empty($metadataLines)) { + $lines[] = ''; + $lines = array_merge($lines, $metadataLines); + } + + return implode("\n", $lines); + } + + /** + * Build a shift status message following MD3 patterns. + */ + protected static function md3ShiftMessage( + string $action, + string $time, + ?string $duration = null, + ?string $status = null, + ?int $lateMinutes = null, + ?string $replacingUser = null, + ?string $replacementReason = null + ): string { + $lines = []; + + // Main action result + $icon = $action === 'open' ? static::$iconOpen : static::$iconClose; + $actionText = $action === 'open' ? 'Смена открыта' : 'Смена закрыта'; + $lines[] = static::$iconSuccess . ' *' . $actionText . '*'; + $lines[] = static::$iconDeadline . ' ' . $time; + + // Duration (for closed shifts) + if ($duration) { + $lines[] = ''; + $lines[] = '⏱️ Продолжительность: ' . $duration; + } + + // Late status + if ($lateMinutes && $lateMinutes > 0) { + $lines[] = ''; + $lines[] = static::$iconWarning . ' Опоздание: ' . $lateMinutes . ' ' . + static::pluralizeRu($lateMinutes, 'минута', 'минуты', 'минут'); + } + + // Replacement info + if ($replacingUser) { + $lines[] = ''; + $lines[] = static::$iconNote . ' Замена: ' . $replacingUser; + if ($replacementReason) { + $lines[] = static::$iconComment . ' Причина: ' . $replacementReason; + } + } + + return implode("\n", $lines); + } + + /** + * Build a deadline reminder message following MD3 patterns. + */ + protected static function md3DeadlineReminder( + string $title, + string $deadlineTime, + int $minutesUntil, + ?string $description = null + ): string { + $lines = []; + + // Urgent header + $lines[] = static::$iconDeadline . ' *НАПОМИНАНИЕ О ДЕДЛАЙНЕ*'; + $lines[] = ''; + $lines[] = static::$iconPin . ' *' . $title . '*'; + + if ($description) { + $lines[] = ''; + $lines[] = $description; + } + + $lines[] = ''; + $lines[] = static::$iconUrgent . ' Дедлайн ' . static::md3FormatRelativeTime($minutesUntil, true) . '!'; + $lines[] = static::$iconDeadline . ' Время: ' . $deadlineTime; + + return implode("\n", $lines); + } + + /** + * Build an overdue notification message following MD3 patterns. + */ + protected static function md3OverdueNotification( + string $title, + string $deadlineTime, + string $overdueTime, + ?string $description = null + ): string { + $lines = []; + + // Urgent header + $lines[] = static::$iconWarning . ' *СРОК ВЫПОЛНЕНИЯ ИСТЁК*'; + $lines[] = ''; + $lines[] = static::$iconPin . ' *' . $title . '*'; + + if ($description) { + $lines[] = ''; + $lines[] = $description; + } + + $lines[] = ''; + $lines[] = static::$iconUrgent . ' Дедлайн был: ' . $deadlineTime; + $lines[] = static::$iconOverdue . ' Просрочено на: ' . $overdueTime; + + return implode("\n", $lines); + } +} From 997d7b055be4f48d38e9668ac10f82776c83fa62 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 8 Jan 2026 09:17:47 +0100 Subject: [PATCH 3/3] Revert "Initial commit with task details" This reverts commit 026def350a856093edddebf10c04834b5ccd7991. --- CLAUDE.md | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index be8f009..bf715c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -362,16 +362,3 @@ Copyright: © 2023-2025 谢榕川 All rights reserved - [swagger.yaml](swagger.yaml) - OpenAPI спецификация - [docs/](docs/) - Дополнительная документация - [check-scheduler.md](check-scheduler.md) - Проверка планировщика задач - ---- - -Issue to solve: https://github.com/xierongchuan/TaskMateTelegramBot/issues/31 -Your prepared branch: issue-31-e50894ec22be -Your prepared working directory: /tmp/gh-issue-solver-1767858884685 -Your forked repository: konard/TaskMateTelegramBot -Original repository (upstream): xierongchuan/TaskMateTelegramBot - -Proceed. - - -Run timestamp: 2026-01-08T07:54:52.013Z \ No newline at end of file