Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.86% covered (success)
90.86%
338 / 372
50.00% covered (danger)
50.00%
8 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
GridWidgetManagementApiController
90.84% covered (success)
90.84%
337 / 371
50.00% covered (danger)
50.00%
8 / 16
110.01
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isClientModule
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
4.84
 resolveModuleLabel
28.57% covered (danger)
28.57%
4 / 14
0.00% covered (danger)
0.00%
0 / 1
55.10
 actionAvailableSources
82.05% covered (warning)
82.05%
32 / 39
0.00% covered (danger)
0.00%
0 / 1
16.30
 actionCatalogWidgets
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
10
 resolveCatalogWidgetLabel
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
9.89
 resolveCatalogWidgetIcon
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
9.89
 actionFieldsAndFilters
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
7
 actionPreview
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 actionCreate
96.99% covered (success)
96.99%
129 / 133
0.00% covered (danger)
0.00%
0 / 1
18
 actionDelete
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 actionData
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 resolveWidgetMetadata
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 resolveNextGridstackY
64.29% covered (warning)
64.29%
9 / 14
0.00% covered (danger)
0.00%
0 / 1
3.41
 jsonResponse
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 errorResponse
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Engine\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\ModuleMetadata;
12use App\Core\Engine\Domain\Model\PermissionContext;
13use App\Core\Engine\Domain\Model\RelationGridMetadata;
14use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
15use App\Core\Grid\Widget\Application\WidgetAggregationDataServiceInterface;
16use App\Core\Grid\Widget\Application\WidgetListQueryServiceInterface;
17use App\Core\Grid\Widget\WidgetRegistry;
18use App\Core\Instance\Application\Service\InstanceContextManagerInterface;
19use App\Shared\Infrastructure\Http\ApiResponseTrait;
20use PDO;
21use Psr\Http\Message\ResponseFactoryInterface;
22use Psr\Http\Message\ResponseInterface;
23use Psr\Http\Message\ServerRequestInterface;
24use Yiisoft\Translator\TranslatorInterface;
25
26/**
27 * Grid Widget Management REST API Controller.
28 *
29 * Provides REST endpoints for widget builder discovery, live preview generation,
30 * widget placement creation, position updates, and deletion.
31 *
32 * @package App\Core\Engine\Presentation\Api
33 */
34final readonly class GridWidgetManagementApiController
35{
36    use ApiResponseTrait;
37
38    private const int DEFAULT_TILE_WIDTH = 6;
39    private const int DEFAULT_TILE_HEIGHT = 6;
40
41    /** @var list<string> Operational end-user client SaaS modules. */
42    public const array CLIENT_MODULES = [
43        'dashboard',
44        'calendar',
45        'work_time',
46        'documents',
47        'contacts',
48        'companies',
49        'partners',
50        'opportunities',
51        'quotes',
52        'orders',
53        'projects',
54        'project_stages',
55        'project_tasks',
56        'tickets',
57        'contracts',
58        'products',
59        'services',
60        'sold_products',
61        'sold_services',
62        'emails',
63        'comments',
64        'accounts',
65    ];
66
67    /** @var array<string, string> Fallback localized Polish module display labels. */
68    private const array MODULE_TRANSLATIONS = [
69        'calendar'              => 'Kalendarz',
70        'work_time'             => 'Czas pracy',
71        'documents'             => 'Dokumenty',
72        'contacts'              => 'Kontakty',
73        'companies'             => 'Firmy',
74        'partners'              => 'Partnerzy',
75        'opportunities'         => 'Szanse sprzedaży',
76        'quotes'                => 'Oferty',
77        'orders'                => 'Zamówienia',
78        'projects'              => 'Projekty',
79        'project_stages'        => 'Etapy projektów',
80        'project_tasks'         => 'Zadania projektowe',
81        'tickets'               => 'Zgłoszenia',
82        'contracts'             => 'Umowy',
83        'products'              => 'Produkty',
84        'services'              => 'Usługi',
85        'sold_products'         => 'Sprzedane produkty',
86        'sold_services'         => 'Sprzedane usługi',
87        'emails'                => 'Wiadomości e-mail',
88        'comments'              => 'Komentarze',
89        'accounts'              => 'Konta',
90        'dashboard'             => 'Pulpit',
91        'system_users'          => 'Użytkownicy',
92        'system_administrators' => 'Administratorzy',
93        'system_profiles'       => 'Profile uprawnień',
94        'system_structure'      => 'Struktura organizacyjna',
95        'system_modules'        => 'Moduły',
96        'system_fields'         => 'Pola systemowe',
97        'system_sections'       => 'Sekcje formularza',
98        'system_uitypes'        => 'Typy pól',
99        'system_picklists'      => 'Słowniki',
100        'system_prefixes'       => 'Prefiksy numeracji',
101        'system_menu'           => 'Menu systemowe',
102        'system_languages'      => 'Języki',
103        'system_translations'   => 'Tłumaczenia',
104        'system_filters'        => 'Filtry listy',
105        'system_filters_grid'   => 'Filtry siatki',
106        'system_widgets'        => 'Widżety',
107        'system_actions'        => 'Akcje',
108        'system_relations_1m'   => 'Relacje 1:M',
109        'system_relations_mm'   => 'Relacje M:M',
110        'system_relations_grid' => 'Relacje GRID',
111        'automation_cron'       => 'Zadania CRON',
112        'automation_queue'      => 'Kolejka zadań',
113        'mail_smtp'             => 'Serwery SMTP',
114        'mail_templates'        => 'Szablony e-mail',
115        'mail_servers'          => 'Serwery pocztowe',
116        'mail_queue'            => 'Kolejka poczty',
117        'pdf_templates'         => 'Szablony PDF',
118        'client_mailboxes'      => 'Skrzynki pocztowe',
119        'client_mail_templates' => 'Szablony mailowe',
120        'taxes'                 => 'Podatki i VAT',
121        'discounts'             => 'Rabaty i oferty',
122        'currencies'            => 'Waluty',
123        'settings_parameters'   => 'Parametry systemu',
124        'about_licenses'        => 'Licencje',
125        'about_requirements'    => 'Wymagania systemowe',
126        'logs_auth'             => 'Logi logowania',
127        'logs_sessions'         => 'Logi sesji',
128        'logs_cron'             => 'Logi zadań Cron',
129        'logs_api'              => 'Logi API',
130        'logs_audit_create'     => 'Audyt: Tworzenie',
131        'logs_audit_read'       => 'Audyt: Odczyt',
132        'logs_audit_update'     => 'Audyt: Modyfikacja',
133        'logs_audit_delete'     => 'Audyt: Usunięcie',
134        'workflows'             => 'Przepływy pracy',
135        'server_ntp'            => 'Serwery NTP',
136        'server_dns'            => 'Serwery DNS',
137        'integrations'          => 'Integracje',
138        'user_password_resets'  => 'Dziennik resetu haseł',
139    ];
140
141    /**
142     * GridWidgetManagementApiController constructor.
143     *
144     * @param MetadataRepositoryInterface           $metadataRepository     Metadata repository.
145     * @param WidgetAggregationDataServiceInterface $aggregationService     Aggregation data service.
146     * @param WidgetListQueryServiceInterface       $listQueryService       List widget query service.
147     * @param WidgetRegistry                        $widgetRegistry         Widget registry.
148     * @param ResponseFactoryInterface              $responseFactory        PSR-17 response factory.
149     * @param PDO                                   $pdo                    Database connection.
150     * @param string                                $appProfile             Active application deployment profile.
151     * @param InstanceContextManagerInterface|null  $instanceContextManager Instance context manager.
152     * @param TranslatorInterface|null              $translator             Optional translator service.
153     */
154    public function __construct(
155        private MetadataRepositoryInterface           $metadataRepository,
156        private WidgetAggregationDataServiceInterface $aggregationService,
157        private WidgetListQueryServiceInterface       $listQueryService,
158        private WidgetRegistry                        $widgetRegistry,
159        private ResponseFactoryInterface              $responseFactory,
160        private PDO                                   $pdo,
161        private string                                $appProfile = 'admin',
162        private ?InstanceContextManagerInterface      $instanceContextManager = null,
163        private ?TranslatorInterface                  $translator = null
164    ) {
165    }
166
167    /**
168     * Checks whether the specified module is an operational client SaaS module.
169     *
170     * @param ModuleMetadata $module Module metadata.
171     * @return bool True if module belongs to client SaaS scope.
172     */
173    public static function isClientModule(ModuleMetadata $module): bool
174    {
175        $cleanName = strtolower(str_replace([' ', '-'], '_', $module->name));
176        if (in_array($cleanName, self::CLIENT_MODULES, true)) {
177            return true;
178        }
179
180        if (str_starts_with($module->tableName, 'c_')) {
181            if (in_array($cleanName, ['system_users', 'system_structure', 'users', 'structure'], true)) {
182                return false;
183            }
184            return true;
185        }
186
187        return false;
188    }
189
190    /**
191     * Resolves localized Polish display label for a module.
192     *
193     * @param ModuleMetadata $module Module metadata entity.
194     * @return string Localized display label.
195     */
196    public function resolveModuleLabel(ModuleMetadata $module): string
197    {
198        $cleanName = strtolower(str_replace([' ', '-'], '_', $module->name));
199
200        if ($this->translator !== null) {
201            $transLabel = $this->translator->translate($module->label, [], 'menu', 'pl');
202            if ($transLabel !== null && $transLabel !== $module->label && $transLabel !== '') {
203                return $transLabel;
204            }
205            $transName = $this->translator->translate($module->name, [], 'menu', 'pl');
206            if ($transName !== null && $transName !== $module->name && $transName !== '') {
207                return $transName;
208            }
209        }
210
211        if (isset(self::MODULE_TRANSLATIONS[$cleanName])) {
212            return self::MODULE_TRANSLATIONS[$cleanName];
213        }
214
215        $labelLower = strtolower(str_replace([' ', '-'], '_', $module->label));
216        if (isset(self::MODULE_TRANSLATIONS[$labelLower])) {
217            return self::MODULE_TRANSLATIONS[$labelLower];
218        }
219
220        return $module->label !== '' ? $module->label : $module->name;
221    }
222
223    /**
224     * Returns list of modules eligible as widget data sources with their filters.
225     *
226     * @param PermissionContext $context Actor security context.
227     * @return ResponseInterface JSON response.
228     */
229    public function actionAvailableSources(PermissionContext $context): ResponseInterface
230    {
231        $isRemote = $this->instanceContextManager !== null && $this->instanceContextManager->isRemote();
232        $effectiveProfile = $isRemote ? 'client' : $this->appProfile;
233        $isClientContext = ($effectiveProfile === 'client');
234        $activeHostId = $isClientContext ? 2 : 1;
235
236        $allModules = $this->metadataRepository->findAllActiveModules();
237        $sources = [];
238
239        foreach ($allModules as $module) {
240            if ($module->name === 'dashboard' || $module->name === 'system_modules' || !$module->hasGrid) {
241                continue;
242            }
243
244            $isClient = self::isClientModule($module);
245            if ($isClientContext) {
246                if (!$isClient || !$module->isAvailableForHost($activeHostId)) {
247                    continue;
248                }
249            } else {
250                if ($isClient || !$module->isAvailableForHost($activeHostId)) {
251                    continue;
252                }
253            }
254
255            $filters = $this->metadataRepository->findModuleFilters($module->id, $context);
256            $filterItems = [];
257            foreach ($filters as $f) {
258                $filterItems[] = [
259                    'id'         => $f->id,
260                    'name'       => $f->name,
261                    'label'      => $f->label !== '' ? $f->label : $f->name,
262                    'is_default' => $f->isDefault,
263                ];
264            }
265
266            $sources[] = [
267                'id'         => $module->id,
268                'name'       => $module->name,
269                'label'      => $this->resolveModuleLabel($module),
270                'icon'       => $module->iconClass ?? 'bi bi-boxes',
271                'has_list'   => $module->hasList,
272                'filters'    => $filterItems,
273            ];
274        }
275
276        usort($sources, static fn(array $a, array $b): int => strcoll(
277            mb_strtolower($a['label']),
278            mb_strtolower($b['label'])
279        ));
280
281        return $this->jsonResponse([
282            'status'  => true,
283            'message' => 'Available widget sources retrieved.',
284            'data'    => ['sources' => $sources],
285        ]);
286    }
287
288    /**
289     * Returns list of available registered widgets from system catalog for current context.
290     *
291     * @param PermissionContext $context Actor security context.
292     * @return ResponseInterface JSON response.
293     */
294    public function actionCatalogWidgets(PermissionContext $context): ResponseInterface
295    {
296        $isRemote = $this->instanceContextManager !== null && $this->instanceContextManager->isRemote();
297        $effectiveProfile = $isRemote ? 'client' : $this->appProfile;
298        $isClientContext = ($effectiveProfile === 'client');
299
300        $stmt = $this->pdo->prepare('
301            SELECT `id`, `name`, `label`, `category`, `handler_class`,
302                   `default_w`, `default_h`, `min_w`, `max_w`, `min_h`, `max_h`,
303                   `description`, `default_params`
304            FROM `a_core_widget_records`
305            WHERE `is_active` = 1 AND `special_access` <= 1
306            ORDER BY `id` ASC
307        ');
308        $stmt->execute();
309        /** @var list<array<string, mixed>> $rows */
310        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
311
312        $catalog = [];
313        foreach ($rows as $row) {
314            $name = (string) $row['name'];
315            if (str_starts_with($name, 'wdg_') || in_array($name, ['custom_chart', 'custom_list'], true)) {
316                continue;
317            }
318
319            if ($isClientContext) {
320                if (in_array($name, ['system_diagnostics', 'security_audit'], true)) {
321                    continue;
322                }
323            } else {
324                if (in_array($name, ['current_calendar_events', 'overdue_calendar_events'], true)) {
325                    continue;
326                }
327            }
328
329            $label = $this->resolveCatalogWidgetLabel($name, (string) $row['label']);
330            $desc = (string) ($row['description'] ?? '');
331            $icon = $this->resolveCatalogWidgetIcon($name, (string) $row['category']);
332
333            $catalog[] = [
334                'id'          => (int) $row['id'],
335                'name'        => $name,
336                'label'       => $label,
337                'category'    => (string) $row['category'],
338                'description' => $desc,
339                'icon'        => $icon,
340                'default_w'   => (int) ($row['default_w'] ?? 6),
341                'default_h'   => (int) ($row['default_h'] ?? 6),
342            ];
343        }
344
345        return $this->jsonResponse([
346            'status'  => true,
347            'message' => 'Catalog widgets retrieved.',
348            'data'    => ['widgets' => $catalog],
349        ]);
350    }
351
352    /**
353     * Resolves human-readable Polish label for a catalog widget.
354     *
355     * @param string $name Widget machine name.
356     * @param string $fallback Fallback database label.
357     * @return string Localized display label.
358     */
359    private function resolveCatalogWidgetLabel(string $name, string $fallback): string
360    {
361        return match ($name) {
362            'current_calendar_events' => 'Sprawy bieżące',
363            'overdue_calendar_events' => 'Sprawy zaległe',
364            'system_diagnostics'      => 'Wymagania i diagnostyka',
365            'security_audit'          => 'Audyt bezpieczeństwa',
366            'virtual_table'           => 'Wirtualna tabela',
367            'physical_table'          => 'Fizyczna tabela',
368            default                   => $fallback !== '' ? $fallback : $name,
369        };
370    }
371
372    /**
373     * Resolves visual icon class for catalog widget.
374     *
375     * @param string $name Widget machine name.
376     * @param string $category Widget category.
377     * @return string CSS icon class.
378     */
379    private function resolveCatalogWidgetIcon(string $name, string $category): string
380    {
381        return match ($name) {
382            'current_calendar_events' => 'bi bi-calendar-check text-primary',
383            'overdue_calendar_events' => 'bi bi-exclamation-triangle-fill text-danger',
384            'system_diagnostics'      => 'bi bi-cpu text-info',
385            'security_audit'          => 'bi bi-shield-check text-warning',
386            'virtual_table'           => 'bi bi-table text-success',
387            'physical_table'          => 'bi bi-database text-secondary',
388            default                   => $category === 'chart' ? 'bi bi-bar-chart-line' : 'bi bi-grid-3x3',
389        };
390    }
391
392    /**
393     * Returns categorical and numeric fields for the selected source module.
394     *
395     * @param ServerRequestInterface $request Incoming HTTP request.
396     * @param PermissionContext      $context Actor security context.
397     * @return ResponseInterface JSON response.
398     */
399    public function actionFieldsAndFilters(
400        ServerRequestInterface $request,
401        PermissionContext $context
402    ): ResponseInterface {
403        $queryParams = $request->getQueryParams();
404        $moduleName = (string) ($queryParams['module'] ?? '');
405
406        if ($moduleName === '') {
407            return $this->errorResponse('Parameter "module" is required.', 400);
408        }
409
410        $module = $this->metadataRepository->findModule($moduleName);
411        $fields = $this->metadataRepository->findFields($module->id);
412        $filters = $this->metadataRepository->findModuleFilters($module->id, $context);
413
414        $categorical = [];
415        $numeric = [];
416        $dates = [];
417
418        foreach ($fields as $field) {
419            $uiType = strtolower($field->uitypeName);
420            $isNum = in_array($uiType, ['integer', 'decimal', 'currency', 'percentage'], true);
421            $isDate = in_array($uiType, ['date', 'datetime', 'time'], true);
422
423            $fieldItem = [
424                'key'      => $field->fieldKey,
425                'column'   => $field->columnExpression,
426                'label'    => $field->label !== '' ? $field->label : $field->fieldKey,
427                'uitype'   => $field->uitypeName,
428            ];
429
430            if ($isNum) {
431                $numeric[] = $fieldItem;
432            } elseif ($isDate) {
433                $dates[] = $fieldItem;
434            } else {
435                $categorical[] = $fieldItem;
436            }
437        }
438
439        $filterList = array_map(static fn($f): array => [
440            'id'         => $f->id,
441            'name'       => $f->name,
442            'label'      => $f->label !== '' ? $f->label : $f->name,
443            'is_default' => $f->isDefault,
444        ], $filters);
445
446        return $this->jsonResponse([
447            'status' => true,
448            'data'   => [
449                'categorical_fields' => $categorical,
450                'numeric_fields'     => $numeric,
451                'date_fields'        => $dates,
452                'filters'            => $filterList,
453            ],
454        ]);
455    }
456
457    /**
458     * Generates live preview of chart or list widget.
459     *
460     * @param ServerRequestInterface $request Server request.
461     * @param PermissionContext      $context Actor security context.
462     * @return ResponseInterface JSON response.
463     */
464    public function actionPreview(ServerRequestInterface $request, PermissionContext $context): ResponseInterface
465    {
466        $body = (string) $request->getBody();
467        /** @var array<string, mixed> $data */
468        $data = json_decode($body, true) ?? [];
469        $widgetType = (string) ($data['widget_type'] ?? 'custom_chart');
470
471        if ($widgetType === 'custom_list') {
472            $listData = $this->listQueryService->fetchListWidgetData($data, $context);
473            return $this->jsonResponse([
474                'status' => true,
475                'data'   => ['list_data' => $listData],
476            ]);
477        }
478
479        $chartData = $this->aggregationService->computeChartData($data, $context);
480        return $this->jsonResponse([
481            'status' => true,
482            'data'   => [
483                'chart_data' => $chartData,
484                'chart_type' => (string) ($data['chart_type'] ?? 'bar'),
485            ],
486        ]);
487    }
488
489    /**
490     * Creates and persists a new widget relation tile on the grid.
491     *
492     * @param ServerRequestInterface $request Server request.
493     * @param PermissionContext      $context Actor security context.
494     * @return ResponseInterface JSON response.
495     */
496    public function actionCreate(ServerRequestInterface $request, PermissionContext $context): ResponseInterface
497    {
498        $body = (string) $request->getBody();
499        /** @var array<string, mixed> $data */
500        $data = json_decode($body, true) ?? [];
501
502        $targetModuleName = (string) ($data['target_module'] ?? 'dashboard');
503        $targetModule = $this->metadataRepository->findModule($targetModuleName);
504        $widgetType = (string) ($data['widget_type'] ?? 'custom_chart');
505
506        $isCatalogWidget = ($widgetType === 'catalog_widget' || !empty($data['catalog_widget_id']));
507
508        if ($isCatalogWidget) {
509            $catId = (int) ($data['catalog_widget_id'] ?? 0);
510            $catStmt = $this->pdo->prepare('
511                SELECT `id`, `name`, `label`, `category`, `handler_class`, `default_w`, `default_h`, `default_params`
512                FROM `a_core_widget_records`
513                WHERE `id` = :id LIMIT 1
514            ');
515            $catStmt->execute([':id' => $catId]);
516            /** @var array<string, mixed>|false $catRow */
517            $catRow = $catStmt->fetch(PDO::FETCH_ASSOC);
518            if (!$catRow) {
519                return $this->errorResponse("Catalog widget #{$catId} not found.", 404);
520            }
521
522            $newWidgetId = (int) $catRow['id'];
523            $uniqueWidgetName = (string) $catRow['name'];
524            $category = (string) $catRow['category'];
525            $handlerClass = (string) $catRow['handler_class'];
526            $description = (string) ($catRow['description'] ?? $uniqueWidgetName);
527            $label = trim((string) ($data['label'] ?? $data['title'] ?? ''));
528            if ($label === '') {
529                $label = $this->resolveCatalogWidgetLabel($uniqueWidgetName, (string) $catRow['label']);
530            }
531        } else {
532            $widgetRecord = $this->resolveWidgetMetadata($widgetType);
533            if ($widgetRecord === null) {
534                return $this->errorResponse("Widget type '{$widgetType}' not found in catalog.", 404);
535            }
536
537            $label = trim((string) ($data['label'] ?? $data['title'] ?? 'Nowy widżet'));
538            if ($label === '') {
539                $label = $widgetRecord->label;
540            }
541
542            $uniqueWidgetName = 'wdg_' . $targetModule->name . '_' . time() . '_' . random_int(100, 999);
543            $category = $widgetType === 'custom_list' ? 'list' : 'chart';
544            $handlerClass = $widgetRecord->handlerClass;
545            $targetLabel = $targetModule->label !== '' ? $targetModule->label : $targetModule->name;
546            $description = 'Custom ' . $category . ' widget for ' . $targetLabel;
547        }
548
549        $width = max(3, min(12, (int) ($data['width'] ?? $data['tile_width'] ?? self::DEFAULT_TILE_WIDTH)));
550        $height = max(3, min(12, (int) ($data['height'] ?? $data['tile_height'] ?? self::DEFAULT_TILE_HEIGHT)));
551
552        $gridFilterId = isset($data['grid_filter_id']) ? (int) $data['grid_filter_id'] : null;
553        if ($gridFilterId === null || $gridFilterId <= 0) {
554            $defaultGridFilter = $this->metadataRepository->findFilterGrid($targetModule->id, null);
555            $gridFilterId = $defaultGridFilter->id;
556        }
557
558        $widgetParams = is_array($data['widget_params'] ?? null) ? $data['widget_params'] : [];
559        $paramsJson = (string) json_encode($widgetParams, JSON_UNESCAPED_UNICODE);
560
561        $userId = $context->actorUserId > 0 ? $context->actorUserId : 1;
562
563        if (!$isCatalogWidget) {
564            $widgetStmt = $this->pdo->prepare('
565                INSERT INTO `a_core_widget_records`
566                    (`name`, `label`, `category`, `handler_class`,
567                     `default_w`, `default_h`, `min_w`, `max_w`, `min_h`, `max_h`,
568                     `is_resizable`, `is_movable`, `is_active`, `is_system`,
569                     `description`, `default_params`, `special_access`, `created_by`, `owner`)
570                VALUES
571                    (:name, :label, :category, :handler_class,
572                     :default_w, :default_h, 3, 12, 3, 12,
573                     1, 1, 1, 0,
574                     :description, :default_params, 1, :created_by, :owner)
575            ');
576            $widgetStmt->execute([
577                ':name'           => $uniqueWidgetName,
578                ':label'          => $label,
579                ':category'       => $category,
580                ':handler_class'  => $handlerClass,
581                ':default_w'      => $width,
582                ':default_h'      => $height,
583                ':description'    => $description,
584                ':default_params' => $paramsJson,
585                ':created_by'     => $userId,
586                ':owner'          => $userId,
587            ]);
588            $newWidgetId = (int) $this->pdo->lastInsertId();
589        }
590
591        $uniqueName = 'rel_' . $targetModule->name . '_' . time() . '_' . random_int(100, 999);
592        $nextY = $this->resolveNextGridstackY($targetModule->id, $gridFilterId);
593
594        $stmt = $this->pdo->prepare('
595            INSERT INTO `a_core_relation_grid_records`
596                (`name`, `label`, `module_id`, `grid_filter_id`, `widget_id`,
597                 `pos_x`, `pos_y`, `width`, `height`, `is_locked`, `widget_params`,
598                 `is_active`, `sort_order`, `special_access`, `created_by`, `owner`)
599            VALUES
600                (:name, :label, :module_id, :grid_filter_id, :widget_id,
601                 :pos_x, :pos_y, :width, :height, 0, :widget_params,
602                 1, 10, 1, :created_by, :owner)
603        ');
604
605        $stmt->execute([
606            ':name'           => $uniqueName,
607            ':label'          => $label,
608            ':module_id'      => $targetModule->id,
609            ':grid_filter_id' => $gridFilterId,
610            ':widget_id'      => $newWidgetId,
611            ':pos_x'          => 0,
612            ':pos_y'          => $nextY,
613            ':width'          => $width,
614            ':height'         => $height,
615            ':widget_params'  => $paramsJson,
616            ':created_by'     => $userId,
617            ':owner'          => $userId,
618        ]);
619
620        $newRelationId = (int) $this->pdo->lastInsertId();
621        $this->metadataRepository->clearCache();
622
623        $relations = $this->metadataRepository->findGridRelationsByModule($targetModule->id, $gridFilterId);
624        $createdRelation = null;
625        foreach ($relations as $r) {
626            if ($r->id === $newRelationId) {
627                $createdRelation = $r;
628                break;
629            }
630        }
631
632        if ($createdRelation === null) {
633            $createdWidget = new \App\Core\Engine\Domain\Model\WidgetMetadata(
634                id: $newWidgetId,
635                name: $uniqueWidgetName,
636                label: $label,
637                category: $category,
638                handlerClass: $handlerClass,
639                defaultW: $width,
640                defaultH: $height,
641                isResizable: true,
642                isMovable: true,
643                isActive: true,
644                isSystem: false,
645                description: $description,
646                defaultParams: $widgetParams
647            );
648
649            $createdRelation = new RelationGridMetadata(
650                id: $newRelationId,
651                name: $uniqueName,
652                label: $label,
653                moduleId: $targetModule->id,
654                gridFilterId: $gridFilterId,
655                widgetId: $newWidgetId,
656                posX: 0,
657                posY: $nextY,
658                width: $width,
659                height: $height,
660                isLocked: false,
661                widgetParams: $widgetParams,
662                isActive: true,
663                sortOrder: 10,
664                widget: $createdWidget,
665                moduleName: $targetModule->name
666            );
667        }
668
669        $html = $this->widgetRegistry->renderWidget($createdRelation, $request, $context);
670
671        return $this->jsonResponse([
672            'status'  => true,
673            'message' => 'Widget created successfully.',
674            'data'    => [
675                'relation_id' => $newRelationId,
676                'name'        => $uniqueName,
677                'label'       => $label,
678                'pos_x'       => 0,
679                'pos_y'       => $nextY,
680                'width'       => $width,
681                'height'      => $height,
682                'html'        => $html,
683                'tile_html'   => $html,
684            ],
685        ], 201);
686    }
687
688    /**
689     * Removes a widget relation tile from the grid.
690     *
691     * @param int               $relationId Relation primary key.
692     * @param PermissionContext $context    Actor security context.
693     * @return ResponseInterface JSON response.
694     */
695    public function actionDelete(int $relationId, PermissionContext $context): ResponseInterface
696    {
697        $stmt = $this->pdo->prepare('
698            SELECT `id`, `widget_id`, `owner`, `created_by`
699            FROM `a_core_relation_grid_records`
700            WHERE `id` = :id
701            LIMIT 1
702        ');
703        $stmt->execute([':id' => $relationId]);
704        $row = $stmt->fetch(PDO::FETCH_ASSOC);
705
706        if (!$row) {
707            return $this->errorResponse('Widget relation not found.', 404);
708        }
709
710        $ownerId = (int) ($row['owner'] ?? 0);
711        $creatorId = (int) ($row['created_by'] ?? 0);
712        if (!$context->isSuperuser && $context->actorUserId !== $ownerId && $context->actorUserId !== $creatorId) {
713            return $this->errorResponse('Forbidden: You lack permission to delete this widget.', 403);
714        }
715
716        $widgetId = (int) ($row['widget_id'] ?? 0);
717        $wStmt = $this->pdo->prepare('SELECT `is_system` FROM `a_core_widget_records` WHERE `id` = :id');
718        $wStmt->execute([':id' => $widgetId]);
719        $wRow = $wStmt->fetch(PDO::FETCH_ASSOC);
720        $isSystem = (int) ($wRow['is_system'] ?? 1);
721
722        $delStmt = $this->pdo->prepare('DELETE FROM `a_core_relation_grid_records` WHERE `id` = :id');
723        $delStmt->execute([':id' => $relationId]);
724
725        if ($isSystem === 0) {
726            $delW = $this->pdo->prepare('DELETE FROM `a_core_widget_records` WHERE `id` = :id');
727            $delW->execute([':id' => $widgetId]);
728        }
729
730        $this->metadataRepository->clearCache();
731
732        return $this->jsonResponse([
733            'status'  => true,
734            'message' => 'Widget tile removed successfully.',
735            'data'    => ['relation_id' => $relationId],
736        ]);
737    }
738
739    /**
740     * Fetches refreshed data for an existing chart widget relation tile.
741     *
742     * @param int               $relationId Relation primary key.
743     * @param PermissionContext $context    Actor security context.
744     * @return ResponseInterface JSON response.
745     */
746    public function actionData(int $relationId, PermissionContext $context): ResponseInterface
747    {
748        $stmt = $this->pdo->prepare('
749            SELECT `widget_params`
750            FROM `a_core_relation_grid_records`
751            WHERE `id` = :id
752            LIMIT 1
753        ');
754        $stmt->execute([':id' => $relationId]);
755        $row = $stmt->fetch(PDO::FETCH_ASSOC);
756
757        if (!$row) {
758            return $this->errorResponse('Widget relation not found.', 404);
759        }
760
761        /** @var array<string, mixed> $params */
762        $params = json_decode((string) ($row['widget_params'] ?? '{}'), true) ?? [];
763        $chartData = $this->aggregationService->computeChartData($params, $context);
764
765        return $this->jsonResponse([
766            'status' => true,
767            'data'   => [
768                'chart_data' => $chartData,
769                'chart_type' => (string) ($params['chart_type'] ?? 'bar'),
770            ],
771        ]);
772    }
773
774    /**
775     * Resolves matching WidgetMetadata by machine name from active catalog.
776     *
777     * @param string $name
778     * @return \App\Core\Engine\Domain\Model\WidgetMetadata|null
779     */
780    private function resolveWidgetMetadata(string $name): ?\App\Core\Engine\Domain\Model\WidgetMetadata
781    {
782        $allWidgets = $this->metadataRepository->findAllActiveWidgets();
783        foreach ($allWidgets as $w) {
784            if ($w->name === $name) {
785                return $w;
786            }
787        }
788
789        return null;
790    }
791
792    /**
793     * Resolves next available Y position on Gridstack canvas.
794     *
795     * @param int      $moduleId
796     * @param int|null $gridFilterId
797     * @return int
798     */
799    private function resolveNextGridstackY(int $moduleId, ?int $gridFilterId): int
800    {
801        if ($gridFilterId !== null && $gridFilterId > 0) {
802            $stmt = $this->pdo->prepare('
803                SELECT COALESCE(MAX(`pos_y` + `height`), 0) AS `max_y`
804                FROM `a_core_relation_grid_records`
805                WHERE `module_id` = :module_id AND `grid_filter_id` = :grid_filter_id
806            ');
807            $stmt->execute([
808                ':module_id'      => $moduleId,
809                ':grid_filter_id' => $gridFilterId,
810            ]);
811        } else {
812            $stmt = $this->pdo->prepare('
813                SELECT COALESCE(MAX(`pos_y` + `height`), 0) AS `max_y`
814                FROM `a_core_relation_grid_records`
815                WHERE `module_id` = :module_id AND `grid_filter_id` IS NULL
816            ');
817            $stmt->execute([
818                ':module_id' => $moduleId,
819            ]);
820        }
821        $row = $stmt->fetch(PDO::FETCH_ASSOC);
822
823        return (int) ($row['max_y'] ?? 0);
824    }
825
826    /**
827     * Creates a JSON HTTP response.
828     *
829     * @param array<string, mixed> $payload Response payload.
830     * @param int                  $status  HTTP status code.
831     * @return ResponseInterface
832     */
833    private function jsonResponse(array $payload, int $status = 200): ResponseInterface
834    {
835        $response = $this->responseFactory->createResponse($status);
836        $response->getBody()->write((string) json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
837        return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
838    }
839
840    /**
841     * Creates an error JSON response.
842     *
843     * @param string $message Error message.
844     * @param int    $status  HTTP status code.
845     * @return ResponseInterface
846     */
847    private function errorResponse(string $message, int $status = 400): ResponseInterface
848    {
849        return $this->jsonResponse([
850            'status'  => false,
851            'message' => $message,
852            'data'    => [],
853        ], $status);
854    }
855}