trim((string) $request->input('search', '')), 'source' => trim((string) $request->input('source', '')), 'status' => trim((string) $request->input('status', '')), 'status_group' => trim((string) $request->input('status_group', '')), 'payment_status' => trim((string) $request->input('payment_status', '')), 'date_from' => trim((string) $request->input('date_from', '')), 'date_to' => trim((string) $request->input('date_to', '')), 'sort' => (string) $request->input('sort', 'ordered_at'), 'sort_dir' => (string) $request->input('sort_dir', 'DESC'), 'page' => max(1, (int) $request->input('page', 1)), 'per_page' => max(1, min(100, (int) $request->input('per_page', 20)), ), ]; $result = $this->orders->paginate($filters); $totalPages = max(1, (int) ceil(((int) $result['total']) / max(1, (int) $result['per_page']))); $sourceOptions = $this->orders->sourceOptions(); $stats = $this->orders->quickStats(); $statusCounts = $this->orders->statusCounts(); $statusConfig = $this->orders->statusPanelConfig(); $statusLabelMap = $this->statusLabelMap($statusConfig); $statusColorMap = $this->statusColorMap($statusConfig); $statusOptions = $this->buildStatusFilterOptions($this->orders->statusOptions(), $statusLabelMap); $statusPanel = $this->buildStatusPanel($statusConfig, $statusCounts, $filters['status'], $filters, $filters['status_group']); $tableRows = array_map(fn (array $row): array => $this->toTableRow($row, $statusLabelMap, $statusColorMap), (array) ($result['items'] ?? [])); $tableListData = [ 'list_key' => 'orders', 'base_path' => '/orders/list', 'query' => $filters, 'filters' => [ [ 'key' => 'search', 'label' => $this->translator->get('orders.filters.search'), 'type' => 'text', 'value' => $filters['search'], ], [ 'key' => 'source', 'label' => $this->translator->get('orders.filters.source'), 'type' => 'select', 'value' => $filters['source'], 'options' => ['' => $this->translator->get('orders.filters.any')] + $sourceOptions, ], [ 'key' => 'status', 'label' => $this->translator->get('orders.filters.status'), 'type' => 'select', 'value' => $filters['status'], 'options' => ['' => $this->translator->get('orders.filters.any')] + $statusOptions, ], [ 'key' => 'payment_status', 'label' => $this->translator->get('orders.filters.payment_status'), 'type' => 'select', 'value' => $filters['payment_status'], 'options' => $this->paymentStatusFilterOptions(), ], [ 'key' => 'date_from', 'label' => $this->translator->get('orders.filters.date_from'), 'type' => 'date', 'value' => $filters['date_from'], ], [ 'key' => 'date_to', 'label' => $this->translator->get('orders.filters.date_to'), 'type' => 'date', 'value' => $filters['date_to'], ], ], 'columns' => [ ['key' => 'order_ref', 'label' => $this->translator->get('orders.fields.order_ref'), 'sortable' => true, 'sort_key' => 'source_order_id', 'raw' => true], ['key' => 'buyer', 'label' => $this->translator->get('orders.fields.buyer'), 'raw' => true], ['key' => 'status_badges', 'label' => $this->translator->get('orders.fields.status'), 'sortable' => true, 'sort_key' => 'status_code', 'raw' => true], ['key' => 'products', 'label' => $this->translator->get('orders.fields.products'), 'raw' => true], ['key' => 'totals', 'label' => $this->translator->get('orders.fields.totals'), 'sortable' => true, 'sort_key' => 'total_with_tax', 'raw' => true], ['key' => 'shipping', 'label' => $this->translator->get('orders.fields.shipping'), 'raw' => true], ['key' => 'ordered_at', 'label' => $this->translator->get('orders.fields.ordered_at'), 'sortable' => true, 'sort_key' => 'ordered_at'], ], 'rows' => $tableRows, 'pagination' => [ 'page' => (int) ($result['page'] ?? 1), 'total_pages' => $totalPages, 'total' => (int) ($result['total'] ?? 0), 'per_page' => (int) ($result['per_page'] ?? 20), ], 'per_page_options' => [20, 50, 100], 'selectable' => true, 'select_name' => 'selected_ids[]', 'select_value_key' => 'id', 'header_actions' => [], 'empty_message' => $this->translator->get('orders.empty'), 'show_actions' => false, ]; if ($request->header('X-Requested-With') === 'XMLHttpRequest') { $tableHtml = $this->template->render('components/table-list', [ 'tableList' => $tableListData, ]); $panelHtml = $this->template->render('components/order-status-panel', [ 'statusPanelList' => $statusPanel, 'statusPanelTitle' => 'Statusy', ]); return Response::json([ 'tableHtml' => $tableHtml, 'panelHtml' => $panelHtml, ]); } $html = $this->template->render('orders/list', [ 'title' => $this->translator->get('orders.title'), 'activeMenu' => 'orders', 'activeOrders' => 'list', 'user' => $this->auth->user(), 'csrfToken' => Csrf::token(), 'tableList' => $tableListData, 'stats' => $stats, 'statusPanel' => $statusPanel, 'allStatuses' => $this->buildAllStatusOptions($statusConfig), 'statusColorMap' => $statusColorMap, 'errorMessage' => (string) ($result['error'] ?? ''), ], 'layouts/app'); return Response::html($html); } public function show(Request $request): Response { $orderId = max(0, (int) $request->input('id', 0)); $details = $this->orders->findDetails($orderId); if ($details === null) { return Response::html('Not found', 404); } $order = is_array($details['order'] ?? null) ? $details['order'] : []; $items = is_array($details['items'] ?? null) ? $details['items'] : []; $addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : []; $payments = is_array($details['payments'] ?? null) ? $details['payments'] : []; $shipments = is_array($details['shipments'] ?? null) ? $details['shipments'] : []; $documents = is_array($details['documents'] ?? null) ? $details['documents'] : []; $notes = is_array($details['notes'] ?? null) ? $details['notes'] : []; $history = is_array($details['status_history'] ?? null) ? $details['status_history'] : []; $activityLog = is_array($details['activity_log'] ?? null) ? $details['activity_log'] : []; $statusCode = (string) (($order['effective_status_id'] ?? '') !== '' ? $order['effective_status_id'] : ($order['status_code'] ?? '')); $statusCounts = $this->orders->statusCounts(); $statusConfig = $this->orders->statusPanelConfig(); $statusLabelMap = $this->statusLabelMap($statusConfig); $resolvedHistory = $this->resolveHistoryLabels($history, $statusLabelMap); $allStatuses = $this->buildAllStatusOptions($statusConfig); $packages = $this->shipmentPackages !== null ? $this->shipmentPackages->findByOrderId($orderId) : []; if ($this->storagePath !== '') { foreach ($packages as &$pkg) { $lp = trim((string) ($pkg['label_path'] ?? '')); if ($lp !== '' && !file_exists($this->storagePath . '/' . $lp)) { $pkg['label_path'] = ''; } } unset($pkg); } $receipts = $this->receiptRepo !== null ? $this->receiptRepo->findByOrderId($orderId) : []; $activeReceiptConfigs = []; if ($this->receiptConfigRepo !== null) { $activeReceiptConfigs = array_filter( $this->receiptConfigRepo->listAll(), static fn(array $c): bool => (int) ($c['is_active'] ?? 0) === 1 ); } $emailTemplates = $this->emailTemplateRepo !== null ? $this->emailTemplateRepo->listActive() : []; $emailMailboxes = $this->emailMailboxRepo !== null ? $this->emailMailboxRepo->listActive() : []; $invoices = $this->invoiceRepo !== null ? $this->invoiceRepo->findByOrderId($orderId) : []; $activeInvoiceConfigs = []; if ($this->invoiceConfigRepo !== null) { $activeInvoiceConfigs = array_values(array_filter( $this->invoiceConfigRepo->listAll(), static fn (array $c): bool => (int) ($c['is_active'] ?? 0) === 1 )); } $flashSuccess = (string) Flash::get('order.success', ''); $flashError = (string) Flash::get('order.error', ''); $customerRiskInfo = $this->buildCustomerRiskInfo($order, $orderId); $html = $this->template->render('orders/show', [ 'title' => $this->translator->get('orders.details.title') . ' #' . $orderId, 'activeMenu' => 'orders', 'activeOrders' => 'list', 'user' => $this->auth->user(), 'csrfToken' => Csrf::token(), 'orderId' => $orderId, 'order' => $order, 'items' => $items, 'addresses' => $addresses, 'payments' => $payments, 'shipments' => $shipments, 'packages' => $packages, 'pendingPrintPackageIds' => $this->printJobRepo !== null ? $this->printJobRepo->pendingPackageIds() : [], 'documents' => $documents, 'notes' => $notes, 'history' => $resolvedHistory, 'activityLog' => $activityLog, 'statusLabel' => $this->statusLabel($statusCode, $statusLabelMap), 'statusPanel' => $this->buildStatusPanel($statusConfig, $statusCounts, $statusCode), 'allStatuses' => $allStatuses, 'currentStatusCode' => $statusCode, 'flashSuccess' => $flashSuccess, 'flashError' => $flashError, 'receipts' => $receipts, 'receiptConfigs' => $activeReceiptConfigs, 'invoices' => $invoices, 'invoiceConfigs' => $activeInvoiceConfigs, 'emailTemplates' => $emailTemplates, 'emailMailboxes' => $emailMailboxes, 'customerRiskInfo' => $customerRiskInfo, ], 'layouts/app'); return Response::html($html); } /** * Sklada informacje o historii zwrotow klienta biezacego zamowienia. * * @param array $order * @return array{count:int, orders:array>, email:string, phone:string, name:string, text:string} */ private function buildCustomerRiskInfo(array $order, int $orderId): array { $count = max(0, (int) ($order['customer_returned_count'] ?? 0)); $email = trim((string) ($order['buyer_email'] ?? '')); $phone = trim((string) ($order['buyer_phone'] ?? '')); $name = trim((string) ($order['buyer_name'] ?? '')); $returnedOrders = []; if ($count > 0 && $this->shipmentPackages !== null) { $returnedOrders = $this->shipmentPackages->findReturnedByCustomer( ['email' => $email, 'phone' => $phone, 'name' => $name], $orderId ); } return [ 'count' => $count, 'orders' => $returnedOrders, 'email' => $email, 'phone' => $phone, 'name' => $name, 'text' => $this->composeCustomerRiskText($count, $email, $phone, $name), ]; } private function composeCustomerRiskText(int $count, string $email, string $phone, string $name): string { if ($count <= 0) { return ''; } $hasPhone = $phone !== ''; $hasEmail = $email !== ''; $hasName = $name !== ''; if ($hasPhone && $hasEmail) { $subject = 'Osoba o numerze telefonu ' . $phone . ' oraz email ' . $email; } elseif ($hasEmail) { $subject = 'Osoba o emailu ' . $email; } elseif ($hasPhone) { $subject = 'Osoba o numerze telefonu ' . $phone; } elseif ($hasName) { $subject = 'Osoba o imieniu i nazwisku ' . $name; } else { $subject = 'Ten klient'; } $noun = $count === 1 ? 'przesylke' : 'przesylek'; return $subject . ' nie odebrala ' . $count . ' ' . $noun . '.'; } public function updateDetails(Request $request): Response { $orderId = max(0, (int) $request->input('id', 0)); if ($orderId <= 0) { return Response::html('Not found', 404); } $csrfToken = (string) $request->input('_token', ''); if (!Csrf::validate($csrfToken)) { Flash::set('order.error', $this->translator->get('auth.errors.csrf_expired')); return Response::redirect('/orders/' . $orderId); } $details = $this->orders->findDetails($orderId); if (!is_array($details['order'] ?? null)) { return Response::html('Not found', 404); } $deliveryMethodRaw = trim((string) $request->input('delivery_method', '')); $paymentMethodRaw = trim((string) $request->input('payment_method', '')); $isCod = (string) $request->input('is_cod', '') === '1'; if ($deliveryMethodRaw === '' && $paymentMethodRaw === '') { Flash::set('order.error', 'Podaj formę dostawy lub formę płatności.'); return Response::redirect('/orders/' . $orderId); } $deliveryMethod = $deliveryMethodRaw !== '' ? $deliveryMethodRaw : null; $paymentMethod = $paymentMethodRaw !== '' ? $paymentMethodRaw : null; $externalPaymentTypeId = null; $currentOrder = (array) $details['order']; $currentExternal = strtoupper(trim((string) ($currentOrder['external_payment_type_id'] ?? ''))); $currentlyCod = StringHelper::isCodPayment($currentExternal); if ($isCod && !$currentlyCod) { $externalPaymentTypeId = 'CASH_ON_DELIVERY'; } elseif (!$isCod && $currentlyCod) { $externalPaymentTypeId = ''; } $user = $this->auth->user(); $actorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : null; $changed = $this->orders->updateDeliveryAndPayment( $orderId, $deliveryMethod, $paymentMethod, $externalPaymentTypeId, 'user', $actorName !== '' ? $actorName : null ); if ($changed) { Flash::set('order.success', 'Dane zamówienia zostały zaktualizowane.'); } else { Flash::set('order.error', 'Brak zmian do zapisania.'); } return Response::redirect('/orders/' . $orderId); } public function updateStatus(Request $request): Response { $isAjax = strtolower($request->header('X-Requested-With')) === 'xmlhttprequest'; $orderId = max(0, (int) $request->input('id', 0)); if ($orderId <= 0) { return $isAjax ? Response::json(['success' => false, 'error' => 'Not found'], 404) : Response::html('Not found', 404); } $csrfToken = (string) $request->input('_token', ''); if (!Csrf::validate($csrfToken)) { if ($isAjax) { return Response::json(['success' => false, 'error' => $this->translator->get('auth.errors.csrf_expired')], 403); } Flash::set('order.error', $this->translator->get('auth.errors.csrf_expired')); return Response::redirect('/orders/' . $orderId); } $newStatus = trim((string) $request->input('new_status', '')); if ($newStatus === '') { if ($isAjax) { return Response::json(['success' => false, 'error' => $this->translator->get('orders.details.status_change.status_required')], 422); } Flash::set('order.error', $this->translator->get('orders.details.status_change.status_required')); return Response::redirect('/orders/' . $orderId); } $user = $this->auth->user(); $actorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : null; $oldDetails = $this->orders->findDetails($orderId); $oldOrder = is_array($oldDetails['order'] ?? null) ? $oldDetails['order'] : []; $oldStatus = strtolower(trim((string) ($oldOrder['status_code'] ?? ''))); $success = $this->orders->updateOrderStatus($orderId, $newStatus, 'user', $actorName !== '' ? $actorName : null); if ($success) { $normalizedNew = strtolower(trim($newStatus)); if ($oldStatus !== $normalizedNew) { try { $this->automation?->trigger('order.status_changed', $orderId, [ 'old_status' => $oldStatus, 'new_status' => $normalizedNew, ]); } catch (\Throwable) { } } } if ($isAjax) { if (!$success) { return Response::json(['success' => false, 'error' => $this->translator->get('orders.details.status_change.failed')], 500); } $statusConfig = $this->orders->statusPanelConfig(); $statusLabelMap = $this->statusLabelMap($statusConfig); $statusColorMap = $this->statusColorMap($statusConfig); $normalizedCode = strtolower(trim($newStatus)); return Response::json([ 'success' => true, 'status_code' => $normalizedCode, 'status_label' => $this->statusLabel($normalizedCode, $statusLabelMap), 'status_color' => $statusColorMap[$normalizedCode] ?? '', ]); } if ($success) { Flash::set('order.success', $this->translator->get('orders.details.status_change.success')); } else { Flash::set('order.error', $this->translator->get('orders.details.status_change.failed')); } return Response::redirect('/orders/' . $orderId); } public function toggleInvoiceRequested(Request $request): Response { $orderId = max(0, (int) $request->input('id', 0)); if ($orderId <= 0) { return Response::json(['success' => false, 'error' => 'Not found'], 404); } if (!Csrf::validate((string) $request->input('_token', ''))) { return Response::json(['success' => false, 'error' => $this->translator->get('auth.errors.csrf_expired')], 403); } $value = (int) $request->input('invoice_requested', 0) === 1; $this->orders->setInvoiceRequested($orderId, $value); $user = $this->auth->user(); $actorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : null; $this->orders->recordActivity( $orderId, 'invoice_requested_changed', 'Klient prosi o fakture: ' . ($value ? 'tak' : 'nie'), ['invoice_requested' => $value ? 1 : 0], 'user', $actorName !== '' ? $actorName : null ); return Response::json(['success' => true, 'invoice_requested' => $value ? 1 : 0]); } /** * @param array $row * @return array */ private function toTableRow(array $row, array $statusLabelMap, array $statusColorMap = []): array { $internalOrderNumber = trim((string) ($row['internal_order_number'] ?? '')); $sourceOrderId = trim((string) ($row['source_order_id'] ?? '')); $externalOrderId = trim((string) ($row['external_order_id'] ?? '')); $source = trim((string) ($row['source'] ?? '')); $integrationName = trim((string) ($row['integration_name'] ?? '')); $buyerName = trim((string) ($row['buyer_name'] ?? '')); $buyerEmail = trim((string) ($row['buyer_email'] ?? '')); $buyerCity = trim((string) ($row['buyer_city'] ?? '')); $status = trim((string) (($row['effective_status_id'] ?? '') !== '' ? $row['effective_status_id'] : ($row['status_code'] ?? ''))); $currency = trim((string) ($row['currency'] ?? '')); $totalWithTax = $row['total_with_tax'] !== null ? number_format((float) $row['total_with_tax'], 2, '.', ' ') : '-'; $totalPaid = $row['total_paid'] !== null ? number_format((float) $row['total_paid'], 2, '.', ' ') : '-'; $paymentType = strtoupper(trim((string) ($row['external_payment_type_id'] ?? ''))); $isCod = StringHelper::isCodPayment($paymentType); $paymentStatus = isset($row['payment_status']) ? (int) $row['payment_status'] : null; $isUnpaid = !$isCod && $paymentStatus === 0; $itemsCount = max(0, (int) ($row['items_count'] ?? 0)); $itemsQty = $this->formatQuantity((float) ($row['items_qty'] ?? 0)); $shipments = max(0, (int) ($row['shipments_count'] ?? 0)); $documents = max(0, (int) ($row['documents_count'] ?? 0)); $itemsPreview = is_array($row['items_preview'] ?? null) ? $row['items_preview'] : []; $projectsDone = max(0, (int) ($row['projects_done'] ?? 0)); $projectsTotal = max(0, (int) ($row['projects_total'] ?? 0)); $returnedCount = max(0, (int) ($row['customer_returned_count'] ?? 0)); $returnedBadge = $returnedCount >= 1 ? ' zwroty: ' . $returnedCount . '' : ''; $previewBtn = ''; return [ 'id' => (int) ($row['id'] ?? 0), 'order_ref' => '
' . '' . '
' . '' . htmlspecialchars($integrationName !== '' ? $integrationName : $this->sourceLabel($source), ENT_QUOTES, 'UTF-8') . '' . 'ID: ' . htmlspecialchars($sourceOrderId !== '' ? $sourceOrderId : $externalOrderId, ENT_QUOTES, 'UTF-8') . '' . '
' . '
', 'buyer' => '
' . '
' . htmlspecialchars($buyerName !== '' ? $buyerName : '-', ENT_QUOTES, 'UTF-8') . $returnedBadge . '
' . '
' . '' . htmlspecialchars($buyerEmail, ENT_QUOTES, 'UTF-8') . '' . '' . htmlspecialchars($buyerCity, ENT_QUOTES, 'UTF-8') . '' . '
' . '
', 'status_badges' => '
' . $this->statusBadge($status, $this->statusLabel($status, $statusLabelMap), $statusColorMap[strtolower(trim($status))] ?? '') . '
', 'products' => $this->productsHtml($itemsPreview, $itemsCount, $itemsQty, $projectsDone, $projectsTotal), 'totals' => '
' . '
' . htmlspecialchars($totalWithTax . ' ' . $currency, ENT_QUOTES, 'UTF-8') . ($isUnpaid ? ' Nieopłacone' : '') . '
' . '
' . ($isCod ? 'Za pobraniem' : 'oplacono: ' . htmlspecialchars($totalPaid . ' ' . $currency, ENT_QUOTES, 'UTF-8')) . '
' . '
', 'shipping' => $this->shippingHtml( trim((string) ($row['external_carrier_id'] ?? '')), $shipments, $documents ), 'ordered_at' => (string) ($row['ordered_at'] ?? ''), '_row_class' => trim($this->agedRowClass((string) ($row['ordered_at'] ?? '')) . ($returnedCount >= 1 ? ' is-risk-return' : '')), ]; } private function agedRowClass(string $orderedAt): string { if ($orderedAt === '') { return ''; } $ts = strtotime($orderedAt); if ($ts === false) { return ''; } $ageDays = (int) floor((time() - $ts) / 86400); if ($ageDays < 4) { return ''; } $level = $ageDays >= 7 ? 7 : $ageDays; return 'order-row-aged order-row-aged-' . $level; } private function statusBadge(string $statusCode, string $statusLabel, string $colorHex = ''): string { $label = $statusLabel !== '' ? $statusLabel : '-'; $code = strtolower(trim($statusCode)); if ($colorHex !== '') { $style = 'background-color:' . htmlspecialchars($colorHex, ENT_QUOTES, 'UTF-8') . ';color:#fff'; return '' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . ''; } $class = 'is-neutral'; if (in_array($code, ['shipped', 'delivered'], true)) { $class = 'is-success'; } elseif (in_array($code, ['cancelled', 'returned'], true)) { $class = 'is-danger'; } elseif (in_array($code, ['new', 'confirmed'], true)) { $class = 'is-info'; } elseif (in_array($code, ['processing', 'packed', 'paid'], true)) { $class = 'is-warn'; } return '' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . ''; } private function sourceLabel(string $source): string { return match (strtolower(trim($source))) { 'allegro' => 'Allegro', 'shoppro' => 'shopPRO', 'erli' => 'Erli', default => ucfirst(strtolower(trim($source))), }; } private function statusLabel(string $statusCode, array $statusLabelMap = []): string { $key = strtolower(trim($statusCode)); if ($key === '') { return '-'; } if (isset($statusLabelMap[$key])) { return (string) $statusLabelMap[$key]; } $normalized = str_replace(['_', '-'], ' ', $key); return ucfirst($normalized); } /** * @param array}> $config * @param array $counts * @return array> */ private function buildStatusPanel(array $config, array $counts, string $currentStatusCode, array $query = [], string $currentStatusGroup = ''): array { $allCount = 0; foreach ($counts as $count) { $allCount += (int) $count; } $result = [[ 'name' => '', 'items' => [[ 'code' => '', 'label' => 'Wszystkie', 'count' => $allCount, 'is_active' => trim($currentStatusCode) === '' && trim($currentStatusGroup) === '', 'tone' => 'neutral', 'color_hex' => '#64748b', 'url' => $this->statusFilterUrl($query, ''), ]], ]]; foreach ($config as $group) { $items = []; $groupColor = StringHelper::normalizeColorHex((string) ($group['color_hex'] ?? '#64748b')); $groupId = (string) ((int) ($group['id'] ?? 0)); $groupItems = is_array($group['items'] ?? null) ? $group['items'] : []; $isActiveGroup = $currentStatusGroup !== '' && $currentStatusGroup === $groupId; $groupCount = 0; foreach ($groupItems as $status) { $code = strtolower(trim((string) ($status['code'] ?? ''))); if ($code === '') { continue; } $statusCount = (int) ($counts[$code] ?? 0); $groupCount += $statusCount; $items[] = [ 'code' => $code, 'label' => (string) ($status['name'] ?? $code), 'count' => $statusCount, 'is_active' => !$isActiveGroup && trim(strtolower($currentStatusCode)) === $code, 'tone' => $this->statusTone($code), 'color_hex' => $groupColor, 'url' => $this->statusFilterUrl($query, $code), ]; } if ($items === []) { continue; } $result[] = [ 'name' => (string) ($group['name'] ?? ''), 'color_hex' => $groupColor, 'group_id' => $groupId, 'group_url' => $this->groupFilterUrl($query, $groupId), 'group_count' => $groupCount, 'is_active_group' => $isActiveGroup, 'items' => $items, ]; } $usedCodes = []; foreach ($result as $group) { $items = is_array($group['items'] ?? null) ? $group['items'] : []; foreach ($items as $item) { $code = strtolower(trim((string) ($item['code'] ?? ''))); if ($code !== '') { $usedCodes[$code] = true; } } } $extraItems = []; foreach ($counts as $code => $count) { $normalizedCode = strtolower(trim((string) $code)); if ($normalizedCode === '' || $normalizedCode === '_empty' || isset($usedCodes[$normalizedCode])) { continue; } $extraItems[] = [ 'code' => $normalizedCode, 'label' => $this->statusLabel($normalizedCode), 'count' => (int) $count, 'is_active' => trim(strtolower($currentStatusCode)) === $normalizedCode, 'tone' => $this->statusTone($normalizedCode), 'color_hex' => '#64748b', 'url' => $this->statusFilterUrl($query, $normalizedCode), ]; } if ($extraItems !== []) { $result[] = [ 'name' => 'Pozostale', 'color_hex' => '#64748b', 'items' => $extraItems, ]; } return $result; } /** * @param array $query */ private function statusFilterUrl(array $query, string $statusCode): string { $params = $query; unset($params['status_group']); if ($statusCode === '') { unset($params['status']); } else { $params['status'] = $statusCode; } $params['page'] = 1; $clean = []; foreach ($params as $key => $value) { if ($value === '' || $value === null) { continue; } $clean[(string) $key] = (string) $value; } $qs = http_build_query($clean); return $qs === '' ? '/orders/list' : '/orders/list?' . $qs; } private function groupFilterUrl(array $query, string $groupId): string { $params = $query; unset($params['status']); if ($groupId === '' || $groupId === '0') { unset($params['status_group']); } else { $params['status_group'] = $groupId; } $params['page'] = 1; $clean = []; foreach ($params as $key => $value) { if ($value === '' || $value === null) { continue; } $clean[(string) $key] = (string) $value; } $qs = http_build_query($clean); return $qs === '' ? '/orders/list' : '/orders/list?' . $qs; } private function statusTone(string $statusCode): string { $code = strtolower(trim($statusCode)); if (in_array($code, ['new', 'confirmed'], true)) { return 'info'; } if (in_array($code, ['paid', 'processing', 'packed'], true)) { return 'warn'; } if (in_array($code, ['shipped', 'delivered'], true)) { return 'success'; } if (in_array($code, ['cancelled', 'returned'], true)) { return 'danger'; } return 'neutral'; } /** * @param array}> $config * @return array */ private function statusLabelMap(array $config): array { $map = []; foreach ($config as $group) { $items = is_array($group['items'] ?? null) ? $group['items'] : []; foreach ($items as $item) { $code = strtolower(trim((string) ($item['code'] ?? ''))); if ($code === '') { continue; } $map[$code] = (string) ($item['name'] ?? $code); } } return $map; } /** * @param array}> $config * @return array */ private function statusColorMap(array $config): array { $map = []; foreach ($config as $group) { $groupColor = StringHelper::normalizeColorHex((string) ($group['color_hex'] ?? '')); if ($groupColor === '') { continue; } $items = is_array($group['items'] ?? null) ? $group['items'] : []; foreach ($items as $item) { $code = strtolower(trim((string) ($item['code'] ?? ''))); if ($code !== '') { $map[$code] = $groupColor; } } } return $map; } /** * @param array $statusCodes * @param array $statusLabelMap * @return array */ private function buildStatusFilterOptions(array $statusCodes, array $statusLabelMap): array { $options = []; foreach ($statusCodes as $code => $value) { $rawCode = trim((string) ($code !== '' ? $code : $value)); if ($rawCode === '') { continue; } $normalizedCode = strtolower($rawCode); $options[$normalizedCode] = $this->statusLabel($normalizedCode, $statusLabelMap); } return $options; } /** * @param array> $itemsPreview */ private function productsHtml(array $itemsPreview, int $itemsCount, string $itemsQty, int $projectsDone = 0, int $projectsTotal = 0): string { if ($itemsPreview === []) { return '
' . '
0 pozycji / 0.000 szt.
' . '
'; } $html = '
'; foreach ($itemsPreview as $item) { $name = trim((string) ($item['name'] ?? '')); $qty = $this->formatQuantity((float) ($item['quantity'] ?? 0)); $mediaUrl = trim((string) ($item['media_url'] ?? '')); $thumb = $mediaUrl !== '' ? '' . '' . '' . '' : ''; $html .= '
' . $thumb . '
' . '
' . htmlspecialchars($name !== '' ? $name : '-', ENT_QUOTES, 'UTF-8') . '
' . '
' . htmlspecialchars($qty, ENT_QUOTES, 'UTF-8') . ' szt.
' . '
' . '
'; } if ($itemsCount > count($itemsPreview)) { $html .= '
+' . ($itemsCount - count($itemsPreview)) . ' pozycji
'; } $html .= '
' . $itemsCount . ' pozycji / ' . htmlspecialchars($itemsQty, ENT_QUOTES, 'UTF-8') . ' szt.' . $this->projectBadge($projectsDone, $projectsTotal) . '
'; $html .= '
'; return $html; } private function projectBadge(int $done, int $total): string { if ($total === 0) { return ''; } if ($done === $total) { return ' ' . '' . ''; } if ($done > 0) { return ' ' . $done . '/' . $total . ''; } return ' ' . '' . ''; } private function shippingHtml(string $deliveryMethod, int $shipments, int $documents): string { $deliveryMethod = trim(html_entity_decode(strip_tags($deliveryMethod), ENT_QUOTES | ENT_HTML5, 'UTF-8')); $html = '
'; if ($deliveryMethod !== '' && !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $deliveryMethod)) { $html .= '
' . htmlspecialchars($deliveryMethod, ENT_QUOTES, 'UTF-8') . '
'; } $html .= '
wys.: ' . $shipments . ' dok.: ' . $documents . '
'; $html .= '
'; return $html; } private function formatQuantity(float $value): string { $rounded = round($value, 3); if (abs($rounded - round($rounded)) < 0.0005) { return (string) (int) round($rounded); } $formatted = number_format($rounded, 3, '.', ''); return rtrim(rtrim($formatted, '0'), '.'); } /** * @return array */ private function paymentStatusFilterOptions(): array { return [ '' => $this->translator->get('orders.filters.any'), '0' => 'nieoplacone', '1' => 'czesciowo oplacone', '2' => 'oplacone', '3' => 'zwrocone', ]; } /** * @param array}> $config * @return array */ private function buildAllStatusOptions(array $config): array { $options = []; foreach ($config as $group) { $groupName = trim((string) ($group['name'] ?? '')); $items = is_array($group['items'] ?? null) ? $group['items'] : []; foreach ($items as $item) { $code = strtolower(trim((string) ($item['code'] ?? ''))); if ($code === '') { continue; } $options[] = [ 'code' => $code, 'name' => (string) ($item['name'] ?? $code), 'group' => $groupName, ]; } } return $options; } /** * @param array> $history * @param array $statusLabelMap * @return array> */ private function resolveHistoryLabels(array $history, array $statusLabelMap): array { return array_map(function (array $entry) use ($statusLabelMap): array { $fromCode = trim((string) ($entry['from_status_id'] ?? '')); $toCode = trim((string) ($entry['to_status_id'] ?? '')); $entry['from_label'] = $fromCode !== '' ? $this->statusLabel($fromCode, $statusLabelMap) : '-'; $entry['to_label'] = $toCode !== '' ? $this->statusLabel($toCode, $statusLabelMap) : '-'; return $entry; }, $history); } public function sendEmail(Request $request): Response { $orderId = max(0, (int) $request->input('id', 0)); if ($orderId <= 0) { return Response::json(['success' => false, 'message' => 'Nieprawidlowe zamowienie'], 400); } $csrfToken = (string) $request->input('_token', ''); if (!Csrf::validate($csrfToken)) { return Response::json(['success' => false, 'message' => 'Sesja wygasla, odswiez strone'], 403); } if ($this->emailService === null) { return Response::json(['success' => false, 'message' => 'Modul e-mail nie jest skonfigurowany'], 500); } $templateId = max(0, (int) $request->input('template_id', 0)); if ($templateId <= 0) { return Response::json(['success' => false, 'message' => 'Wybierz szablon'], 400); } $mailboxId = (int) $request->input('mailbox_id', 0); $user = $this->auth->user(); $userName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : ''; $result = $this->emailService->send($orderId, $templateId, $mailboxId > 0 ? $mailboxId : null, $userName !== '' ? $userName : null); return Response::json([ 'success' => $result['success'], 'message' => $result['success'] ? 'E-mail wyslany pomyslnie' : ('Blad wysylki: ' . ($result['error'] ?? 'nieznany')), ]); } public function emailPreview(Request $request): Response { $orderId = max(0, (int) $request->input('id', 0)); $templateId = max(0, (int) $request->input('template_id', 0)); if ($orderId <= 0 || $templateId <= 0 || $this->emailService === null) { return Response::json(['subject' => '', 'body_html' => '', 'attachments' => []], 400); } $preview = $this->emailService->preview($orderId, $templateId); return Response::json($preview); } public function addPayment(Request $request): Response { $orderId = max(0, (int) $request->input('id', 0)); if ($orderId <= 0) { return Response::json(['ok' => false, 'error' => 'Nieprawidłowe ID zamówienia.'], 400); } if (!Csrf::validate((string) $request->input('_token', ''))) { return Response::json(['ok' => false, 'error' => 'Nieprawidłowy token CSRF.'], 403); } $amount = (float) $request->input('amount', 0); $paymentTypeId = trim((string) $request->input('payment_type_id', '')); $paymentDate = trim((string) $request->input('payment_date', '')); $comment = trim((string) $request->input('comment', '')); if ($amount <= 0) { return Response::json(['ok' => false, 'error' => 'Kwota musi być większa od 0.'], 422); } if ($paymentTypeId === '') { return Response::json(['ok' => false, 'error' => 'Wybierz typ płatności.'], 422); } try { $result = $this->orders->addPayment($orderId, [ 'amount' => $amount, 'payment_type_id' => $paymentTypeId, 'payment_date' => $paymentDate !== '' ? $paymentDate . ' ' . date('H:i:s') : '', 'comment' => $comment, ]); } catch (\Throwable $ex) { return Response::json(['ok' => false, 'error' => 'Błąd zapisu: ' . $ex->getMessage()], 500); } if ($result === null) { return Response::json(['ok' => false, 'error' => 'Nie udało się zapisać płatności.'], 500); } $this->orders->recordActivity( $orderId, 'payment', 'Dodano płatność: ' . number_format($amount, 2, '.', ' ') . ' PLN (' . $paymentTypeId . ')', ['payment_id' => $result['id'], 'amount' => $amount, 'type' => $paymentTypeId], 'user', ($this->auth->user() ?? [])['name'] ?? null ); try { $this->automation?->trigger('payment.status_changed', $orderId, [ 'new_payment_status' => (string) $result['payment_status'], 'total_paid' => $result['total_paid'], 'payment_type_id' => $paymentTypeId, ]); } catch (\Throwable) { } $this->pushPaymentToShoppro($orderId, $result['payment_status']); return Response::json([ 'ok' => true, 'payment_id' => $result['id'], 'payment_status' => $result['payment_status'], 'total_paid' => $result['total_paid'], ]); } private function pushPaymentToShoppro(int $orderId, int $paymentStatus): void { if ($paymentStatus !== 2 || $this->shopproIntegrations === null) { return; } try { $orderStmt = $this->orders->findOrderSourceInfo($orderId); if ($orderStmt === null || ($orderStmt['source'] ?? '') !== 'shoppro') { return; } $integrationId = (int) ($orderStmt['integration_id'] ?? 0); $sourceOrderId = trim((string) ($orderStmt['source_order_id'] ?? '')); if ($integrationId <= 0 || $sourceOrderId === '') { return; } $integration = $this->shopproIntegrations->findIntegration($integrationId); if ($integration === null || empty($integration['is_active']) || empty($integration['has_api_key'])) { return; } $baseUrl = trim((string) ($integration['base_url'] ?? '')); $apiKey = $this->shopproIntegrations->getApiKeyDecrypted($integrationId); if ($baseUrl === '' || $apiKey === null || trim($apiKey) === '') { return; } $client = new ShopproApiClient(); $pushResult = $client->setOrderPaid($baseUrl, $apiKey, 10, $sourceOrderId); $this->orders->recordActivity( $orderId, 'sync', $pushResult['ok'] ? 'Wysłano status płatności do shopPRO (opłacone)' : 'Błąd push płatności do shopPRO: ' . ($pushResult['message'] ?? 'unknown'), ['direction' => 'push', 'target' => 'shoppro', 'ok' => $pushResult['ok']], 'system' ); } catch (\Throwable) { } } public function quickSearch(Request $request): Response { $query = trim((string) $request->input('q', '')); if ($query === '' || mb_strlen($query) < 2) { return Response::json(['results' => []]); } $limit = min((int) $request->input('limit', 10), 20); $results = $this->orders->quickSearch($query, $limit); return Response::json(['results' => $results]); } public function preview(Request $request): Response { $orderId = max(0, (int) $request->input('id', 0)); $details = $this->orders->findDetails($orderId); if ($details === null) { return Response::html('
Zamowienie nie znalezione.
', 404); } $order = is_array($details['order'] ?? null) ? $details['order'] : []; $items = is_array($details['items'] ?? null) ? $details['items'] : []; $addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : []; $notes = is_array($details['notes'] ?? null) ? $details['notes'] : []; $addressByType = ['customer' => null, 'delivery' => null, 'invoice' => null]; foreach ($addresses as $address) { $type = (string) ($address['address_type'] ?? ''); if ($type !== '' && array_key_exists($type, $addressByType) && $addressByType[$type] === null) { $addressByType[$type] = $address; } } $html = $this->template->render('orders/partials/preview-content', [ 'order' => $order, 'items' => $items, 'addressByType' => $addressByType, 'notes' => $notes, ]); return Response::html($html); } }