first commit
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
// Подключаем роуты модуля Clients
|
||||
$routes->group('clients', ['filter' => 'org', 'namespace' => 'App\Modules\Clients\Controllers'], static function ($routes) {
|
||||
$routes->get('/', 'Clients::index');
|
||||
$routes->get('table', 'Clients::table'); // AJAX endpoint для таблицы
|
||||
$routes->get('new', 'Clients::new');
|
||||
$routes->post('create', 'Clients::create');
|
||||
$routes->get('edit/(:num)', 'Clients::edit/$1');
|
||||
$routes->post('update/(:num)', 'Clients::update/$1');
|
||||
$routes->get('delete/(:num)', 'Clients::delete/$1');
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Clients\Controllers;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Modules\Clients\Models\ClientModel;
|
||||
|
||||
class Clients extends BaseController
|
||||
{
|
||||
protected $clientModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->clientModel = new ClientModel();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$config = $this->getTableConfig();
|
||||
$data = $this->prepareTableData($config);
|
||||
|
||||
$data['title'] = 'Клиенты';
|
||||
|
||||
return $this->renderTwig($config['viewPath'], $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Конфигурация таблицы клиентов
|
||||
*/
|
||||
protected function getTableConfig(): array
|
||||
{
|
||||
$organizationId = session()->get('active_org_id');
|
||||
|
||||
return [
|
||||
'model' => $this->clientModel,
|
||||
'columns' => [
|
||||
'name' => ['label' => 'Имя / Название', 'width' => '40%'],
|
||||
'email' => ['label' => 'Email', 'width' => '25%'],
|
||||
'phone' => ['label' => 'Телефон', 'width' => '20%'],
|
||||
],
|
||||
'searchable' => ['name', 'email', 'phone'],
|
||||
'sortable' => ['name', 'email', 'phone', 'created_at'],
|
||||
'defaultSort' => 'name',
|
||||
'order' => 'asc',
|
||||
'viewPath' => '@Clients/index',
|
||||
'partialPath' => '@Clients/_table',
|
||||
'itemsKey' => 'clients',
|
||||
'scope' => function ($builder) use ($organizationId) {
|
||||
$builder->where('organization_id', $organizationId);
|
||||
},
|
||||
];
|
||||
}
|
||||
public function table()
|
||||
{
|
||||
return parent::table();
|
||||
}
|
||||
|
||||
public function new()
|
||||
{
|
||||
$data = [
|
||||
'title' => 'Добавить клиента',
|
||||
'client' => null,
|
||||
];
|
||||
|
||||
return $this->renderTwig('@Clients/form', $data);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$organizationId = session()->get('active_org_id');
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|min_length[2]|max_length[255]',
|
||||
'email' => 'permit_empty|valid_email',
|
||||
'phone' => 'permit_empty|max_length[50]',
|
||||
];
|
||||
|
||||
if (!$this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$this->clientModel->insert([
|
||||
'organization_id' => $organizationId,
|
||||
'name' => $this->request->getPost('name'),
|
||||
'email' => $this->request->getPost('email') ?? null,
|
||||
'phone' => $this->request->getPost('phone') ?? null,
|
||||
'notes' => $this->request->getPost('notes') ?? null,
|
||||
]);
|
||||
|
||||
if ($this->clientModel->errors()) {
|
||||
return redirect()->back()->withInput()->with('error', 'Ошибка при создании клиента');
|
||||
}
|
||||
|
||||
session()->setFlashdata('success', 'Клиент успешно добавлен');
|
||||
return redirect()->to('/clients');
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$organizationId = session()->get('active_org_id');
|
||||
|
||||
$client = $this->clientModel
|
||||
->where('id', $id)
|
||||
->where('organization_id', $organizationId)
|
||||
->first();
|
||||
|
||||
if (!$client) {
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Клиент не найден');
|
||||
}
|
||||
|
||||
$data = [
|
||||
'title' => 'Редактировать клиента',
|
||||
'client' => $client,
|
||||
];
|
||||
|
||||
return $this->renderTwig('@Clients/form', $data);
|
||||
}
|
||||
|
||||
public function update($id)
|
||||
{
|
||||
$organizationId = session()->get('active_org_id');
|
||||
|
||||
// Проверяем что клиент принадлежит организации
|
||||
$client = $this->clientModel
|
||||
->where('id', $id)
|
||||
->where('organization_id', $organizationId)
|
||||
->first();
|
||||
|
||||
if (!$client) {
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Клиент не найден');
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|min_length[2]|max_length[255]',
|
||||
'email' => 'permit_empty|valid_email',
|
||||
'phone' => 'permit_empty|max_length[50]',
|
||||
];
|
||||
|
||||
if (!$this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$this->clientModel->update($id, [
|
||||
'name' => $this->request->getPost('name'),
|
||||
'email' => $this->request->getPost('email') ?? null,
|
||||
'phone' => $this->request->getPost('phone') ?? null,
|
||||
'notes' => $this->request->getPost('notes') ?? null,
|
||||
]);
|
||||
|
||||
if ($this->clientModel->errors()) {
|
||||
return redirect()->back()->withInput()->with('error', 'Ошибка при обновлении клиента');
|
||||
}
|
||||
|
||||
session()->setFlashdata('success', 'Клиент успешно обновлён');
|
||||
return redirect()->to('/clients');
|
||||
}
|
||||
|
||||
public function delete($id)
|
||||
{
|
||||
$organizationId = session()->get('active_org_id');
|
||||
|
||||
// Проверяем что клиент принадлежит организации
|
||||
$client = $this->clientModel
|
||||
->where('id', $id)
|
||||
->where('organization_id', $organizationId)
|
||||
->first();
|
||||
|
||||
if (!$client) {
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Клиент не найден');
|
||||
}
|
||||
|
||||
$this->clientModel->delete($id);
|
||||
|
||||
session()->setFlashdata('success', 'Клиент удалён');
|
||||
return redirect()->to('/clients');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Clients\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class ClientModel extends Model
|
||||
{
|
||||
protected $table = 'organizations_clients';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = true;
|
||||
protected $allowedFields = ['organization_id', 'name', 'email', 'phone', 'notes'];
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
protected $deletedField = 'deleted_at';
|
||||
|
||||
/**
|
||||
* Получить клиентов организации
|
||||
*/
|
||||
public function forOrganization(int $organizationId)
|
||||
{
|
||||
return $this->where('organization_id', $organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск по имени или email
|
||||
*/
|
||||
public function search(int $organizationId, string $query = '')
|
||||
{
|
||||
$builder = $this->forOrganization($organizationId);
|
||||
|
||||
if (!empty($query)) {
|
||||
$builder->groupStart()
|
||||
->like('name', $query)
|
||||
->orLike('email', $query)
|
||||
->orLike('phone', $query)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
return $builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{# app/Modules/Clients/Views/_table.twig #}
|
||||
|
||||
{# Определяем тип запроса: AJAX = только tbody + footer #}
|
||||
{% set isAjax = app.request.headers.get('X-Requested-With') == 'XMLHttpRequest' %}
|
||||
|
||||
{# Настройки пагинации - ИСПОЛЬЗУЕМ pagerDetails напрямую #}
|
||||
{% if pagerDetails is defined %}
|
||||
{% set pagination = pagerDetails %}
|
||||
{% else %}
|
||||
{# Fallback если pagerDetails нет #}
|
||||
{% set pagination = {
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
total: clients|length|default(0),
|
||||
perPage: perPage|default(10),
|
||||
from: 1,
|
||||
to: clients|length|default(0)
|
||||
} %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
{# Проверка на пустое состояние #}
|
||||
{% set isEmpty = clients is empty or clients|length == 0 %}
|
||||
|
||||
{# AJAX запрос - tbody + footer #}
|
||||
<tbody>
|
||||
{% if isEmpty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-5">
|
||||
<i class="fa-solid fa-users text-muted mb-3" style="font-size: 3rem;"></i>
|
||||
<p class="text-muted mb-3">
|
||||
{% if filters.name or filters.email or filters.phone %}
|
||||
Клиенты не найдены
|
||||
{% else %}
|
||||
Клиентов пока нет
|
||||
{% endif %}
|
||||
</p>
|
||||
<a href="{{ base_url('/clients/new') }}" class="btn btn-primary">
|
||||
<i class="fa-solid fa-plus me-2"></i>Добавить клиента
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for client in clients %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-primary text-white rounded-circle d-flex justify-content-center align-items-center me-3" style="width:40px;height:40px;">
|
||||
{{ client.name|first|upper }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ client.name }}</strong>
|
||||
{% if client.notes %}
|
||||
<br><small class="text-muted">{{ client.notes|slice(0, 50) }}{{ client.notes|length > 50 ? '...' : '' }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if client.email %}
|
||||
<a href="mailto:{{ client.email }}">{{ client.email }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if client.phone %}
|
||||
<a href="tel:{{ client.phone }}">{{ client.phone }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ base_url('/clients/edit/' ~ client.id) }}" class="btn btn-sm btn-outline-primary" title="Редактировать">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</a>
|
||||
<a href="{{ base_url('/clients/delete/' ~ client.id) }}" class="btn btn-sm btn-outline-danger" title="Удалить" onclick="return confirm('Вы уверены что хотите удалить этого клиента?');">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
{{ include('@components/table/pagination.twig', { pagination: pagination, id: 'clients-table' }) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends 'layouts/base.twig' %}
|
||||
{% import 'macros/forms.twig' as forms %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<a href="{{ base_url('/clients') }}" class="btn btn-outline-secondary me-3">
|
||||
<i class="fa-solid fa-arrow-left"></i>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="h4 mb-0">{{ title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
{{ forms.form_open(client ? base_url('/clients/update/' ~ client.id) : base_url('/clients/create')) }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label fw-bold">Имя / Название *</label>
|
||||
<input type="text" name="name" id="name" class="form-control {{ errors.name ? 'is-invalid' : '' }}"
|
||||
value="{{ old.name ?? client.name ?? '' }}" required autofocus>
|
||||
{% if errors.name %}
|
||||
<div class="invalid-feedback">{{ errors.name }}</div>
|
||||
{% endif %}
|
||||
<div class="form-text">ФИО клиента или название компании</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" name="email" id="email" class="form-control {{ errors.email ? 'is-invalid' : '' }}"
|
||||
value="{{ old.email ?? client.email ?? '' }}">
|
||||
{% if errors.email %}
|
||||
<div class="invalid-feedback">{{ errors.email }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="phone" class="form-label">Телефон</label>
|
||||
<input type="tel" name="phone" id="phone" class="form-control {{ errors.phone ? 'is-invalid' : '' }}"
|
||||
value="{{ old.phone ?? client.phone ?? '' }}">
|
||||
{% if errors.phone %}
|
||||
<div class="invalid-feedback">{{ errors.phone }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="notes" class="form-label">Заметки</label>
|
||||
<textarea name="notes" id="notes" rows="4" class="form-control {{ errors.notes ? 'is-invalid' : '' }}"
|
||||
placeholder="Дополнительная информация о клиенте...">{{ old.notes ?? client.notes ?? '' }}</textarea>
|
||||
{% if errors.notes %}
|
||||
<div class="invalid-feedback">{{ errors.notes }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<a href="{{ base_url('/clients') }}" class="btn btn-secondary">Отмена</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa-solid fa-check me-2"></i>
|
||||
{{ client ? 'Сохранить изменения' : 'Добавить клиента' }}
|
||||
</button>
|
||||
</div>
|
||||
{{ forms.form_close() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,115 @@
|
||||
{% extends 'layouts/base.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0">Клиенты</h1>
|
||||
<p class="text-muted mb-0">Управление клиентами вашей организации</p>
|
||||
</div>
|
||||
<a href="{{ base_url('/clients/new') }}" class="btn btn-primary">
|
||||
<i class="fa-solid fa-plus me-2"></i>Добавить клиента
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white py-3">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="text-muted small">
|
||||
Нажмите на <i class="fa-solid fa-search text-muted"></i> для поиска по столбцу
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Формируем строки таблицы из клиентов #}
|
||||
{% set tableRows = [] %}
|
||||
{% if clients is defined and clients|length > 0 %}
|
||||
{% for client in clients %}
|
||||
{% set tableRows = tableRows|merge([{
|
||||
cells: [
|
||||
{
|
||||
content: '<div class="d-flex align-items-center">
|
||||
<div class="bg-primary text-white rounded-circle d-flex justify-content-center align-items-center me-3" style="width:40px;height:40px;">' ~ client.name|first|upper ~ '</div>
|
||||
<div><strong>' ~ client.name ~ '</strong>' ~ (client.notes ? '<br><small class="text-muted">' ~ client.notes|slice(0, 50) ~ (client.notes|length > 50 ? '...' : '') ~ '</small>') ~ '</div>
|
||||
</div>',
|
||||
class: ''
|
||||
},
|
||||
{
|
||||
content: client.email ? '<a href="mailto:' ~ client.email ~ '">' ~ client.email ~ '</a>' : '<span class="text-muted">—</span>',
|
||||
class: ''
|
||||
},
|
||||
{
|
||||
content: client.phone ? '<a href="tel:' ~ client.phone ~ '">' ~ client.phone ~ '</a>' : '<span class="text-muted">—</span>',
|
||||
class: ''
|
||||
}
|
||||
],
|
||||
actions: '<a href="' ~ base_url('/clients/edit/' ~ client.id) ~ '" class="btn btn-sm btn-outline-primary" title="Редактировать"><i class="fa-solid fa-pen"></i></a>
|
||||
<a href="' ~ base_url('/clients/delete/' ~ client.id) ~ '" class="btn btn-sm btn-outline-danger" title="Удалить" onclick="return confirm(\'Вы уверены что хотите удалить этого клиента?\');"><i class="fa-solid fa-trash"></i></a>'
|
||||
}]) %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div id="clients-table">
|
||||
{{ include('@components/table/table.twig', {
|
||||
id: 'clients-table',
|
||||
url: '/clients/table',
|
||||
perPage: perPage|default(10),
|
||||
sort: sort|default(''),
|
||||
order: order|default('asc'),
|
||||
filters: filters|default({}),
|
||||
columns: {
|
||||
name: { label: 'Имя / Название', width: '40%' },
|
||||
email: { label: 'Email', width: '25%' },
|
||||
phone: { label: 'Телефон', width: '20%' }
|
||||
},
|
||||
rows: tableRows,
|
||||
pagerDetails: {
|
||||
currentPage: pagerDetails.currentPage|default(1),
|
||||
pageCount: pagerDetails.pageCount|default(1),
|
||||
total: pagerDetails.total|default(0),
|
||||
perPage: perPage|default(10),
|
||||
from: pagerDetails.from|default(1),
|
||||
to: pagerDetails.to|default(clients|length|default(0))
|
||||
},
|
||||
actions: { label: 'Действия', width: '15%' },
|
||||
emptyMessage: 'Клиентов пока нет',
|
||||
emptyIcon: 'fa-solid fa-users',
|
||||
emptyActionUrl: base_url('/clients/new'),
|
||||
emptyActionLabel: 'Добавить клиента',
|
||||
emptyActionIcon: 'fa-solid fa-plus'
|
||||
}) }}
|
||||
{# CSRF токен для AJAX запросов #}
|
||||
{{ csrf_field()|raw }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ parent() }}
|
||||
<script src="/assets/js/modules/DataTable.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.data-table').forEach(function(container) {
|
||||
const id = container.id;
|
||||
const url = container.dataset.url;
|
||||
const perPage = parseInt(container.dataset.perPage) || 10;
|
||||
|
||||
if (window.dataTables && window.dataTables[id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const table = new DataTable(id, {
|
||||
url: url,
|
||||
perPage: perPage
|
||||
});
|
||||
|
||||
window.dataTables = window.dataTables || {};
|
||||
window.dataTables[id] = table;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user