feat(helpdesk): add Software Products page with BC contract type sync
Add a new Software-Produkte feature to the helpdesk module that syncs contract types (Create_SaaS_License=true) from BC OData into a local database table with a nightly scheduler job, providing a Grid.js list page and detail/edit page for managing custom product names. - DB migration 003: helpdesk_software_products table (code UNIQUE key) - BcODataGateway: FS_Contract_Types entity with SaaS filter + fallback - SoftwareProductRepository: upsert, listPaged, softDelete, updateName - SoftwareProductSyncService: fetch → upsert → soft-delete lifecycle - SoftwareProductSyncJobHandler: daily at 02:00 via scheduler platform - SoftwareProductService: web UI business logic with validation - Permission: helpdesk.software-products.manage (nav-gated) - List page: Grid.js with Code, BC Description, Name, Status columns - Detail page: Code/BC Description read-only, Name editable, PRG pattern - i18n: de + en translation keys Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -356,5 +356,24 @@
|
||||
"Lookup": "Nachschlagen",
|
||||
"Monitoring": "Monitoring",
|
||||
"Contract type": "Vertragsart",
|
||||
"Customer": "Kunde"
|
||||
"Customer": "Kunde",
|
||||
"Software products": "Software-Produkte",
|
||||
"Software product": "Software-Produkt",
|
||||
"Search software products...": "Software-Produkte suchen...",
|
||||
"BC description": "BC-Bezeichnung",
|
||||
"Product name": "Produktname",
|
||||
"Code": "Code",
|
||||
"Active": "Aktiv",
|
||||
"Inactive": "Inaktiv",
|
||||
"Last synced": "Zuletzt synchronisiert",
|
||||
"Modified": "Geändert",
|
||||
"Edit software product": "Software-Produkt bearbeiten",
|
||||
"Back to list": "Zurück zur Liste",
|
||||
"Save & close": "Speichern & schließen",
|
||||
"Software product updated": "Software-Produkt aktualisiert",
|
||||
"Software product not found": "Software-Produkt nicht gefunden",
|
||||
"Product details": "Produktdetails",
|
||||
"Enter product name...": "Produktname eingeben...",
|
||||
"Name must not exceed 255 characters": "Name darf maximal 255 Zeichen lang sein",
|
||||
"Failed to save": "Speichern fehlgeschlagen"
|
||||
}
|
||||
|
||||
@@ -356,5 +356,24 @@
|
||||
"Lookup": "Lookup",
|
||||
"Monitoring": "Monitoring",
|
||||
"Contract type": "Contract type",
|
||||
"Customer": "Customer"
|
||||
"Customer": "Customer",
|
||||
"Software products": "Software products",
|
||||
"Software product": "Software product",
|
||||
"Search software products...": "Search software products...",
|
||||
"BC description": "BC description",
|
||||
"Product name": "Product name",
|
||||
"Code": "Code",
|
||||
"Active": "Active",
|
||||
"Inactive": "Inactive",
|
||||
"Last synced": "Last synced",
|
||||
"Modified": "Modified",
|
||||
"Edit software product": "Edit software product",
|
||||
"Back to list": "Back to list",
|
||||
"Save & close": "Save & close",
|
||||
"Software product updated": "Software product updated",
|
||||
"Software product not found": "Software product not found",
|
||||
"Product details": "Product details",
|
||||
"Enter product name...": "Enter product name...",
|
||||
"Name must not exceed 255 characters": "Name must not exceed 255 characters",
|
||||
"Failed to save": "Failed to save"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Handler;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Service\SoftwareProductSyncService;
|
||||
use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface;
|
||||
|
||||
class SoftwareProductSyncJobHandler implements ScheduledJobHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SoftwareProductSyncService $syncService
|
||||
) {
|
||||
}
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'label' => 'Software product sync',
|
||||
'description' => 'Syncs contract types with Create_SaaS_License from Business Central to local database',
|
||||
'default_enabled' => 1,
|
||||
'default_timezone' => defined('APP_TIMEZONE') ? (string) APP_TIMEZONE : 'Europe/Berlin',
|
||||
'default_schedule_type' => 'daily',
|
||||
'default_schedule_interval' => 1,
|
||||
'default_schedule_time' => '02:00',
|
||||
'default_schedule_weekdays_csv' => null,
|
||||
'default_catchup_once' => 1,
|
||||
'allowed_schedule_types' => ['daily', 'weekly'],
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(?int $actorUserId): array
|
||||
{
|
||||
return $this->syncService->sync();
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,14 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
public const ABILITY_SETTINGS_MANAGE = 'helpdesk.settings.manage';
|
||||
public const ABILITY_TEAM_WORKLOAD = 'helpdesk.team-workload.view';
|
||||
public const ABILITY_RISK_RADAR = 'helpdesk.risk-radar.view';
|
||||
public const ABILITY_SOFTWARE_PRODUCTS_MANAGE = 'helpdesk.software-products.manage';
|
||||
|
||||
public const PERMISSION_ACCESS = 'helpdesk.access';
|
||||
public const PERMISSION_SETTINGS_MANAGE = 'helpdesk.settings.manage';
|
||||
public const PERMISSION_TEAM_WORKLOAD = 'helpdesk.team-workload.view';
|
||||
public const PERMISSION_RISK_RADAR = 'helpdesk.risk-radar.view';
|
||||
/** @api Used in authorize() match for ability resolution */
|
||||
public const PERMISSION_SOFTWARE_PRODUCTS_MANAGE = 'helpdesk.software-products.manage';
|
||||
|
||||
public function __construct(
|
||||
private readonly PermissionService $permissionService
|
||||
@@ -25,7 +28,7 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
|
||||
public function supports(string $ability): bool
|
||||
{
|
||||
return in_array($ability, [self::ABILITY_ACCESS, self::ABILITY_SETTINGS_MANAGE, self::ABILITY_TEAM_WORKLOAD, self::ABILITY_RISK_RADAR], true);
|
||||
return in_array($ability, [self::ABILITY_ACCESS, self::ABILITY_SETTINGS_MANAGE, self::ABILITY_TEAM_WORKLOAD, self::ABILITY_RISK_RADAR, self::ABILITY_SOFTWARE_PRODUCTS_MANAGE], true);
|
||||
}
|
||||
|
||||
public function authorize(string $ability, array $context = []): AuthorizationDecision
|
||||
@@ -40,6 +43,7 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
self::ABILITY_SETTINGS_MANAGE => self::PERMISSION_SETTINGS_MANAGE,
|
||||
self::ABILITY_TEAM_WORKLOAD => self::PERMISSION_TEAM_WORKLOAD,
|
||||
self::ABILITY_RISK_RADAR => self::PERMISSION_RISK_RADAR,
|
||||
self::ABILITY_SOFTWARE_PRODUCTS_MANAGE => self::PERMISSION_SOFTWARE_PRODUCTS_MANAGE,
|
||||
default => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -15,8 +15,12 @@ use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskTenantSettingsGateway;
|
||||
use MintyPHP\Module\Helpdesk\Repository\HelpdeskTenantSettingsRepository;
|
||||
use MintyPHP\Module\Helpdesk\Repository\HelpdeskTokenRepository;
|
||||
use MintyPHP\Module\Helpdesk\Service\SoftwareProductService;
|
||||
use MintyPHP\Module\Helpdesk\Service\SoftwareProductSyncService;
|
||||
use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine;
|
||||
use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService;
|
||||
use MintyPHP\Module\Helpdesk\Handler\SoftwareProductSyncJobHandler;
|
||||
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
@@ -84,5 +88,21 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
|
||||
));
|
||||
|
||||
$container->set(SystemRecommendationEngine::class, static fn (): SystemRecommendationEngine => new SystemRecommendationEngine());
|
||||
|
||||
$container->set(SoftwareProductRepository::class, static fn (): SoftwareProductRepository => new SoftwareProductRepository());
|
||||
|
||||
$container->set(SoftwareProductSyncService::class, static fn (AppContainer $c): SoftwareProductSyncService => new SoftwareProductSyncService(
|
||||
$c->get(BcODataGateway::class),
|
||||
$c->get(SoftwareProductRepository::class),
|
||||
$c->get(HelpdeskSettingsGateway::class)
|
||||
));
|
||||
|
||||
$container->set(SoftwareProductService::class, static fn (AppContainer $c): SoftwareProductService => new SoftwareProductService(
|
||||
$c->get(SoftwareProductRepository::class)
|
||||
));
|
||||
|
||||
$container->set(SoftwareProductSyncJobHandler::class, static fn (AppContainer $c): SoftwareProductSyncJobHandler => new SoftwareProductSyncJobHandler(
|
||||
$c->get(SoftwareProductSyncService::class)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,16 +21,19 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider
|
||||
$canManageSettings = $authorizationService->authorize('helpdesk.settings.manage', $actorContext)->isAllowed();
|
||||
$canViewTeam = $authorizationService->authorize('helpdesk.team-workload.view', $actorContext)->isAllowed();
|
||||
$canViewRiskRadar = $authorizationService->authorize('helpdesk.risk-radar.view', $actorContext)->isAllowed();
|
||||
$canManageSoftwareProducts = $authorizationService->authorize('helpdesk.software-products.manage', $actorContext)->isAllowed();
|
||||
} catch (\Throwable) {
|
||||
$canManageSettings = false;
|
||||
$canViewTeam = false;
|
||||
$canViewRiskRadar = false;
|
||||
$canManageSoftwareProducts = false;
|
||||
}
|
||||
|
||||
return ['helpdesk.nav' => [
|
||||
'can_manage_settings' => $canManageSettings,
|
||||
'can_view_team' => $canViewTeam,
|
||||
'can_view_risk_radar' => $canViewRiskRadar,
|
||||
'can_manage_software_products' => $canManageSoftwareProducts,
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
class SoftwareProductRepository
|
||||
{
|
||||
/**
|
||||
* Insert or update a software product by code.
|
||||
*
|
||||
* Updates bc_description and synced_at on conflict. Reactivates soft-deleted entries.
|
||||
* Never touches the user-managed name field.
|
||||
*/
|
||||
public function upsertByCode(string $code, string $bcDescription): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'INSERT INTO helpdesk_software_products (code, bc_description, synced_at, active) VALUES (?, ?, NOW(), 1) '
|
||||
. 'ON DUPLICATE KEY UPDATE bc_description = VALUES(bc_description), synced_at = VALUES(synced_at), active = 1',
|
||||
$code,
|
||||
$bcDescription
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function findByCode(string $code): ?array
|
||||
{
|
||||
$code = trim($code);
|
||||
if ($code === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = DB::selectOne(
|
||||
'SELECT * FROM helpdesk_software_products WHERE code = ? LIMIT 1',
|
||||
$code
|
||||
);
|
||||
|
||||
return $this->normalizeRow($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total: int, rows: list<array<string, mixed>>}
|
||||
*/
|
||||
public function listPaged(array $filters): array
|
||||
{
|
||||
$search = trim((string) ($filters['search'] ?? ''));
|
||||
$active = trim((string) ($filters['active'] ?? ''));
|
||||
|
||||
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 100, 0);
|
||||
[$order, $dir] = RepoQuery::sanitizeOrder(
|
||||
$filters,
|
||||
['code', 'bc_description', 'name', 'active', 'synced_at'],
|
||||
'code',
|
||||
'asc'
|
||||
);
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
RepoQuery::addLikeFilter($where, $params, ['code', 'bc_description', 'name'], $search);
|
||||
|
||||
if (in_array($active, ['0', '1'], true)) {
|
||||
$where[] = 'active = ?';
|
||||
$params[] = $active;
|
||||
}
|
||||
|
||||
$whereSql = $where ? (' WHERE ' . implode(' AND ', $where)) : '';
|
||||
$total = (int) (DB::selectValue('SELECT COUNT(*) FROM helpdesk_software_products' . $whereSql, ...$params) ?? 0);
|
||||
|
||||
$rows = DB::select(
|
||||
'SELECT * FROM helpdesk_software_products'
|
||||
. $whereSql
|
||||
. sprintf(' ORDER BY `%s` %s LIMIT ? OFFSET ?', $order, $dir),
|
||||
...array_merge($params, [(string) $limit, (string) $offset])
|
||||
);
|
||||
|
||||
$normalized = [];
|
||||
if (is_array($rows)) {
|
||||
foreach ($rows as $row) {
|
||||
$item = $this->normalizeRow($row);
|
||||
if ($item !== null) {
|
||||
$normalized[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => $normalized,
|
||||
];
|
||||
}
|
||||
|
||||
public function updateName(string $code, string $name): bool
|
||||
{
|
||||
if (trim($code) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = DB::update(
|
||||
'UPDATE helpdesk_software_products SET name = ? WHERE code = ?',
|
||||
$name,
|
||||
$code
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete entries whose code is NOT in the given list of active BC codes.
|
||||
*
|
||||
* @param list<string> $activeCodes Codes that still exist in BC
|
||||
* @return int Number of deactivated rows
|
||||
*/
|
||||
public function softDeleteNotInCodes(array $activeCodes): int
|
||||
{
|
||||
if ($activeCodes === []) {
|
||||
// Deactivate all if BC returned no codes
|
||||
$result = DB::update('UPDATE helpdesk_software_products SET active = 0 WHERE active = 1');
|
||||
|
||||
return is_int($result) ? $result : 0;
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($activeCodes), '?'));
|
||||
$result = DB::update(
|
||||
'UPDATE helpdesk_software_products SET active = 0 WHERE active = 1 AND code NOT IN (' . $placeholders . ')',
|
||||
...$activeCodes
|
||||
);
|
||||
|
||||
return is_int($result) ? $result : 0;
|
||||
}
|
||||
|
||||
private function normalizeRow(mixed $row): ?array
|
||||
{
|
||||
if (!is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
$item = $row['helpdesk_software_products'] ?? $row;
|
||||
if (!is_array($item) || !isset($item['id'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ class BcODataGateway
|
||||
public const ENTITY_CONTRACT_LINES = 'FS_Contract_Lines_Test';
|
||||
public const ENTITY_MEETINGS = 'FS_Debitor_Meetings';
|
||||
public const ENTITY_CONTRACT_DOMAINS = 'FS_Contract_Domains';
|
||||
/** @api Used by listContractTypes() */
|
||||
public const ENTITY_CONTRACT_TYPES = 'FS_Contract_Types';
|
||||
|
||||
private const CONNECT_TIMEOUT = 10;
|
||||
private const REQUEST_TIMEOUT = 30;
|
||||
@@ -877,6 +879,72 @@ class BcODataGateway
|
||||
return $this->extractODataValues($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all contract types where Create_SaaS_License is true.
|
||||
*
|
||||
* Tries $filter=Create_SaaS_License eq true first; falls back to
|
||||
* fetching all types and filtering PHP-side if BC rejects the filter.
|
||||
*
|
||||
* Returns trimmed Code + Description. Entries with empty Code are skipped.
|
||||
*
|
||||
* @return array<int, array{Code: string, Description: string}>
|
||||
*/
|
||||
public function listContractTypes(): array
|
||||
{
|
||||
$select = 'Code,Description,Create_SaaS_License';
|
||||
$filter = 'Create_SaaS_License eq true';
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_TYPES)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$top=5000'
|
||||
. '&$select=' . rawurlencode($select);
|
||||
|
||||
$response = $this->request('GET', $url);
|
||||
|
||||
// Fallback: fetch all, filter PHP-side if BC rejects the boolean filter
|
||||
if ($response === null) {
|
||||
$urlAll = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_TYPES)
|
||||
. '?$top=5000'
|
||||
. '&$select=' . rawurlencode($select);
|
||||
|
||||
$response = $this->request('GET', $urlAll);
|
||||
if ($response === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$all = $this->extractODataValues($response);
|
||||
|
||||
return $this->normalizeContractTypes(array_filter(
|
||||
$all,
|
||||
static fn (array $row): bool => !empty($row['Create_SaaS_License'])
|
||||
));
|
||||
}
|
||||
|
||||
return $this->normalizeContractTypes($this->extractODataValues($response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim and filter contract type rows: skip empty codes, trim whitespace.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<int, array{Code: string, Description: string}>
|
||||
*/
|
||||
private function normalizeContractTypes(array $rows): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$code = trim((string) ($row['Code'] ?? ''));
|
||||
if ($code === '') {
|
||||
continue;
|
||||
}
|
||||
$result[] = [
|
||||
'Code' => $code,
|
||||
'Description' => trim((string) ($row['Description'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single ticket by ticket number.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
|
||||
|
||||
/** @api Called from pages/helpdesk/software-products action files */
|
||||
class SoftwareProductService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SoftwareProductRepository $repository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findByCode(string $code): ?array
|
||||
{
|
||||
return $this->repository->findByCode($code);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total: int, rows: list<array<string, mixed>>}
|
||||
*/
|
||||
public function listPaged(array $filters): array
|
||||
{
|
||||
return $this->repository->listPaged($filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user-managed name field for a software product.
|
||||
*
|
||||
* @return array{ok: bool, form: array<string, mixed>, errors: array<string, string>}
|
||||
*/
|
||||
public function updateNameByCode(string $code, string $name): array
|
||||
{
|
||||
$name = trim($name);
|
||||
$errors = [];
|
||||
|
||||
if (mb_strlen($name) > 255) {
|
||||
$errors['name'] = t('Name must not exceed 255 characters');
|
||||
}
|
||||
|
||||
$product = $this->repository->findByCode($code);
|
||||
if ($product === null) {
|
||||
$errors['code'] = t('Software product not found');
|
||||
}
|
||||
|
||||
$form = array_merge($product ?? [], ['name' => $name]);
|
||||
|
||||
if ($errors !== []) {
|
||||
return ['ok' => false, 'form' => $form, 'errors' => $errors];
|
||||
}
|
||||
|
||||
$updated = $this->repository->updateName($code, $name);
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'form' => $form, 'errors' => ['name' => t('Failed to save')]];
|
||||
}
|
||||
|
||||
$form = $this->repository->findByCode($code) ?? $form;
|
||||
|
||||
return ['ok' => true, 'form' => $form, 'errors' => []];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
|
||||
|
||||
class SoftwareProductSyncService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BcODataGateway $bcODataGateway,
|
||||
private readonly SoftwareProductRepository $repository,
|
||||
private readonly HelpdeskSettingsGateway $settingsGateway
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync contract types from BC to the local helpdesk_software_products table.
|
||||
*
|
||||
* - Fetches all types with Create_SaaS_License=true
|
||||
* - Upserts each entry (inserts new, updates bc_description, reactivates soft-deleted)
|
||||
* - Soft-deletes entries whose code no longer appears in BC
|
||||
*
|
||||
* @return array{status: string, error_code: ?string, error_message: ?string, result: array<string, int>}
|
||||
*/
|
||||
public function sync(): array
|
||||
{
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return [
|
||||
'status' => 'skipped',
|
||||
'error_code' => 'bc_not_configured',
|
||||
'error_message' => 'Business Central connection is not configured',
|
||||
'result' => [],
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$bcTypes = $this->bcODataGateway->listContractTypes();
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'status' => 'failed',
|
||||
'error_code' => 'bc_fetch_failed',
|
||||
'error_message' => mb_substr('BC OData fetch failed: ' . $e->getMessage(), 0, 255),
|
||||
'result' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$syncedCodes = [];
|
||||
$upserted = 0;
|
||||
|
||||
foreach ($bcTypes as $type) {
|
||||
$code = $type['Code'];
|
||||
$description = $type['Description'];
|
||||
|
||||
if ($this->repository->upsertByCode($code, $description)) {
|
||||
$upserted++;
|
||||
}
|
||||
$syncedCodes[] = $code;
|
||||
}
|
||||
|
||||
$deactivated = $this->repository->softDeleteNotInCodes($syncedCodes);
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'result' => [
|
||||
'bc_count' => count($bcTypes),
|
||||
'upserted' => $upserted,
|
||||
'deactivated' => $deactivated,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
14
modules/helpdesk/migrations/003_create_software_products.sql
Normal file
14
modules/helpdesk/migrations/003_create_software_products.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE IF NOT EXISTS `helpdesk_software_products` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`code` VARCHAR(30) NOT NULL,
|
||||
`bc_description` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`name` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`synced_at` DATETIME NULL DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_hsp_code` (`code`),
|
||||
KEY `idx_hsp_active` (`active`),
|
||||
KEY `idx_hsp_synced_at` (`synced_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -39,6 +39,9 @@ return [
|
||||
['path' => 'helpdesk/team-workload-data', 'target' => 'helpdesk/team-workload-data'],
|
||||
['path' => 'helpdesk/risk-radar', 'target' => 'helpdesk/risk-radar'],
|
||||
['path' => 'helpdesk/risk-radar-data', 'target' => 'helpdesk/risk-radar-data'],
|
||||
['path' => 'helpdesk/software-products', 'target' => 'helpdesk/software-products'],
|
||||
['path' => 'helpdesk/software-products-data', 'target' => 'helpdesk/software-products-data'],
|
||||
['path' => 'helpdesk/software-products/edit/{code}', 'target' => 'helpdesk/software-products/edit'],
|
||||
],
|
||||
'public_paths' => [],
|
||||
|
||||
@@ -92,6 +95,12 @@ return [
|
||||
'active' => true,
|
||||
'is_system' => true,
|
||||
],
|
||||
[
|
||||
'key' => 'helpdesk.software-products.manage',
|
||||
'description' => 'Manage helpdesk software products (view and edit product names)',
|
||||
'active' => true,
|
||||
'is_system' => true,
|
||||
],
|
||||
],
|
||||
|
||||
'search_resources' => [],
|
||||
@@ -100,7 +109,22 @@ return [
|
||||
'modules/helpdesk/css/helpdesk.css',
|
||||
],
|
||||
],
|
||||
'scheduler_jobs' => [],
|
||||
'scheduler_jobs' => [
|
||||
[
|
||||
'job_key' => 'helpdesk_software_product_sync',
|
||||
'handler' => \MintyPHP\Module\Helpdesk\Handler\SoftwareProductSyncJobHandler::class,
|
||||
'label' => 'Software product sync',
|
||||
'description' => 'Syncs contract types with Create_SaaS_License from Business Central to local database',
|
||||
'default_enabled' => 1,
|
||||
'default_timezone' => 'Europe/Berlin',
|
||||
'default_schedule_type' => 'daily',
|
||||
'default_schedule_interval' => 1,
|
||||
'default_schedule_time' => '02:00',
|
||||
'default_schedule_weekdays_csv' => null,
|
||||
'default_catchup_once' => 1,
|
||||
'allowed_schedule_types' => ['daily', 'weekly'],
|
||||
],
|
||||
],
|
||||
'layout_context_providers' => [
|
||||
\MintyPHP\Module\Helpdesk\Providers\HelpdeskLayoutProvider::class,
|
||||
],
|
||||
|
||||
38
modules/helpdesk/pages/helpdesk/software-products-data().php
Normal file
38
modules/helpdesk/pages/helpdesk/software-products-data().php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\SoftwareProductService;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_SOFTWARE_PRODUCTS_MANAGE);
|
||||
gridRequireGetRequest();
|
||||
|
||||
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/software-products/filter-schema.php');
|
||||
|
||||
$service = app(SoftwareProductService::class);
|
||||
$result = $service->listPaged($filters);
|
||||
|
||||
$rows = $result['rows'] ?? [];
|
||||
$total = $result['total'] ?? 0;
|
||||
|
||||
$editBaseUrl = lurl('helpdesk/software-products/edit/');
|
||||
|
||||
$preparedRows = [];
|
||||
foreach ($rows as $row) {
|
||||
$code = (string) ($row['code'] ?? '');
|
||||
$active = (int) ($row['active'] ?? 1);
|
||||
|
||||
$preparedRows[] = [
|
||||
'code' => $code,
|
||||
'bc_description' => (string) ($row['bc_description'] ?? ''),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'active' => $active,
|
||||
'active_label' => $active ? t('Active') : t('Inactive'),
|
||||
'active_variant' => $active ? 'success' : 'neutral',
|
||||
'synced_at' => (string) ($row['synced_at'] ?? ''),
|
||||
'edit_url' => $code !== '' ? $editBaseUrl . rawurlencode($code) : '',
|
||||
];
|
||||
}
|
||||
|
||||
gridJsonDataResult($preparedRows, $total);
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
$values = is_array($values ?? null) ? $values : [];
|
||||
$formId = $formId ?? 'software-product-form';
|
||||
?>
|
||||
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
|
||||
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="helpdesk-software-product-form">
|
||||
<div class="app-tabs-nav">
|
||||
<button type="button" data-tab="basic" data-tab-default><?php e(t('Master data')); ?></button>
|
||||
</div>
|
||||
|
||||
<div data-tab-panel="basic">
|
||||
<fieldset>
|
||||
<legend><?php e(t('Product details')); ?></legend>
|
||||
|
||||
<label for="software-product-code"><?php e(t('Code')); ?></label>
|
||||
<input
|
||||
type="text"
|
||||
id="software-product-code"
|
||||
name="code"
|
||||
value="<?php e($values['code'] ?? ''); ?>"
|
||||
readonly
|
||||
aria-readonly="true"
|
||||
>
|
||||
|
||||
<label for="software-product-bc-description"><?php e(t('BC description')); ?></label>
|
||||
<input
|
||||
type="text"
|
||||
id="software-product-bc-description"
|
||||
name="bc_description"
|
||||
value="<?php e($values['bc_description'] ?? ''); ?>"
|
||||
readonly
|
||||
aria-readonly="true"
|
||||
>
|
||||
|
||||
<label for="software-product-name"><?php e(t('Product name')); ?></label>
|
||||
<input
|
||||
type="text"
|
||||
id="software-product-name"
|
||||
name="name"
|
||||
value="<?php e($values['name'] ?? ''); ?>"
|
||||
maxlength="255"
|
||||
placeholder="<?php e(t('Enter product name...')); ?>"
|
||||
>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php Session::getCsrfInput(); ?>
|
||||
</form>
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\SoftwareProductService;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_SOFTWARE_PRODUCTS_MANAGE);
|
||||
|
||||
$request = requestInput();
|
||||
$returnTarget = requestResolveReturnTarget();
|
||||
$closeTarget = requestResolveReturnTarget('helpdesk/software-products');
|
||||
|
||||
$productCode = rawurldecode(trim((string) ($code ?? '')));
|
||||
$editTarget = requestPathWithReturnTarget('helpdesk/software-products/edit/' . rawurlencode($productCode), $returnTarget);
|
||||
|
||||
$service = app(SoftwareProductService::class);
|
||||
$product = $service->findByCode($productCode);
|
||||
|
||||
if ($product === null) {
|
||||
Flash::error(t('Software product not found'), $closeTarget, 'software_product_not_found');
|
||||
Router::redirect($closeTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
$errorBag = formErrors();
|
||||
$warnings = [];
|
||||
$form = $product;
|
||||
|
||||
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
|
||||
Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
|
||||
Router::redirect($editTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
$name = (string) $request->body('name', '');
|
||||
$result = $service->updateNameByCode($productCode, $name);
|
||||
$form = $result['form'] ?? $form;
|
||||
$errorBag->merge($result['errors'] ?? []);
|
||||
|
||||
if (($result['ok'] ?? false) && !$errorBag->hasAny()) {
|
||||
$action = (string) $request->body('action', 'save');
|
||||
if ($action === 'save_close') {
|
||||
Flash::success(t('Software product updated'), $closeTarget, 'software_product_updated');
|
||||
Router::redirect($closeTarget);
|
||||
} else {
|
||||
Flash::success(t('Software product updated'), $editTarget, 'software_product_updated');
|
||||
Router::redirect($editTarget);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$validationSummaryErrors = $errorBag->toArray();
|
||||
$errors = $errorBag->toFlatList();
|
||||
$titleText = t('Edit software product');
|
||||
Buffer::set('title', $titleText);
|
||||
Buffer::set('style_groups', json_encode(['helpdesk']));
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('Helpdesk'), 'path' => 'helpdesk'],
|
||||
['label' => t('Software products'), 'path' => 'helpdesk/software-products'],
|
||||
['label' => $titleText],
|
||||
];
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
$form = is_array($form ?? null) ? $form : [];
|
||||
$validationSummaryErrors = is_array($validationSummaryErrors ?? null) ? $validationSummaryErrors : [];
|
||||
$errors = is_array($errors ?? null) ? $errors : [];
|
||||
$warnings = is_array($warnings ?? null) ? $warnings : [];
|
||||
$closeTarget = $closeTarget ?? 'helpdesk/software-products';
|
||||
|
||||
$values = $form;
|
||||
$formId = 'software-product-form';
|
||||
$active = (int) ($values['active'] ?? 1);
|
||||
?>
|
||||
<div class="app-details-container">
|
||||
<section>
|
||||
<?php require templatePath('partials/app-flash.phtml'); ?>
|
||||
|
||||
<?php
|
||||
$titlebar = [
|
||||
'title' => $titleText ?? t('Edit software product'),
|
||||
'backHref' => $closeTarget,
|
||||
'backTitle' => t('Back to list'),
|
||||
'actions' => [
|
||||
[
|
||||
'form' => $formId,
|
||||
'name' => 'action',
|
||||
'value' => 'save',
|
||||
'class' => 'secondary outline',
|
||||
'label' => t('Save'),
|
||||
],
|
||||
[
|
||||
'form' => $formId,
|
||||
'name' => 'action',
|
||||
'value' => 'save_close',
|
||||
'class' => 'primary',
|
||||
'label' => t('Save & close'),
|
||||
],
|
||||
],
|
||||
];
|
||||
require templatePath('partials/app-details-titlebar.phtml');
|
||||
?>
|
||||
|
||||
<?php require templatePath('partials/app-details-validation-summary.phtml'); ?>
|
||||
|
||||
<?php if ($warnings): ?>
|
||||
<div class="app-details-warnings">
|
||||
<div class="notice" data-variant="info">
|
||||
<ul>
|
||||
<?php foreach ($warnings as $warning): ?>
|
||||
<li><?php e($warning); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php require __DIR__ . '/_form.phtml'; ?>
|
||||
|
||||
<?php Session::getCsrfInput(); ?>
|
||||
</section>
|
||||
|
||||
<aside id="app-details-aside-section">
|
||||
<div class="app-details-aside-section">
|
||||
<hgroup>
|
||||
<h2><?php e($values['code'] ?? ''); ?></h2>
|
||||
<p><?php e(t('Software product')); ?></p>
|
||||
</hgroup>
|
||||
|
||||
<dl class="app-details-aside-meta">
|
||||
<dt><?php e(t('Status')); ?></dt>
|
||||
<dd>
|
||||
<span class="badge" data-variant="<?php e($active ? 'success' : 'neutral'); ?>">
|
||||
<?php e($active ? t('Active') : t('Inactive')); ?>
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<?php if (!empty($values['synced_at'])): ?>
|
||||
<dt><?php e(t('Last synced')); ?></dt>
|
||||
<dd><?php e($values['synced_at']); ?></dd>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($values['created_at'])): ?>
|
||||
<dt><?php e(t('Created')); ?></dt>
|
||||
<dd><?php e($values['created_at']); ?></dd>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($values['updated_at'])): ?>
|
||||
<dt><?php e(t('Modified')); ?></dt>
|
||||
<dd><?php e($values['updated_at']); ?></dd>
|
||||
<?php endif; ?>
|
||||
</dl>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
return gridFilterSchema([
|
||||
'query' => [
|
||||
'search' => ['type' => 'string'],
|
||||
'active' => ['type' => 'string', 'default' => 'all'],
|
||||
'limit' => ['type' => 'int', 'default' => 20, 'min' => 1, 'max' => 100],
|
||||
'offset' => ['type' => 'int', 'default' => 0, 'min' => 0],
|
||||
'order' => [
|
||||
'type' => 'order',
|
||||
'allowed' => ['code', 'bc_description', 'name', 'active', 'synced_at'],
|
||||
'default' => 'code',
|
||||
],
|
||||
'dir' => ['type' => 'dir', 'default' => 'asc'],
|
||||
],
|
||||
'toolbar' => [
|
||||
[
|
||||
'key' => 'search',
|
||||
'type' => 'text',
|
||||
'label' => 'Search',
|
||||
'placeholder' => 'Search software products...',
|
||||
'input_id' => 'helpdesk-software-products-search-input',
|
||||
'search' => true,
|
||||
'query' => ['type' => 'string'],
|
||||
],
|
||||
[
|
||||
'key' => 'active',
|
||||
'type' => 'select',
|
||||
'label' => 'Status',
|
||||
'input_id' => 'helpdesk-software-products-active-filter',
|
||||
'default' => 'all',
|
||||
'normalize' => 'all_to_empty',
|
||||
'allowed' => [
|
||||
['id' => 'all', 'description' => 'All'],
|
||||
['id' => '1', 'description' => 'Active'],
|
||||
['id' => '0', 'description' => 'Inactive'],
|
||||
],
|
||||
'label_attributes' => ['data-filter-optional' => true],
|
||||
],
|
||||
],
|
||||
]);
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_SOFTWARE_PRODUCTS_MANAGE);
|
||||
|
||||
$request = requestInput();
|
||||
$query = $request->queryAll();
|
||||
|
||||
$filterSchema = require __DIR__ . '/filter-schema.php';
|
||||
$listFilterContext = gridBuildListFilterContext($filterSchema, [
|
||||
'query' => $query,
|
||||
'search_keys' => ['search'],
|
||||
]);
|
||||
$toolbarFilterSchema = $listFilterContext['toolbarFilterSchema'];
|
||||
$searchToolbarFilterSchema = $listFilterContext['searchToolbarFilterSchema'];
|
||||
$drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema'];
|
||||
$toolbarFilterState = $listFilterContext['toolbarFilterState'];
|
||||
$clientFilterSchema = $listFilterContext['clientFilterSchema'];
|
||||
$searchConfig = $listFilterContext['searchConfig'];
|
||||
$schemaByKey = $listFilterContext['schemaByKey'];
|
||||
$filterChipMeta = [
|
||||
'search' => [
|
||||
'label' => t('Search'),
|
||||
'type' => 'text',
|
||||
],
|
||||
'active' => [
|
||||
'label' => t('Status'),
|
||||
'type' => 'select',
|
||||
'default' => (string) ($schemaByKey['active']['default'] ?? 'all'),
|
||||
'options' => gridOptionMapFromAllowed((array) ($schemaByKey['active'] ?? [])),
|
||||
],
|
||||
];
|
||||
|
||||
$settingsGateway = app(HelpdeskSettingsGateway::class);
|
||||
$isConfigured = $settingsGateway->isConfigured();
|
||||
|
||||
Buffer::set('title', t('Software products'));
|
||||
Buffer::set('style_groups', json_encode(['helpdesk']));
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('Helpdesk'), 'path' => 'helpdesk'],
|
||||
['label' => t('Software products')],
|
||||
];
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
|
||||
$searchToolbarFilterSchema = is_array($searchToolbarFilterSchema ?? null) ? $searchToolbarFilterSchema : [];
|
||||
$drawerToolbarFilterSchema = is_array($drawerToolbarFilterSchema ?? null) ? $drawerToolbarFilterSchema : [];
|
||||
$toolbarFilterState = is_array($toolbarFilterState ?? null) ? $toolbarFilterState : [];
|
||||
$toolbarOptionSets = is_array($toolbarOptionSets ?? null) ? $toolbarOptionSets : [];
|
||||
$clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchema : [];
|
||||
$searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
||||
$filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : [];
|
||||
$isConfigured = $isConfigured ?? false;
|
||||
?>
|
||||
<?php require templatePath('partials/app-flash.phtml'); ?>
|
||||
|
||||
<?php if (!$isConfigured): ?>
|
||||
<div class="notice" data-variant="warning" role="alert">
|
||||
<p><?php e(t('BC connection is not configured. Please configure the connection in the settings.')); ?></p>
|
||||
<a href="<?php e(lurl('helpdesk/settings')); ?>" role="button" class="secondary outline small">
|
||||
<?php e(t('Open settings')); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$listTitle = t('Software products');
|
||||
require templatePath('partials/app-list-titlebar.phtml');
|
||||
?>
|
||||
<?php
|
||||
$filterUiNamespace = 'helpdesk-software-products';
|
||||
require templatePath('partials/app-list-filters.phtml');
|
||||
?>
|
||||
<div class="app-list-table">
|
||||
<div id="helpdesk-software-products-grid"></div>
|
||||
</div>
|
||||
|
||||
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
|
||||
<script type="application/json" id="page-config-helpdesk-software-products"><?php gridJsonForJs([
|
||||
'dataUrl' => lurl('helpdesk/software-products-data'),
|
||||
'gridLang' => gridLang(),
|
||||
'gridSearch' => $searchConfig,
|
||||
'filterSchema' => $clientFilterSchema,
|
||||
'filterChipMeta' => $filterChipMeta,
|
||||
'editBaseUrl' => lurl('helpdesk/software-products/edit/'),
|
||||
'labels' => [
|
||||
'code' => t('Code'),
|
||||
'bcDescription' => t('BC description'),
|
||||
'name' => t('Product name'),
|
||||
'status' => t('Status'),
|
||||
],
|
||||
]); ?></script>
|
||||
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/pages/helpdesk-software-products-index.js')); ?>"></script>
|
||||
@@ -5,13 +5,15 @@ $helpdeskNav = is_array($layoutNav['helpdesk.nav'] ?? null) ? $layoutNav['helpde
|
||||
$canManageSettings = !empty($helpdeskNav['can_manage_settings']);
|
||||
$canViewTeam = !empty($helpdeskNav['can_view_team']);
|
||||
$canViewRiskRadar = !empty($helpdeskNav['can_view_risk_radar']);
|
||||
$canManageSoftwareProducts = !empty($helpdeskNav['can_manage_software_products']);
|
||||
|
||||
$settingsActive = navActive('helpdesk/settings', true);
|
||||
$teamActive = navActive('helpdesk/team', true);
|
||||
$riskRadarActive = navActive('helpdesk/risk-radar', true);
|
||||
$domainsActive = navActive('helpdesk/domains', true);
|
||||
$softwareProductsActive = navActive('helpdesk/software-products', true);
|
||||
$customersActive = navActive('helpdesk', true);
|
||||
if ($settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive'] || $domainsActive['isActive']) {
|
||||
if ($settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive'] || $domainsActive['isActive'] || $softwareProductsActive['isActive']) {
|
||||
$customersActive = ['class' => '', 'aria' => '', 'isActive' => false];
|
||||
}
|
||||
|
||||
@@ -59,6 +61,12 @@ $helpdeskNavGroups = [
|
||||
'label' => t('Administration'),
|
||||
'icon' => 'bi-sliders',
|
||||
'items' => [
|
||||
[
|
||||
'label' => t('Software products'),
|
||||
'path' => 'helpdesk/software-products',
|
||||
'active' => $softwareProductsActive,
|
||||
'visible' => $canManageSoftwareProducts,
|
||||
],
|
||||
[
|
||||
'label' => t('Settings'),
|
||||
'path' => 'helpdesk/settings',
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
|
||||
use MintyPHP\Module\Helpdesk\Service\SoftwareProductService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SoftwareProductServiceTest extends TestCase
|
||||
{
|
||||
private function createService(?SoftwareProductRepository $repository = null): SoftwareProductService
|
||||
{
|
||||
$repository = $repository ?? $this->createMock(SoftwareProductRepository::class);
|
||||
|
||||
return new SoftwareProductService($repository);
|
||||
}
|
||||
|
||||
public function testFindByCodeReturnsNullForMissingProduct(): void
|
||||
{
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->method('findByCode')->willReturn(null);
|
||||
|
||||
$service = $this->createService($repository);
|
||||
$this->assertNull($service->findByCode('NONEXISTENT'));
|
||||
}
|
||||
|
||||
public function testFindByCodeReturnsProduct(): void
|
||||
{
|
||||
$product = ['id' => 1, 'code' => 'INFO', 'name' => 'Infopoints', 'active' => 1];
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->method('findByCode')->willReturn($product);
|
||||
|
||||
$service = $this->createService($repository);
|
||||
$result = $service->findByCode('INFO');
|
||||
$this->assertSame('INFO', $result['code']);
|
||||
}
|
||||
|
||||
public function testUpdateNameByCodeReturnsErrorForMissingProduct(): void
|
||||
{
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->method('findByCode')->willReturn(null);
|
||||
|
||||
$service = $this->createService($repository);
|
||||
$result = $service->updateNameByCode('NONEXISTENT', 'Test');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('code', $result['errors']);
|
||||
}
|
||||
|
||||
public function testUpdateNameByCodeTrimsWhitespace(): void
|
||||
{
|
||||
$product = ['id' => 1, 'code' => 'INFO', 'name' => '', 'active' => 1];
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->method('findByCode')->willReturn($product);
|
||||
$repository->expects($this->once())
|
||||
->method('updateName')
|
||||
->with('INFO', 'Trimmed Name')
|
||||
->willReturn(true);
|
||||
|
||||
$service = $this->createService($repository);
|
||||
$result = $service->updateNameByCode('INFO', ' Trimmed Name ');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertEmpty($result['errors']);
|
||||
}
|
||||
|
||||
public function testUpdateNameByCodeRejectsOverlongName(): void
|
||||
{
|
||||
$product = ['id' => 1, 'code' => 'INFO', 'name' => '', 'active' => 1];
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->method('findByCode')->willReturn($product);
|
||||
$repository->expects($this->never())->method('updateName');
|
||||
|
||||
$service = $this->createService($repository);
|
||||
$result = $service->updateNameByCode('INFO', str_repeat('A', 256));
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('name', $result['errors']);
|
||||
}
|
||||
|
||||
public function testUpdateNameByCodeAllowsEmptyName(): void
|
||||
{
|
||||
$product = ['id' => 1, 'code' => 'INFO', 'name' => 'Old', 'active' => 1];
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->method('findByCode')->willReturn($product);
|
||||
$repository->expects($this->once())
|
||||
->method('updateName')
|
||||
->with('INFO', '')
|
||||
->willReturn(true);
|
||||
|
||||
$service = $this->createService($repository);
|
||||
$result = $service->updateNameByCode('INFO', '');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
}
|
||||
|
||||
public function testListPagedDelegatesToRepository(): void
|
||||
{
|
||||
$expected = ['total' => 2, 'rows' => [['code' => 'A'], ['code' => 'B']]];
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->expects($this->once())
|
||||
->method('listPaged')
|
||||
->with(['search' => 'test'])
|
||||
->willReturn($expected);
|
||||
|
||||
$service = $this->createService($repository);
|
||||
$result = $service->listPaged(['search' => 'test']);
|
||||
|
||||
$this->assertSame(2, $result['total']);
|
||||
$this->assertCount(2, $result['rows']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
|
||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\SoftwareProductSyncService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SoftwareProductSyncServiceTest extends TestCase
|
||||
{
|
||||
private function createService(
|
||||
?BcODataGateway $gateway = null,
|
||||
?SoftwareProductRepository $repository = null,
|
||||
bool $isConfigured = true
|
||||
): SoftwareProductSyncService {
|
||||
$gateway = $gateway ?? $this->createMock(BcODataGateway::class);
|
||||
$repository = $repository ?? $this->createMock(SoftwareProductRepository::class);
|
||||
$settingsGateway = $this->createMock(HelpdeskSettingsGateway::class);
|
||||
$settingsGateway->method('isConfigured')->willReturn($isConfigured);
|
||||
|
||||
return new SoftwareProductSyncService($gateway, $repository, $settingsGateway);
|
||||
}
|
||||
|
||||
public function testSyncSkipsWhenNotConfigured(): void
|
||||
{
|
||||
$service = $this->createService(isConfigured: false);
|
||||
$result = $service->sync();
|
||||
|
||||
$this->assertSame('skipped', $result['status']);
|
||||
$this->assertSame('bc_not_configured', $result['error_code']);
|
||||
}
|
||||
|
||||
public function testSyncReturnsFailedOnGatewayException(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('listContractTypes')
|
||||
->willThrowException(new \RuntimeException('Connection refused'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->sync();
|
||||
|
||||
$this->assertSame('failed', $result['status']);
|
||||
$this->assertSame('bc_fetch_failed', $result['error_code']);
|
||||
$this->assertStringContainsString('Connection refused', $result['error_message']);
|
||||
}
|
||||
|
||||
public function testSyncUpsertsAndSoftDeletesCorrectly(): void
|
||||
{
|
||||
$bcTypes = [
|
||||
['Code' => 'INFO', 'Description' => 'Infopoints'],
|
||||
['Code' => 'WEBSITE', 'Description' => 'Saas-Gebuehren Website'],
|
||||
];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('listContractTypes')->willReturn($bcTypes);
|
||||
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->expects($this->exactly(2))
|
||||
->method('upsertByCode')
|
||||
->willReturnCallback(function (string $code, string $desc): bool {
|
||||
$this->assertContains($code, ['INFO', 'WEBSITE']);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$repository->expects($this->once())
|
||||
->method('softDeleteNotInCodes')
|
||||
->with(['INFO', 'WEBSITE'])
|
||||
->willReturn(1);
|
||||
|
||||
$service = $this->createService($gateway, $repository);
|
||||
$result = $service->sync();
|
||||
|
||||
$this->assertSame('success', $result['status']);
|
||||
$this->assertNull($result['error_code']);
|
||||
$this->assertSame(2, $result['result']['bc_count']);
|
||||
$this->assertSame(2, $result['result']['upserted']);
|
||||
$this->assertSame(1, $result['result']['deactivated']);
|
||||
}
|
||||
|
||||
public function testSyncHandlesEmptyBcResponse(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('listContractTypes')->willReturn([]);
|
||||
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->expects($this->never())->method('upsertByCode');
|
||||
$repository->expects($this->once())
|
||||
->method('softDeleteNotInCodes')
|
||||
->with([])
|
||||
->willReturn(3);
|
||||
|
||||
$service = $this->createService($gateway, $repository);
|
||||
$result = $service->sync();
|
||||
|
||||
$this->assertSame('success', $result['status']);
|
||||
$this->assertSame(0, $result['result']['bc_count']);
|
||||
$this->assertSame(0, $result['result']['upserted']);
|
||||
$this->assertSame(3, $result['result']['deactivated']);
|
||||
}
|
||||
|
||||
public function testSyncIsIdempotent(): void
|
||||
{
|
||||
$bcTypes = [
|
||||
['Code' => 'INFO', 'Description' => 'Infopoints'],
|
||||
];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('listContractTypes')->willReturn($bcTypes);
|
||||
|
||||
$repository = $this->createMock(SoftwareProductRepository::class);
|
||||
$repository->method('upsertByCode')->willReturn(true);
|
||||
$repository->method('softDeleteNotInCodes')->willReturn(0);
|
||||
|
||||
$service = $this->createService($gateway, $repository);
|
||||
|
||||
$result1 = $service->sync();
|
||||
$result2 = $service->sync();
|
||||
|
||||
$this->assertSame('success', $result1['status']);
|
||||
$this->assertSame('success', $result2['status']);
|
||||
$this->assertSame($result1['result']['bc_count'], $result2['result']['bc_count']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { initStandardListPage } from '/js/core/app-grid-factory.js';
|
||||
import { readPageConfig } from '/js/core/app-page-config.js';
|
||||
import { warnOnce } from '/js/core/app-dom.js';
|
||||
import { escapeHtml, getAppBase, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||||
|
||||
const config = readPageConfig('helpdesk-software-products');
|
||||
if (config) {
|
||||
const gridjs = window.gridjs;
|
||||
if (!gridjs) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk software products grid init failed: Grid.js missing', { module: 'helpdesk-software-products', component: 'grid' });
|
||||
} else {
|
||||
const appBase = getAppBase();
|
||||
const labels = config.labels || {};
|
||||
|
||||
const editUrlIndex = 4;
|
||||
|
||||
const gridOptions = {
|
||||
gridjs,
|
||||
container: '#helpdesk-software-products-grid',
|
||||
dataUrl: config.dataUrl || 'helpdesk/software-products-data',
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
name: labels.code || 'Code',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const code = escapeHtml(cell?.code || '');
|
||||
const editUrl = String(cell?.url || '').trim();
|
||||
if (editUrl === '') {
|
||||
return gridjs.html(code);
|
||||
}
|
||||
const href = escapeHtml(withCurrentListReturn(editUrl));
|
||||
return gridjs.html(`<a href="${href}">${code}</a>`);
|
||||
},
|
||||
},
|
||||
{ name: labels.bcDescription || 'BC Description', sort: true },
|
||||
{ name: labels.name || 'Product Name', sort: true },
|
||||
{
|
||||
name: labels.status || 'Status',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const label = escapeHtml(cell?.label || '');
|
||||
const variant = cell?.variant || 'neutral';
|
||||
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
|
||||
},
|
||||
},
|
||||
{ name: 'edit_url', hidden: true },
|
||||
],
|
||||
sortColumns: ['code', 'bc_description', 'name', 'active', null],
|
||||
paginationLimit: 20,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => (data.data || []).map((row) => [
|
||||
{ code: row.code || '', url: row.edit_url || '' },
|
||||
row.bc_description || '',
|
||||
row.name || '',
|
||||
{ label: row.active_label || '', variant: row.active_variant || 'neutral' },
|
||||
row.edit_url || '',
|
||||
]),
|
||||
search: config.gridSearch || null,
|
||||
filterSchema: config.filterSchema || [],
|
||||
urlSync: true,
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => rowData?.cells?.[editUrlIndex]?.data || '',
|
||||
},
|
||||
};
|
||||
|
||||
const { gridConfig } = initStandardListPage({
|
||||
grid: gridOptions,
|
||||
filters: {
|
||||
mode: 'drawer',
|
||||
chipMeta: config.filterChipMeta || {},
|
||||
watchInputs: ['#helpdesk-software-products-search-input'],
|
||||
},
|
||||
});
|
||||
|
||||
if (!gridConfig || !gridConfig.grid) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk software products grid init failed', { module: 'helpdesk-software-products', component: 'grid' });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user