diff --git a/app/Controllers/Auth.php b/app/Controllers/Auth.php index 320d3de..2bbbfc1 100644 --- a/app/Controllers/Auth.php +++ b/app/Controllers/Auth.php @@ -93,45 +93,6 @@ class Auth extends BaseController } } - /** - * Форматирование времени блокировки для отображения - * - * @param int $seconds Секунды - * @return string - */ - protected function formatBlockTime(int $seconds): string - { - if ($seconds >= 60) { - $minutes = ceil($seconds / 60); - return $minutes . ' ' . $this->pluralize($minutes, ['минуту', 'минуты', 'минут']); - } - return $seconds . ' ' . $this->pluralize($seconds, ['секунду', 'секунды', 'секунд']); - } - - /** - * Склонение окончаний для чисел - * - * @param int $number - * @param array $forms [одна, две, пять] - * @return string - */ - protected function pluralize(int $number, array $forms): string - { - $abs = abs($number); - $mod = $abs % 10; - - if ($abs % 100 >= 11 && $abs % 100 <= 19) { - return $forms[2]; - } - if ($mod === 1) { - return $forms[0]; - } - if ($mod >= 2 && $mod <= 4) { - return $forms[1]; - } - return $forms[2]; - } - public function register() { if ($this->request->getMethod() === 'POST') { diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index 3cb809c..d16b2e2 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -2,12 +2,12 @@ namespace App\Controllers; +use App\Models\OrganizationUserModel; +use App\Services\AccessService; use CodeIgniter\Controller; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use Psr\Log\LoggerInterface; -use App\Models\OrganizationModel; -use App\Services\AccessService; /** * BaseController provides a convenient place for loading components @@ -29,6 +29,7 @@ abstract class BaseController extends Controller protected $session; protected AccessService $access; + protected ?OrganizationUserModel $orgUserModel = null; /** * @return void @@ -51,11 +52,54 @@ abstract class BaseController extends Controller } /** - * Проверка права на действие ( shortcut для $this->access->can() ) - * - * @param string $action - * @param string $resource - * @return bool + * Получение лениво инициализированной модели OrganizationUserModel + */ + protected function getOrgUserModel(): OrganizationUserModel + { + if ($this->orgUserModel === null) { + $this->orgUserModel = new OrganizationUserModel(); + } + return $this->orgUserModel; + } + + // ======================================== + // Методы для работы с пользователем и организацией + // ======================================== + + /** + * Получение ID текущего пользователя + */ + protected function getCurrentUserId(): ?int + { + $userId = $this->session->get('user_id'); + return $userId ? (int) $userId : null; + } + + /** + * Получение данных текущего пользователя + */ + protected function getCurrentUser(): ?array + { + $userId = $this->getCurrentUserId(); + if (!$userId) { + return null; + } + + $userModel = new \App\Models\UserModel(); + return $userModel->find($userId); + } + + /** + * Получение ID активной организации + */ + protected function getActiveOrgId(): ?int + { + $orgId = $this->session->get('active_org_id'); + return $orgId ? (int) $orgId : null; + } + + /** + * Проверка права на действие (shortcut для $this->access->can()) */ protected function can(string $action, string $resource): bool { @@ -63,16 +107,165 @@ abstract class BaseController extends Controller } /** - * Проверка роли (shortcut для $this->access->isRole() ) - * - * @param string|array $roles - * @return bool + * Проверка роли (shortcut для $this->access->isRole()) */ protected function isRole($roles): bool { return $this->access->isRole($roles); } + /** + * Получение membership пользователя для организации + * + * @param int $orgId + * @return array|null + */ + protected function getMembership(int $orgId): ?array + { + $userId = $this->getCurrentUserId(); + if (!$userId || !$orgId) { + return null; + } + + return $this->getOrgUserModel() + ->where('organization_id', $orgId) + ->where('user_id', $userId) + ->first(); + } + + /** + * Получение membership с требованием наличия доступа + * Бросает исключение если доступ запрещён + * + * @param int $orgId + * @return array + */ + protected function requireMembership(int $orgId): array + { + $membership = $this->getMembership($orgId); + if (!$membership) { + throw new \RuntimeException('Доступ запрещён'); + } + return $membership; + } + + /** + * Получение ID активной организации с требованием + * Бросает исключение если организация не выбрана + * + * @return int + */ + protected function requireActiveOrg(): int + { + $orgId = $this->getActiveOrgId(); + if (!$orgId) { + throw new \RuntimeException('Организация не выбрана'); + } + return $orgId; + } + + // ======================================== + // Методы для редиректов и ответов + // ======================================== + + /** + * Редирект с сообщением об ошибке + */ + protected function redirectWithError(string $message, string $redirectUrl): ResponseInterface + { + if ($this->request->isAJAX()) { + return service('response') + ->setStatusCode(403) + ->setJSON(['error' => $message]); + } + + $this->session->setFlashdata('error', $message); + return redirect()->to($redirectUrl); + } + + /** + * Редирект с сообщением об успехе + */ + protected function redirectWithSuccess(string $message, string $redirectUrl): ResponseInterface + { + if ($this->request->isAJAX()) { + return service('response') + ->setStatusCode(200) + ->setJSON(['success' => true, 'message' => $message]); + } + + $this->session->setFlashdata('success', $message); + return redirect()->to($redirectUrl); + } + + /** + * Ответ для AJAX запросов с ошибкой доступа + */ + protected function forbiddenResponse(string $message = 'Доступ запрещён'): ResponseInterface + { + return service('response') + ->setStatusCode(403) + ->setJSON(['error' => $message]); + } + + /** + * Ответ для AJAX запросов с ошибкой валидации + */ + protected function validationErrorResponse(string $message = 'Ошибка валидации', array $errors = []): ResponseInterface + { + return service('response') + ->setStatusCode(422) + ->setJSON([ + 'success' => false, + 'message' => $message, + 'errors' => $errors, + ]); + } + + // ======================================== + // Утилиты для вывода + // ======================================== + + /** + * Форматирование времени блокировки для отображения + */ + protected function formatBlockTime(int $seconds): string + { + if ($seconds >= 60) { + $minutes = ceil($seconds / 60); + return $minutes . ' ' . $this->pluralize($minutes, ['минуту', 'минуты', 'минут']); + } + return $seconds . ' ' . $this->pluralize($seconds, ['секунду', 'секунды', 'секунд']); + } + + /** + * Склонение окончаний для чисел + * + * @param int $number + * @param array $forms [одна, две, пять] + * @return string + */ + protected function pluralize(int $number, array $forms): string + { + $abs = abs($number); + $mod = $abs % 10; + + if ($abs % 100 >= 11 && $abs % 100 <= 19) { + return $forms[2]; + } + if ($mod === 1) { + return $forms[0]; + } + if ($mod >= 2 && $mod <= 4) { + return $forms[1]; + } + return $forms[2]; + } + + // ======================================== + // Рендеринг Twig + // ======================================== + public function renderTwig($template, $data = []) { helper('csrf'); @@ -154,7 +347,7 @@ abstract class BaseController extends Controller // Старый способ извлечения фильтров для совместимости foreach ($this->request->getGet() as $key => $value) { if (str_starts_with($key, 'filters[') && str_ends_with($key, ']')) { - $field = substr($key, 8, -1); // Исправлено: было 9, должно быть 8 + $field = substr($key, 8, -1); $filters[$field] = $value; } } @@ -185,7 +378,7 @@ abstract class BaseController extends Controller if ($value === '') { continue; } - + // Сначала проверяем fieldMap (алиасы) — они имеют приоритет if (isset($config['fieldMap']) && isset($config['fieldMap'][$filterKey])) { $realField = $config['fieldMap'][$filterKey]; @@ -222,7 +415,7 @@ abstract class BaseController extends Controller ]; $data = [ - 'items' => $items, // Алиас для универсального шаблона + 'items' => $items, 'pagerDetails' => $pagerData, 'perPage' => $perPage, 'sort' => $sort, @@ -259,7 +452,6 @@ abstract class BaseController extends Controller $tableData['actions'] = $config['actions'] ?? false; $tableData['actionsConfig'] = $config['actionsConfig'] ?? []; $tableData['columns'] = $config['columns'] ?? []; - $tableData['onRowClick'] = $config['onRowClick'] ?? null; // Параметры для пустого состояния $tableData['emptyMessage'] = $config['emptyMessage'] ?? 'Нет данных'; @@ -287,17 +479,17 @@ abstract class BaseController extends Controller public function table(?array $config = null, ?string $pageUrl = null) { $isPartial = $this->request->getGet('format') === 'partial' || $this->isAjax(); - + // Если это частичный запрос (AJAX) — возвращаем только таблицу if ($isPartial) { return $this->renderTable($config, true); } - + // Прямой запрос к /table — редиректим на основную страницу // Сохраняем все параметры: page, perPage, sort, order, filters $params = $this->request->getGet(); unset($params['format']); // Убираем format=partial если был - + if ($pageUrl) { $redirectUrl = $pageUrl; } else { @@ -305,11 +497,11 @@ abstract class BaseController extends Controller $tableUrl = $config['url'] ?? '/table'; $redirectUrl = $tableUrl; } - + if (!empty($params)) { $redirectUrl .= '?' . http_build_query($params); } - + return redirect()->to($redirectUrl); } } diff --git a/app/Controllers/Organizations.php b/app/Controllers/Organizations.php index 06ad05a..dbb8b3c 100644 --- a/app/Controllers/Organizations.php +++ b/app/Controllers/Organizations.php @@ -3,26 +3,18 @@ namespace App\Controllers; use App\Models\OrganizationModel; -use App\Models\OrganizationUserModel; use App\Models\UserModel; use App\Services\AccessService; class Organizations extends BaseController { - protected OrganizationUserModel $orgUserModel; - - public function __construct() - { - $this->orgUserModel = new OrganizationUserModel(); - } - public function index() { $orgModel = new OrganizationModel(); - $userId = session()->get('user_id'); + $userId = $this->getCurrentUserId(); // Получаем организации пользователя через связующую таблицу - $userOrgLinks = $this->orgUserModel->where('user_id', $userId)->findAll(); + $userOrgLinks = $this->getOrgUserModel()->where('user_id', $userId)->findAll(); // Нам нужно получить сами данные организаций $orgIds = array_column($userOrgLinks, 'organization_id'); @@ -70,7 +62,7 @@ class Organizations extends BaseController // Создаем организацию $orgId = $orgModel->insert([ - 'owner_id' => session()->get('user_id'), + 'owner_id' => $this->getCurrentUserId(), 'name' => $this->request->getPost('name'), 'type' => 'business', 'requisites' => json_encode($requisites), @@ -78,17 +70,17 @@ class Organizations extends BaseController ]); // Привязываем владельца - $this->orgUserModel->insert([ + $this->getOrgUserModel()->insert([ 'organization_id' => $orgId, - 'user_id' => session()->get('user_id'), + 'user_id' => $this->getCurrentUserId(), 'role' => 'owner', 'status' => 'active', 'joined_at' => date('Y-m-d H:i:s'), ]); // Сразу переключаемся на неё - session()->set('active_org_id', $orgId); - session()->setFlashdata('success', 'Организация успешно создана!'); + $this->session->set('active_org_id', $orgId); + $this->session->setFlashdata('success', 'Организация успешно создана!'); return redirect()->to('/'); } @@ -119,9 +111,9 @@ class Organizations extends BaseController // Получаем статистику организации $stats = [ - 'users_total' => $this->orgUserModel->where('organization_id', $orgId)->countAllResults(), - 'users_active' => $this->orgUserModel->where('organization_id', $orgId)->where('status', 'active')->countAllResults(), - 'users_blocked' => $this->orgUserModel->where('organization_id', $orgId)->where('status', 'blocked')->countAllResults(), + 'users_total' => $this->getOrgUserModel()->where('organization_id', $orgId)->countAllResults(), + 'users_active' => $this->getOrgUserModel()->where('organization_id', $orgId)->where('status', 'active')->countAllResults(), + 'users_blocked' => $this->getOrgUserModel()->where('organization_id', $orgId)->where('status', 'blocked')->countAllResults(), ]; // Проверяем права для отображения пунктов меню @@ -194,7 +186,7 @@ class Organizations extends BaseController 'requisites' => json_encode($newRequisites), ]); - session()->setFlashdata('success', 'Организация успешно обновлена!'); + $this->session->setFlashdata('success', 'Организация успешно обновлена!'); return redirect()->to('/organizations'); } @@ -232,17 +224,17 @@ class Organizations extends BaseController // Если это POST с подтверждением — удаляем if ($this->request->getMethod() === 'POST') { // Удаляем связи с пользователями через forCurrentOrg() - $this->orgUserModel->forCurrentOrg()->delete(); + $this->getOrgUserModel()->forCurrentOrg()->delete(); // Мягкое удаление организации $orgModel->delete($orgId); // Если удаляли активную организацию — очищаем - if (session()->get('active_org_id') == $orgId) { - session()->remove('active_org_id'); + if ($this->session->get('active_org_id') == $orgId) { + $this->session->remove('active_org_id'); } - session()->setFlashdata('success', 'Организация "' . $organization['name'] . '" удалена'); + $this->session->setFlashdata('success', 'Организация "' . $organization['name'] . '" удалена'); return redirect()->to('/organizations'); } @@ -254,11 +246,11 @@ class Organizations extends BaseController public function switch($orgId) { - $userId = session()->get('user_id'); + $userId = $this->getCurrentUserId(); $orgId = (int) $orgId; // Проверяем доступ - $membership = $this->orgUserModel + $membership = $this->getOrgUserModel() ->where('organization_id', $orgId) ->where('user_id', $userId) ->first(); @@ -267,11 +259,11 @@ class Organizations extends BaseController // Сбрасываем кэш AccessService при смене организации $this->access->resetCache(); - session()->set('active_org_id', $orgId); - session()->setFlashdata('success', 'Организация изменена'); + $this->session->set('active_org_id', $orgId); + $this->session->setFlashdata('success', 'Организация изменена'); return redirect()->to('/'); } else { - session()->setFlashdata('error', 'Доступ запрещен'); + $this->session->setFlashdata('error', 'Доступ запрещен'); return redirect()->to('/organizations'); } } @@ -309,14 +301,14 @@ class Organizations extends BaseController $tableHtml = $this->renderTable($this->getUsersTableConfig($orgId)); // Получаем данные пользователей для статистики - $users = $this->orgUserModel->getOrganizationUsers($orgId); + $users = $this->getOrgUserModel()->getOrganizationUsers($orgId); return $this->renderTwig('organizations/users', [ 'organization' => $organization, 'organization_id' => $orgId, 'tableHtml' => $tableHtml, 'users' => $users, - 'current_user_id' => session()->get('user_id'), + 'current_user_id' => $this->getCurrentUserId(), 'can_manage_users' => $this->access->canManageUsers(), 'current_role' => $membership['role'], ]); @@ -329,11 +321,11 @@ class Organizations extends BaseController { // Проверяем права для кнопок действий $canManage = $this->access->canManageUsers(); - + return [ 'id' => 'users-table', 'url' => '/organizations/' . $orgId . '/users/table', - 'model' => $this->orgUserModel, + 'model' => $this->getOrgUserModel(), 'columns' => [ 'user_email' => [ 'label' => 'Пользователь', @@ -495,7 +487,7 @@ class Organizations extends BaseController $orgId, $email, $role, - session()->get('user_id') + $this->getCurrentUserId() ); return $this->response->setJSON($result); @@ -519,7 +511,7 @@ class Organizations extends BaseController } // Нельзя заблокировать владельца - $targetMembership = $this->orgUserModel + $targetMembership = $this->getOrgUserModel() ->where('organization_id', $orgId) ->where('user_id', $userId) ->first(); @@ -532,9 +524,9 @@ class Organizations extends BaseController return $this->redirectWithError('Нельзя заблокировать владельца', "/organizations/users/{$orgId}"); } - $this->orgUserModel->blockUser($targetMembership['id']); + $this->getOrgUserModel()->blockUser($targetMembership['id']); - session()->setFlashdata('success', 'Пользователь заблокирован'); + $this->session->setFlashdata('success', 'Пользователь заблокирован'); return redirect()->to("/organizations/users/{$orgId}"); } @@ -551,7 +543,7 @@ class Organizations extends BaseController return $this->redirectWithError('Доступ запрещен', '/organizations'); } - $targetMembership = $this->orgUserModel + $targetMembership = $this->getOrgUserModel() ->where('organization_id', $orgId) ->where('user_id', $userId) ->first(); @@ -560,9 +552,9 @@ class Organizations extends BaseController return $this->redirectWithError('Пользователь не найден', "/organizations/users/{$orgId}"); } - $this->orgUserModel->unblockUser($targetMembership['id']); + $this->getOrgUserModel()->unblockUser($targetMembership['id']); - session()->setFlashdata('success', 'Пользователь разблокирован'); + $this->session->setFlashdata('success', 'Пользователь разблокирован'); return redirect()->to("/organizations/users/{$orgId}"); } @@ -584,21 +576,21 @@ class Organizations extends BaseController } // Удаляем из организации - $this->orgUserModel->delete($membership['id']); + $this->getOrgUserModel()->delete($membership['id']); // Если это была активная организация - переключаем на другую - if (session()->get('active_org_id') == $orgId) { - $userId = session()->get('user_id'); - $otherOrgs = $this->orgUserModel->where('user_id', $userId)->where('status', 'active')->findAll(); + if ($this->session->get('active_org_id') == $orgId) { + $userId = $this->getCurrentUserId(); + $otherOrgs = $this->getOrgUserModel()->where('user_id', $userId)->where('status', 'active')->findAll(); if (!empty($otherOrgs)) { - session()->set('active_org_id', $otherOrgs[0]['organization_id']); + $this->session->set('active_org_id', $otherOrgs[0]['organization_id']); } else { - session()->remove('active_org_id'); + $this->session->remove('active_org_id'); } } - session()->setFlashdata('success', 'Вы покинули организацию'); + $this->session->setFlashdata('success', 'Вы покинули организацию'); return redirect()->to('/organizations'); } @@ -618,9 +610,9 @@ class Organizations extends BaseController $result = $invitationService->resendInvitation($invitationId, $orgId); if ($result['success']) { - session()->setFlashdata('success', 'Приглашение отправлено повторно'); + $this->session->setFlashdata('success', 'Приглашение отправлено повторно'); } else { - session()->setFlashdata('error', $result['message']); + $this->session->setFlashdata('error', $result['message']); } return redirect()->to("/organizations/users/{$orgId}"); @@ -642,9 +634,9 @@ class Organizations extends BaseController $result = $invitationService->cancelInvitation($invitationId, $orgId); if ($result['success']) { - session()->setFlashdata('success', 'Приглашение отозвано'); + $this->session->setFlashdata('success', 'Приглашение отозвано'); } else { - session()->setFlashdata('error', $result['message']); + $this->session->setFlashdata('error', $result['message']); } return redirect()->to("/organizations/users/{$orgId}"); @@ -669,7 +661,7 @@ class Organizations extends BaseController } // Нельзя изменить роль владельца - $targetMembership = $this->orgUserModel + $targetMembership = $this->getOrgUserModel() ->where('organization_id', $orgId) ->where('user_id', $userId) ->first(); @@ -691,11 +683,11 @@ class Organizations extends BaseController return redirect()->back()->withInput()->with('error', 'Недопустимая роль'); } - $this->orgUserModel->update($targetMembership['id'], [ + $this->getOrgUserModel()->update($targetMembership['id'], [ 'role' => $newRole, ]); - session()->setFlashdata('success', 'Роль изменена'); + $this->session->setFlashdata('success', 'Роль изменена'); return redirect()->to("/organizations/users/{$orgId}"); } @@ -717,7 +709,7 @@ class Organizations extends BaseController { $orgId = (int) $orgId; $userId = (int) $userId; - $currentUserId = session()->get('user_id'); + $currentUserId = $this->getCurrentUserId(); $membership = $this->getMembership($orgId); if (!$membership) { @@ -730,7 +722,7 @@ class Organizations extends BaseController } // Нельзя удалить владельца - $targetMembership = $this->orgUserModel + $targetMembership = $this->getOrgUserModel() ->where('organization_id', $orgId) ->where('user_id', $userId) ->first(); @@ -749,51 +741,9 @@ class Organizations extends BaseController } // Удаляем пользователя из организации - $this->orgUserModel->delete($targetMembership['id']); + $this->getOrgUserModel()->delete($targetMembership['id']); - session()->setFlashdata('success', 'Пользователь удалён из организации'); + $this->session->setFlashdata('success', 'Пользователь удалён из организации'); return redirect()->to("/organizations/users/{$orgId}"); } - - // ======================================== - // Вспомогательные методы - // ======================================== - - /** - * Получение membership пользователя для организации - * - * @param int $orgId - * @return array|null - */ - protected function getMembership(int $orgId): ?array - { - $userId = session()->get('user_id'); - if (!$userId || !$orgId) { - return null; - } - - return $this->orgUserModel - ->where('organization_id', $orgId) - ->where('user_id', $userId) - ->first(); - } - - /** - * Редирект с сообщением об ошибке - * - * @param string $message - * @param string $redirectUrl - * @return ResponseInterface - */ - protected function redirectWithError(string $message, string $redirectUrl) - { - if ($this->request->isAJAX()) { - return service('response') - ->setStatusCode(403) - ->setJSON(['error' => $message]); - } - - session()->setFlashdata('error', $message); - return redirect()->to($redirectUrl); - } } diff --git a/app/Models/OrganizationModel.php b/app/Models/OrganizationModel.php index 883c7e0..00b20c5 100644 --- a/app/Models/OrganizationModel.php +++ b/app/Models/OrganizationModel.php @@ -20,10 +20,19 @@ class OrganizationModel extends Model protected $deletedField = 'deleted_at'; // Получить организации конкретного пользователя - public function getUserOrganizations(int $userId) + public function getUserOrganizations(int $userId): array { - // TODO: Здесь мы будем делать JOIN с таблицей organization_users - // Пока упрощенная версия для владельца - return $this->where('owner_id', $userId)->findAll(); + $db = $this->db(); + $builder = $db->newQuery(); + + return $builder->select('o.*, ou.role, ou.status as membership_status, ou.joined_at') + ->from('organizations o') + ->join('organization_users ou', 'ou.organization_id = o.id', 'inner') + ->where('ou.user_id', $userId) + ->where('ou.status', 'active') + ->where('o.deleted_at', null) + ->orderBy('ou.joined_at', 'ASC') + ->get() + ->getResultArray(); } } \ No newline at end of file diff --git a/app/Modules/Clients/Controllers/Clients.php b/app/Modules/Clients/Controllers/Clients.php index 35e87a9..0426e5c 100644 --- a/app/Modules/Clients/Controllers/Clients.php +++ b/app/Modules/Clients/Controllers/Clients.php @@ -70,7 +70,6 @@ class Clients extends BaseController 'type' => 'delete', ] ], - 'onRowClick' => 'viewClient', // Функция для открытия карточки клиента 'emptyMessage' => 'Клиентов пока нет', 'emptyIcon' => 'fa-solid fa-users', 'emptyActionUrl' => base_url('/clients/new'), @@ -220,263 +219,4 @@ class Clients extends BaseController session()->setFlashdata('success', 'Клиент удалён'); return redirect()->to('/clients'); } - - /** - * Возврат ответа "Доступ запрещён" - * - * @param string $message - * @return ResponseInterface - */ - protected function forbiddenResponse(string $message = 'Доступ запрещён') - { - if ($this->request->isAJAX()) { - return service('response') - ->setStatusCode(403) - ->setJSON(['error' => $message]); - } - - session()->setFlashdata('error', $message); - return redirect()->to('/'); - } - - // ======================================== - // API: Просмотр, Экспорт, Импорт - // ======================================== - - /** - * API: Получение данных клиента для модального окна - */ - public function view($id) - { - if (!$this->access->canView('clients')) { - return $this->response->setJSON([ - 'success' => false, - 'error' => 'Доступ запрещён' - ])->setStatusCode(403); - } - - $client = $this->clientModel->forCurrentOrg()->find($id); - - if (!$client) { - return $this->response->setJSON([ - 'success' => false, - 'error' => 'Клиент не найден' - ])->setStatusCode(404); - } - - // Формируем данные для ответа - $data = [ - 'id' => $client['id'], - 'name' => $client['name'], - 'email' => $client['email'] ?? '', - 'phone' => $client['phone'] ?? '', - 'notes' => $client['notes'] ?? '', - 'status' => $client['status'] ?? 'active', - 'created_at' => $client['created_at'] ? date('d.m.Y H:i', strtotime($client['created_at'])) : '', - 'updated_at' => $client['updated_at'] ? date('d.m.Y H:i', strtotime($client['updated_at'])) : '', - ]; - - return $this->response->setJSON([ - 'success' => true, - 'data' => $data - ]); - } - - /** - * Экспорт клиентов - */ - public function export() - { - if (!$this->access->canView('clients')) { - return $this->forbiddenResponse('Доступ запрещён'); - } - - $format = $this->request->getGet('format') ?? 'csv'; - - // Получаем всех клиентов организации - $clients = $this->clientModel->forCurrentOrg()->findAll(); - - // Устанавливаем заголовки для скачивания - if ($format === 'xlsx') { - $filename = 'clients_' . date('Y-m-d') . '.xlsx'; - $this->exportToXlsx($clients, $filename); - } else { - $filename = 'clients_' . date('Y-m-d') . '.csv'; - $this->exportToCsv($clients, $filename); - } - } - - /** - * Экспорт в CSV - */ - protected function exportToCsv(array $clients, string $filename) - { - header('Content-Type: text/csv; charset=utf-8'); - header('Content-Disposition: attachment; filename="' . $filename . '"'); - - $output = fopen('php://output', 'w'); - - // Заголовок CSV - fputcsv($output, ['ID', 'Имя', 'Email', 'Телефон', 'Статус', 'Создан', 'Обновлён'], ';'); - - // Данные - foreach ($clients as $client) { - fputcsv($output, [ - $client['id'], - $client['name'], - $client['email'] ?? '', - $client['phone'] ?? '', - $client['status'] ?? 'active', - $client['created_at'] ?? '', - $client['updated_at'] ?? '', - ], ';'); - } - - fclose($output); - exit; - } - - /** - * Экспорт в XLSX (упрощённый через HTML table) - */ - protected function exportToXlsx(array $clients, string $filename) - { - // Для упрощения используем HTML table с правильными заголовками Excel - // В продакшене рекомендуется использовать PhpSpreadsheet - - header('Content-Type: application/vnd.ms-excel'); - header('Content-Disposition: attachment; filename="' . $filename . '"'); - header('Cache-Control: max-age=0'); - - echo ''; - echo ''; - - foreach ($clients as $client) { - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - } - - echo '
IDИмяEmailТелефонСтатусСозданОбновлён
' . $client['id'] . '' . htmlspecialchars($client['name']) . '' . htmlspecialchars($client['email'] ?? '') . '' . htmlspecialchars($client['phone'] ?? '') . '' . ($client['status'] ?? 'active') . '' . ($client['created_at'] ?? '') . '' . ($client['updated_at'] ?? '') . '
'; - exit; - } - - /** - * Страница импорта клиентов (форма) - */ - public function importPage() - { - if (!$this->access->canCreate('clients')) { - return $this->forbiddenResponse('Доступ запрещён'); - } - - return $this->renderTwig('@Clients/import'); - } - - /** - * Импорт клиентов из файла - */ - public function import() - { - if (!$this->access->canCreate('clients')) { - return $this->forbiddenResponse('Доступ запрещён'); - } - - $file = $this->request->getFile('file'); - - if (!$file->isValid()) { - return $this->response->setJSON([ - 'success' => false, - 'message' => 'Файл не загружен' - ]); - } - - $extension = strtolower($file->getClientExtension()); - - if (!in_array($extension, ['csv', 'xlsx', 'xls'])) { - return $this->response->setJSON([ - 'success' => false, - 'message' => 'Неподдерживаемый формат файла. Используйте CSV или XLSX.' - ]); - } - - $organizationId = session()->get('active_org_id'); - $imported = 0; - $errors = []; - - // Парсим CSV файл - if ($extension === 'csv') { - $handle = fopen($file->getTempName(), 'r'); - - // Пропускаем заголовок - fgetcsv($handle, 0, ';'); - - $row = 1; - while (($data = fgetcsv($handle, 0, ';')) !== false) { - $row++; - - $name = trim($data[1] ?? ''); - $email = trim($data[2] ?? ''); - - // Валидация - if (empty($name)) { - $errors[] = "Строка $row: Имя обязательно"; - continue; - } - - if (!empty($email) && !filter_var($email, FILTER_VALIDATE_EMAIL)) { - $errors[] = "Строка $row: Некорректный email"; - continue; - } - - // Проверка на дубликат email - if (!empty($email)) { - $exists = $this->clientModel->where('organization_id', $organizationId) - ->where('email', $email) - ->countAllResults(); - if ($exists > 0) { - $errors[] = "Строка $row: Клиент с email $email уже существует"; - continue; - } - } - - // Вставка - $this->clientModel->insert([ - 'organization_id' => $organizationId, - 'name' => $name, - 'email' => $email ?: null, - 'phone' => trim($data[3] ?? '') ?: null, - 'status' => 'active', - ]); - - $imported++; - } - - fclose($handle); - } else { - // Для XLSX в продакшене использовать PhpSpreadsheet - return $this->response->setJSON([ - 'success' => false, - 'message' => 'Импорт XLSX файлов временно недоступен. Пожалуйста, используйте CSV формат.' - ]); - } - - $message = "Импортировано клиентов: $imported"; - if (!empty($errors)) { - $message .= '. Ошибок: ' . count($errors); - } - - return $this->response->setJSON([ - 'success' => true, - 'message' => $message, - 'imported' => $imported, - 'errors' => $errors - ]); - } }