diff --git a/modules/erp/bestellungen/service.php b/modules/erp/bestellungen/service.php index 254cba6..ccd95af 100644 --- a/modules/erp/bestellungen/service.php +++ b/modules/erp/bestellungen/service.php @@ -175,3 +175,148 @@ function insert_sales_order_line(PDO $pdo, array $fields): int return (int) $id; } + +function normalize_sales_order_sort_column(string $sortColumn): string +{ + $allowed = [ + 'order_date', + 'external_ref', + 'last_name', + 'first_name', + 'total_amount', + ]; + + return in_array($sortColumn, $allowed, true) ? $sortColumn : 'order_date'; +} + +function normalize_sales_order_sort_direction(string $direction, string $sortColumn): string +{ + $direction = strtoupper(trim($direction)); + if ($direction !== 'ASC' && $direction !== 'DESC') { + return $sortColumn === 'order_date' ? 'DESC' : 'ASC'; + } + + return $direction; +} + +function escape_sales_order_search_term(string $searchTerm): string +{ + return strtr($searchTerm, [ + '\\' => '\\\\', + '%' => '\\%', + '_' => '\\_', + ]); +} + +function get_sales_order_overview(PDO $pdo, array $filters = []): array +{ + $searchTerm = trim((string) ($filters['search'] ?? '')); + $sortColumn = normalize_sales_order_sort_column((string) ($filters['sort_column'] ?? 'order_date')); + $sortDirection = normalize_sales_order_sort_direction((string) ($filters['sort_direction'] ?? ''), $sortColumn); + $limit = max(1, (int) ($filters['limit'] ?? 20)); + $pageSize = max(1, (int) ($filters['page_size'] ?? 20)); + + $filterSql = ''; + $params = []; + if ($searchTerm !== '') { + $filterSql = <<<'SQL' +WHERE ( + so.external_ref ILIKE :search_term ESCAPE '\' + OR COALESCE(ad.last_name, '') ILIKE :search_term ESCAPE '\' + OR COALESCE(ad.first_name, '') ILIKE :search_term ESCAPE '\' +) +SQL; + $params[':search_term'] = '%' . escape_sales_order_search_term($searchTerm) . '%'; + } + + $sortExpressions = [ + 'order_date' => 'so.order_date', + 'external_ref' => 'so.external_ref', + 'last_name' => 'COALESCE(ad.last_name, \'\')', + 'first_name' => 'COALESCE(ad.first_name, \'\')', + 'total_amount' => 'COALESCE(so.total_amount, 0)', + ]; + $sortExpression = $sortExpressions[$sortColumn] ?? 'so.order_date'; + + $baseFromSql = <<<'SQL' +FROM sales_order so +LEFT JOIN LATERAL ( + SELECT + a.first_name, + a.last_name, + a.street, + a.house_number, + a.zip, + a.city, + a.country_name + FROM address a + WHERE a.party_id = so.party_id + AND a.type = 'shipping' + ORDER BY a.id DESC + LIMIT 1 +) ad ON TRUE +SQL; + + $countStmt = $pdo->prepare( + 'SELECT COUNT(*) ' . $baseFromSql . "\n" . $filterSql + ); + foreach ($params as $key => $value) { + $countStmt->bindValue($key, $value, PDO::PARAM_STR); + } + $countStmt->execute(); + $totalCount = (int) $countStmt->fetchColumn(); + + $listSql = <<<'SQL' +SELECT + so.id, + so.external_ref, + so.order_date, + so.total_amount, + COALESCE(ad.first_name, '') AS first_name, + COALESCE(ad.last_name, '') AS last_name, + COALESCE(ad.street, '') AS street, + COALESCE(ad.house_number, '') AS house_number, + COALESCE(ad.zip, '') AS zip, + COALESCE(ad.city, '') AS city, + COALESCE(ad.country_name, '') AS country_name +SQL; + $listSql .= "\n" . $baseFromSql . "\n" . $filterSql; + $listSql .= "\nORDER BY {$sortExpression} {$sortDirection}, so.id {$sortDirection}"; + $listSql .= "\nLIMIT :limit"; + + $listStmt = $pdo->prepare($listSql); + foreach ($params as $key => $value) { + $listStmt->bindValue($key, $value, PDO::PARAM_STR); + } + $listStmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $listStmt->execute(); + + $rows = []; + foreach ($listStmt->fetchAll() as $row) { + $rows[] = [ + 'id' => (int) $row['id'], + 'external_ref' => (string) $row['external_ref'], + 'order_date' => (string) $row['order_date'], + 'total_amount' => $row['total_amount'] !== null ? (float) $row['total_amount'] : null, + 'first_name' => (string) $row['first_name'], + 'last_name' => (string) $row['last_name'], + 'street' => (string) $row['street'], + 'house_number' => (string) $row['house_number'], + 'zip' => (string) $row['zip'], + 'city' => (string) $row['city'], + 'country_name' => (string) $row['country_name'], + ]; + } + + return [ + 'rows' => $rows, + 'search' => $searchTerm, + 'sort_column' => $sortColumn, + 'sort_direction' => $sortDirection, + 'limit' => $limit, + 'page_size' => $pageSize, + 'total_count' => $totalCount, + 'has_more' => $totalCount > $limit, + 'next_limit' => min($totalCount, $limit + $pageSize), + ]; +} diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index e3c3a35..7dc8352 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -3,7 +3,194 @@ declare(strict_types=1); require_once __DIR__ . '/../service.php'; -function render_auth_home_page(array $user, array $otcProducts = []): void +function auth_current_path(): string +{ + $requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '/'); + $path = parse_url($requestUri, PHP_URL_PATH); + + if (!is_string($path) || $path === '') { + return '/'; + } + + return $path; +} + +function auth_build_path_with_query(array $query = []): string +{ + $query = array_filter( + $query, + static fn ($value): bool => $value !== null && $value !== '' + ); + + $path = auth_current_path(); + $queryString = http_build_query($query, '', '&', PHP_QUERY_RFC3986); + + return $queryString === '' ? $path : $path . '?' . $queryString; +} + +function auth_format_bestellung_date(?string $value): string +{ + if ($value === null || $value === '') { + return ''; + } + + try { + return (new DateTimeImmutable($value))->format('d.m.Y H:i'); + } catch (Throwable) { + return $value; + } +} + +function auth_format_bestellung_total(?float $value): string +{ + if ($value === null) { + return ''; + } + + return 'CHF ' . number_format($value, 2, '.', '\''); +} + +function auth_render_bestellungen_large_table(array $bestellungenTable): string +{ + $rows = $bestellungenTable['rows'] ?? []; + $search = (string) ($bestellungenTable['search'] ?? ''); + $sortColumn = (string) ($bestellungenTable['sort_column'] ?? 'order_date'); + $sortDirection = strtoupper((string) ($bestellungenTable['sort_direction'] ?? 'DESC')); + $limit = (int) ($bestellungenTable['limit'] ?? 20); + $pageSize = (int) ($bestellungenTable['page_size'] ?? 20); + $hasMore = (bool) ($bestellungenTable['has_more'] ?? false); + $nextLimit = (int) ($bestellungenTable['next_limit'] ?? $limit); + + $sortColumns = [ + 'order_date' => 'Bestelldatum', + 'external_ref' => 'Bestellnummer', + 'last_name' => 'Nachname', + 'first_name' => 'Vorname', + 'total_amount' => 'Gesamtsumme', + ]; + + $html = []; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
Bestellungen
'; + $html[] = ''; + $html[] = ''; + $html[] = ''; + $html[] = ''; + $html[] = ''; + $html[] = ''; + $html[] = '
'; + $html[] = '
'; + + $headerIndex = 0; + foreach ($sortColumns as $column => $label) { + $isActive = $column === $sortColumn; + $headerDirection = $isActive ? strtolower($sortDirection) : 'none'; + $iconDirection = $isActive ? strtolower($sortDirection) : 'ascending'; + $ariaLabel = $isActive + ? $label . ' ' . ($sortDirection === 'ASC' ? 'aufsteigend sortiert' : 'absteigend sortiert') + : $label . ' sortieren'; + $targetDirection = $isActive + ? ($sortDirection === 'ASC' ? 'DESC' : 'ASC') + : ($column === 'order_date' ? 'DESC' : 'ASC'); + + $html[] = '
'; + $html[] = ''; + $html[] = '
'; + $headerIndex++; + } + + $html[] = '
'; + + foreach ($rows as $index => $row) { + $isStriped = $index % 2 === 1; + $rowClass = 'sg-card-segment sg-card-segment--body sg-card-segment--white sg-large-table__row'; + if ($isStriped) { + $rowClass .= ' sg-large-table__row--striped-light'; + } + + $orderNumber = (string) ($row['external_ref'] ?? ''); + $orderDate = auth_format_bestellung_date((string) ($row['order_date'] ?? '')); + $totalAmount = auth_format_bestellung_total(isset($row['total_amount']) ? (float) $row['total_amount'] : null); + $firstName = (string) ($row['first_name'] ?? ''); + $lastName = (string) ($row['last_name'] ?? ''); + $street = (string) ($row['street'] ?? ''); + $houseNumber = (string) ($row['house_number'] ?? ''); + $zip = (string) ($row['zip'] ?? ''); + $city = (string) ($row['city'] ?? ''); + $country = (string) ($row['country_name'] ?? ''); + + $html[] = '
'; + $html[] = '
' . auth_escape_html($orderDate) . '
'; + $html[] = '
'; + $html[] = ''; + $html[] = '
'; + $html[] = '
' . auth_escape_html($lastName) . '
'; + $html[] = '
' . auth_escape_html($firstName) . '
'; + $html[] = '
' . auth_escape_html($totalAmount) . '
'; + $html[] = '
'; + } + + if ($hasMore) { + $loadMoreUrl = auth_build_path_with_query([ + 'bestellungen_search' => $search !== '' ? $search : null, + 'bestellungen_sort' => $sortColumn, + 'bestellungen_dir' => $sortDirection, + 'bestellungen_limit' => $nextLimit, + ]); + + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = 'Mehr laden'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + } + + $html[] = '
'; + $html[] = '
'; + + $html[] = ''; + + return implode('', $html); +} + +function render_auth_home_page(array $user, array $otcProducts = [], array $bestellungenTable = []): void { $otcProductRows = ''; foreach ($otcProducts as $product) { @@ -112,6 +299,7 @@ function render_auth_home_page(array $user, array $otcProducts = []): void echo ''; echo ''; echo ''; + echo auth_render_bestellungen_large_table($bestellungenTable); echo ''; echo ''; echo ''; @@ -310,6 +498,150 @@ function render_auth_home_page(array $user, array $otcProducts = []): void echo " mediaQuery.addEventListener('change', syncMode);"; echo "})();"; echo "(() => {"; + echo " const table = document.querySelector('[data-bestellungen-large-table]');"; + echo " if (!table) { return; }"; + echo " const searchInput = table.querySelector('[data-bestellungen-search-input]');"; + echo " const clearButton = table.querySelector('[data-bestellungen-search-clear]');"; + echo " const sortButtons = Array.from(table.querySelectorAll('[data-bestellungen-sort-button]'));"; + echo " const drawer = document.querySelector('[data-bestellungen-drawer]');"; + echo " const drawerTitle = drawer ? drawer.querySelector('[data-order-drawer-title]') : null;"; + echo " const drawerOrderNumber = drawer ? drawer.querySelector('[data-order-drawer-order-number]') : null;"; + echo " const drawerOrderDate = drawer ? drawer.querySelector('[data-order-drawer-order-date]') : null;"; + echo " const drawerAddressLine1 = drawer ? drawer.querySelector('[data-order-drawer-address-line-1]') : null;"; + echo " const drawerAddressLine2 = drawer ? drawer.querySelector('[data-order-drawer-address-line-2]') : null;"; + echo " const drawerAddressLine3 = drawer ? drawer.querySelector('[data-order-drawer-address-line-3]') : null;"; + echo " const drawerTotal = drawer ? drawer.querySelector('[data-order-drawer-total]') : null;"; + echo " const searchWrap = searchInput ? searchInput.closest('[data-component=\"single-line-input\"]') : null;"; + echo " const defaultLimit = parseInt(table.dataset.bestellungenPageSize || table.dataset.bestellungenLimit || '20', 10);"; + echo " const currentSortColumn = table.dataset.bestellungenSortColumn || 'order_date';"; + echo " const currentSortDirection = (table.dataset.bestellungenSortDirection || 'DESC').toUpperCase();"; + echo " let searchTimer = null;"; + echo " const setWrapState = (input) => {"; + echo " if (!searchWrap || !input) { return; }"; + echo " searchWrap.setAttribute('data-has-value', String(input.value.trim().length > 0));"; + echo " searchWrap.dataset.componentState = input.value.trim().length > 0 ? 'active' : 'inactive-selectable';"; + echo " };"; + echo " const buildUrl = (params) => {"; + echo " const url = new URL(window.location.href);"; + echo " Object.entries(params).forEach(([key, value]) => {"; + echo " if (value === null || value === undefined || value === '') {"; + echo " url.searchParams.delete(key);"; + echo " } else {"; + echo " url.searchParams.set(key, String(value));"; + echo " }"; + echo " });"; + echo " return url.toString();"; + echo " };"; + echo " const navigate = (params) => {"; + echo " window.location.assign(buildUrl(params));"; + echo " };"; + echo " const currentSearchValue = () => (searchInput ? searchInput.value.trim() : '');"; + echo " const resetLimit = () => defaultLimit;"; + echo " const closeDrawer = () => {"; + echo " if (!drawer) { return; }"; + echo " drawer.dataset.open = 'false';"; + echo " drawer.setAttribute('aria-hidden', 'true');"; + echo " document.querySelectorAll('[data-order-drawer-open]').forEach((trigger) => {"; + echo " trigger.setAttribute('aria-expanded', 'false');"; + echo " });"; + echo " };"; + echo " const setDrawerField = (node, value) => {"; + echo " if (node) { node.textContent = value || ''; }"; + echo " };"; + echo " const formatAddressLine = (street, houseNumber, zip, city, country) => {"; + echo " const streetLine = [street, houseNumber].filter((part) => part && part.length > 0).join(' ').trim();"; + echo " const cityLine = [zip, city].filter((part) => part && part.length > 0).join(' ').trim();"; + echo " return [streetLine, cityLine, country].filter((part) => part && part.length > 0).join(' · ');"; + echo " };"; + echo " const openDrawer = (trigger) => {"; + echo " if (!drawer) { return; }"; + echo " const orderNumber = trigger.dataset.orderNumber || '';"; + echo " const orderDate = trigger.dataset.orderDate || '';"; + echo " const total = trigger.dataset.orderTotal || '';"; + echo " const firstName = trigger.dataset.orderFirstName || '';"; + echo " const lastName = trigger.dataset.orderLastName || '';"; + echo " const street = trigger.dataset.orderStreet || '';"; + echo " const houseNumber = trigger.dataset.orderHouseNumber || '';"; + echo " const zip = trigger.dataset.orderZip || '';"; + echo " const city = trigger.dataset.orderCity || '';"; + echo " const country = trigger.dataset.orderCountry || '';"; + echo " drawer.dataset.open = 'true';"; + echo " drawer.setAttribute('aria-hidden', 'false');"; + echo " if (drawerTitle) { drawerTitle.textContent = orderNumber !== '' ? 'Bestellung ' + orderNumber : 'Bestellung'; }"; + echo " setDrawerField(drawerOrderNumber, orderNumber);"; + echo " setDrawerField(drawerOrderDate, orderDate);"; + echo " setDrawerField(drawerAddressLine1, formatAddressLine(street, houseNumber, '', '', ''));"; + echo " setDrawerField(drawerAddressLine2, formatAddressLine('', '', zip, city, ''));"; + echo " setDrawerField(drawerAddressLine3, country);"; + echo " setDrawerField(drawerTotal, total);"; + echo " document.querySelectorAll('[data-order-drawer-open]').forEach((button) => {"; + echo " button.setAttribute('aria-expanded', String(button === trigger));"; + echo " });"; + echo " };"; + echo " sortButtons.forEach((button) => {"; + echo " button.addEventListener('click', () => {"; + echo " const sortColumn = button.dataset.bestellungenSortColumn || currentSortColumn;"; + echo " const sortDirection = button.dataset.bestellungenSortDirection || currentSortDirection;"; + echo " navigate({"; + echo " bestellungen_search: currentSearchValue(),"; + echo " bestellungen_sort: sortColumn,"; + echo " bestellungen_dir: sortDirection,"; + echo " bestellungen_limit: resetLimit(),"; + echo " });"; + echo " });"; + echo " });"; + echo " if (searchInput) {"; + echo " searchInput.addEventListener('input', () => {"; + echo " setWrapState(searchInput);"; + echo " if (searchTimer) { window.clearTimeout(searchTimer); }"; + echo " searchTimer = window.setTimeout(() => {"; + echo " navigate({"; + echo " bestellungen_search: currentSearchValue(),"; + echo " bestellungen_sort: currentSortColumn,"; + echo " bestellungen_dir: currentSortDirection,"; + echo " bestellungen_limit: resetLimit(),"; + echo " });"; + echo " }, 250);"; + echo " });"; + echo " }"; + echo " if (clearButton && searchInput) {"; + echo " clearButton.addEventListener('click', () => {"; + echo " if (searchTimer) { window.clearTimeout(searchTimer); searchTimer = null; }"; + echo " searchInput.value = '';"; + echo " setWrapState(searchInput);"; + echo " navigate({"; + echo " bestellungen_search: '',"; + echo " bestellungen_sort: currentSortColumn,"; + echo " bestellungen_dir: currentSortDirection,"; + echo " bestellungen_limit: resetLimit(),"; + echo " });"; + echo " });"; + echo " }"; + echo " document.addEventListener('click', (event) => {"; + echo " const openTrigger = event.target.closest('[data-order-drawer-open]');"; + echo " if (openTrigger) {"; + echo " event.preventDefault();"; + echo " openDrawer(openTrigger);"; + echo " return;"; + echo " }"; + echo " const closeTrigger = event.target.closest('[data-order-drawer-close]');"; + echo " if (closeTrigger) {"; + echo " event.preventDefault();"; + echo " closeDrawer();"; + echo " return;"; + echo " }"; + echo " if (drawer && drawer.dataset.open === 'true' && !event.target.closest('[data-bestellungen-drawer=\"true\"]')) {"; + echo " closeDrawer();"; + echo " }"; + echo " });"; + echo " document.addEventListener('keydown', (event) => {"; + echo " if (event.key === 'Escape') {"; + echo " closeDrawer();"; + echo " }"; + echo " });"; + echo " setWrapState(searchInput);"; + echo "})();"; + echo "(() => {"; echo " const overlay = document.querySelector('[data-otc-order-overlay]');"; echo " if (!overlay) { return; }"; echo " const form = overlay.querySelector('[data-otc-order-form]');"; diff --git a/public/index.php b/public/index.php index 7138d3f..b7fae9c 100644 --- a/public/index.php +++ b/public/index.php @@ -4,6 +4,7 @@ declare(strict_types=1); require_once __DIR__ . '/../modules/shared/auth/service.php'; require_once __DIR__ . '/../modules/shared/auth/ui/login.php'; require_once __DIR__ . '/../modules/shared/auth/ui/home.php'; +require_once __DIR__ . '/../modules/erp/bestellungen/service.php'; require_once __DIR__ . '/../modules/erp/lager/service.php'; $env = expand_env_values(parse_env_file(__DIR__ . '/../.env')); @@ -45,7 +46,14 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { $currentUser = auth_current_user($pdo); if ($currentUser !== null) { $otcProducts = get_otc_order_form_products($pdo); - render_auth_home_page($currentUser, $otcProducts); + $bestellungenTable = get_sales_order_overview($pdo, [ + 'search' => (string) ($_GET['bestellungen_search'] ?? ''), + 'sort_column' => (string) ($_GET['bestellungen_sort'] ?? 'order_date'), + 'sort_direction' => (string) ($_GET['bestellungen_dir'] ?? 'DESC'), + 'limit' => (int) ($_GET['bestellungen_limit'] ?? 20), + 'page_size' => 20, + ]); + render_auth_home_page($currentUser, $otcProducts, $bestellungenTable); exit; }