Harden admin ops: integrations UI, uploads, pick/analytics polish.
continuous-integration/drone/push Build is passing

Move PayU and carrier credentials into Settings, raise nginx upload limits, fix analytics Query translation, and improve orders pick/channel UX plus missing i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Rafał MiczekandCursor committed 2026-10-19 15:09:55 +02:00
1 parent da85370936
commit bca7ef9dfa
36 files changed
+4508 -236

No files matched your search

+9
View File
@@ -4,6 +4,11 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Product image uploads are up to 10 MB (API RequestSizeLimit).
# Nginx default is 1m and returns 413 for larger bodies.
client_max_body_size 12m;
client_body_timeout 120s;
# Browser calls same-origin /api and /uploads; proxy to API container. # Browser calls same-origin /api and /uploads; proxy to API container.
location /api/ { location /api/ {
proxy_pass http://api:8080/api/; proxy_pass http://api:8080/api/;
@@ -12,6 +17,10 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_request_buffering on;
proxy_connect_timeout 60s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
} }
location /uploads/ { location /uploads/ {
+64 -3
View File
@@ -53,14 +53,40 @@ export const CS_TRANSLATIONS: TranslationMap = {
'dashboard.errorLoad': 'Nepodařilo se načíst dashboard', 'dashboard.errorLoad': 'Nepodařilo se načíst dashboard',
'dashboard.alertLowStock': 'SKU s nízkým stavem', 'dashboard.alertLowStock': 'SKU s nízkým stavem',
'dashboard.alertPending': 'čeká na platbu', 'dashboard.alertPending': 'čeká na platbu',
'dashboard.alertReviews': 'recenzí k moderaci',
'dashboard.viewOrders': 'Zobrazit objednávky',
'dashboard.openPickList': 'Otevřít pick list',
'dashboard.pendingFulfillment': 'K vyřízení',
'dashboard.pendingFulfillmentHint': 'Vyžaduje pozornost',
'dashboard.lowStockHint': 'Práh ≤ 5',
'dashboard.needsAction': 'Vyžaduje akci — objednávky',
'dashboard.pendingReviews': 'Recenze k moderaci',
'dashboard.moderate': 'Moderovat',
'dashboard.restock': 'Doplnit',
'dashboard.noActionOrders': 'Žádné objednávky nevyžadují akci.',
'dashboard.noLowStock': 'Stavy skladu vypadají v pořádku.',
'dashboard.noPendingReviews': 'Žádné recenze nečekají.',
'orders.purpose':
'Vyřízení objednávek — vyberte více pro kompletaci, faktury nebo štítky.',
'orders.order': 'Objednávka',
'orders.bulkSelected': 'Vybráno {count}', 'orders.bulkSelected': 'Vybráno {count}',
'orders.bulkPick': 'Kompletovat / pick', 'orders.bulkPick': 'Kompletovat / pick',
'orders.bulkInvoices': 'Generovat faktury', 'orders.bulkInvoices': 'Generovat faktury',
'orders.bulkLabels': 'Generovat štítky', 'orders.bulkLabels': 'Generovat štítky',
'orders.bulkLabelsSoon': 'Hromadné štítky vygenerují přepravní štítky pro výběr.', 'orders.bulkLabelsSoon': 'Hromadné štítky vygenerují přepravní štítky pro výběr.',
'orders.bulkLabelsEmpty': 'Nejdřív vyberte objednávky',
'orders.bulkLabelsDone':
'Štítky: {{ok}} vytvořeno, {{fail}} chyb, {{skip}} přeskočeno',
'orders.bulkLabelsError': 'Hromadné generování štítků selhalo',
'orders.selectAll': 'Vybrat vše', 'orders.selectAll': 'Vybrat vše',
'orders.selectRow': 'Vybrat objednávku', 'orders.selectRow': 'Vybrat objednávku',
'products.title': 'Produkty', 'products.title': 'Produkty',
'products.purpose': 'Správa prodejního katalogu a skladů.',
'promotions.purpose': 'Kampaně a promo kódy bez zmatku.',
'newsletter.purpose':
'Komunikace s odběrateli — sestavení, náhled, bezpečné odeslání.',
'reviews.purpose':
'Fronta moderace — ochrana důvěry na obchodě.',
'products.add': 'Přidat produkt', 'products.add': 'Přidat produkt',
'products.edit': 'Upravit', 'products.edit': 'Upravit',
'products.editTitle': 'Upravit produkt', 'products.editTitle': 'Upravit produkt',
@@ -237,6 +263,11 @@ export const CS_TRANSLATIONS: TranslationMap = {
'orders.markCollected': 'Označit jako sestavené', 'orders.markCollected': 'Označit jako sestavené',
'orders.collected': 'Sestaveno', 'orders.collected': 'Sestaveno',
'orders.showCollected': 'Zobrazit sestavené', 'orders.showCollected': 'Zobrazit sestavené',
'orders.pickByOrder': 'Podle objednávek',
'orders.pickByProduct': 'Podle produktů',
'orders.pickTotalQty': 'Celkem k vyzvednutí: {{count}}',
'orders.collectProduct': 'Kompletovat všechny',
'orders.productCollected': 'Všechny kompletovány',
'orders.pendingItems': 'Čekající', 'orders.pendingItems': 'Čekající',
'orders.collectedItems': 'Sestavené', 'orders.collectedItems': 'Sestavené',
'orders.orderWarehouse': 'Sklad pro objednávku', 'orders.orderWarehouse': 'Sklad pro objednávku',
@@ -294,6 +325,7 @@ export const CS_TRANSLATIONS: TranslationMap = {
'promotions.kpiActive': 'Aktivní akce', 'promotions.kpiActive': 'Aktivní akce',
'promotions.kpiRedemptions': 'Uplatnění (stránka)', 'promotions.kpiRedemptions': 'Uplatnění (stránka)',
'promotions.kpiCodes': 'Kódy (stránka)', 'promotions.kpiCodes': 'Kódy (stránka)',
'promotions.kpiDiscount': 'Poskytnuté slevy (30 dní)',
'promotions.sectionBenefit': 'Výhoda', 'promotions.sectionBenefit': 'Výhoda',
'promotions.sectionRequirements': 'Požadavky', 'promotions.sectionRequirements': 'Požadavky',
'promotions.sectionLimits': 'Limity použití', 'promotions.sectionLimits': 'Limity použití',
@@ -330,6 +362,7 @@ export const CS_TRANSLATIONS: TranslationMap = {
'newsletter.searchCampaign': 'Hledat kampaně…', 'newsletter.searchCampaign': 'Hledat kampaně…',
'newsletter.joined': 'Připojen', 'newsletter.joined': 'Připojen',
'newsletter.sent': 'Odesláno', 'newsletter.sent': 'Odesláno',
'newsletter.recipients': 'Příjemci',
'newsletter.unsubscribed': 'Odhlášen', 'newsletter.unsubscribed': 'Odhlášen',
'newsletter.status.sent': 'Odesláno', 'newsletter.status.sent': 'Odesláno',
'newsletter.status.draft': 'Koncept', 'newsletter.status.draft': 'Koncept',
@@ -429,6 +462,8 @@ export const CS_TRANSLATIONS: TranslationMap = {
'settings.title': 'Nastavení', 'settings.title': 'Nastavení',
'settings.purpose': 'settings.purpose':
'Konfigurace obchodu — lokálně, dokud nebudou API nastavení.', 'Konfigurace obchodu — lokálně, dokud nebudou API nastavení.',
'settings.errorLoad': 'Nepodařilo se načíst nastavení',
'settings.errorSave': 'Nepodařilo se uložit nastavení',
'settings.save': 'Uložit změny', 'settings.save': 'Uložit změny',
'settings.navStore': 'Profil obchodu', 'settings.navStore': 'Profil obchodu',
'settings.navLocales': 'Jazyky a měna', 'settings.navLocales': 'Jazyky a měna',
@@ -437,10 +472,36 @@ export const CS_TRANSLATIONS: TranslationMap = {
'settings.navIntegrations': 'Integrace', 'settings.navIntegrations': 'Integrace',
'settings.navEmail': 'E-mail', 'settings.navEmail': 'E-mail',
'settings.navAccount': 'Můj účet', 'settings.navAccount': 'Můj účet',
'settings.integrationsTitle': 'Marketplace a faktury', 'settings.integrationsTitle': 'Platby, dopravci a marketplace',
'settings.integrationsHint': 'settings.integrationsHint':
'Nastavení Allegro, Amazon a iFirma. Sync objednávek a auto-faktury použijí tato nastavení po zapnutí konektorů.', 'PayU, InPost, DHL, DPD, Allegro, Amazon a iFirma — uloženo v databázi. Prázdné tajné pole ponechá uloženou hodnotu.',
'settings.secretSaved': 'uloženo', 'settings.secretSaved': 'uloženo',
'settings.shippingSandbox': 'Použít sandbox / testovací API dopravy',
'settings.live': 'Produkce',
'settings.payuPosId': 'PayU POS ID',
'settings.payuClientId': 'PayU client ID',
'settings.payuClientSecret': 'PayU client secret',
'settings.payuSecondKey': 'PayU second key (MD5)',
'settings.payuOauthUrl': 'PayU OAuth URL',
'settings.payuOrdersUrl': 'PayU orders URL',
'settings.payuNotifyUrl': 'PayU notify (webhook) URL',
'settings.payuContinueUrl': 'PayU continue URL (základ)',
'settings.inPostOrgId': 'InPost organization ID',
'settings.inPostShipXToken': 'InPost ShipX token',
'settings.inPostShipXBaseUrl': 'InPost ShipX base URL',
'settings.inPostGeowidgetConfig': 'Geowidget config',
'settings.inPostGeowidgetSdk': 'Geowidget SDK base URL',
'settings.inPostGeowidgetPublic': 'Geowidget public token',
'settings.inPostGeowidgetSandbox': 'Geowidget sandbox token',
'settings.dhlApiKey': 'DHL API key',
'settings.dhlApiSecret': 'DHL API secret',
'settings.dhlApiBaseUrl': 'DHL API base URL',
'settings.dhlWidgetUrl': 'DHL mapa Parcelshop',
'settings.dpdLogin': 'DPD login',
'settings.dpdPassword': 'DPD heslo',
'settings.dpdFid': 'DPD FID',
'settings.dpdApiBaseUrl': 'DPD API base URL',
'settings.dpdWidgetUrl': 'DPD pudofinder URL',
'settings.allegroClientId': 'Allegro client ID', 'settings.allegroClientId': 'Allegro client ID',
'settings.allegroClientSecret': 'Allegro client secret', 'settings.allegroClientSecret': 'Allegro client secret',
'settings.allegroSellerId': 'Allegro seller ID', 'settings.allegroSellerId': 'Allegro seller ID',
@@ -514,7 +575,7 @@ export const CS_TRANSLATIONS: TranslationMap = {
'settings.mode': 'Režim', 'settings.mode': 'Režim',
'settings.sandbox': 'Sandbox', 'settings.sandbox': 'Sandbox',
'settings.shippingHint': 'settings.shippingHint':
'Přihlašovací údaje zůstávají v konfiguraci serveru — zde jen flagy viditelnosti.', 'Dopravce zapněte zde. API tokeny a URL widgetů nastavíte v Integracích.',
'settings.emailTitle': 'E-mail / newsletter', 'settings.emailTitle': 'E-mail / newsletter',
'settings.fromName': 'Jméno odesílatele', 'settings.fromName': 'Jméno odesílatele',
'settings.fromAddress': 'Adresa odesílatele', 'settings.fromAddress': 'Adresa odesílatele',
+64 -3
View File
@@ -53,14 +53,40 @@ export const DE_TRANSLATIONS: TranslationMap = {
'dashboard.errorLoad': 'Dashboard konnte nicht geladen werden', 'dashboard.errorLoad': 'Dashboard konnte nicht geladen werden',
'dashboard.alertLowStock': 'SKU mit niedrigem Bestand', 'dashboard.alertLowStock': 'SKU mit niedrigem Bestand',
'dashboard.alertPending': 'warten auf Zahlung', 'dashboard.alertPending': 'warten auf Zahlung',
'dashboard.alertReviews': 'Bewertungen zur Moderation',
'dashboard.viewOrders': 'Bestellungen anzeigen',
'dashboard.openPickList': 'Kommissionierung öffnen',
'dashboard.pendingFulfillment': 'Offene Erfüllung',
'dashboard.pendingFulfillmentHint': 'Braucht Aufmerksamkeit',
'dashboard.lowStockHint': 'Schwelle ≤ 5',
'dashboard.needsAction': 'Handlungsbedarf — Bestellungen',
'dashboard.pendingReviews': 'Offene Bewertungen',
'dashboard.moderate': 'Moderieren',
'dashboard.restock': 'Auffüllen',
'dashboard.noActionOrders': 'Keine Bestellungen brauchen gerade Aktion.',
'dashboard.noLowStock': 'Bestände sehen gesund aus.',
'dashboard.noPendingReviews': 'Keine Bewertungen warten.',
'orders.purpose':
'Erfüllen und klären — mehrere auswählen zum Kommissionieren, Rechnungen oder Labels.',
'orders.order': 'Bestellung',
'orders.bulkSelected': '{count} ausgewählt', 'orders.bulkSelected': '{count} ausgewählt',
'orders.bulkPick': 'Kommissionieren', 'orders.bulkPick': 'Kommissionieren',
'orders.bulkInvoices': 'Rechnungen erzeugen', 'orders.bulkInvoices': 'Rechnungen erzeugen',
'orders.bulkLabels': 'Labels erzeugen', 'orders.bulkLabels': 'Labels erzeugen',
'orders.bulkLabelsSoon': 'Massen-Labels erzeugen Versandetiketten für die Auswahl.', 'orders.bulkLabelsSoon': 'Massen-Labels erzeugen Versandetiketten für die Auswahl.',
'orders.bulkLabelsEmpty': 'Zuerst Bestellungen auswählen',
'orders.bulkLabelsDone':
'Labels: {{ok}} erstellt, {{fail}} fehlgeschlagen, {{skip}} übersprungen',
'orders.bulkLabelsError': 'Massen-Labels fehlgeschlagen',
'orders.selectAll': 'Alle auswählen', 'orders.selectAll': 'Alle auswählen',
'orders.selectRow': 'Bestellung auswählen', 'orders.selectRow': 'Bestellung auswählen',
'products.title': 'Produkte', 'products.title': 'Produkte',
'products.purpose': 'Verkaufskatalog und Lager verwalten.',
'promotions.purpose': 'Kampagnen und Codes ohne Verwirrung führen.',
'newsletter.purpose':
'Mit Abonnenten kommunizieren — verfassen, Vorschau, sicher senden.',
'reviews.purpose':
'Moderationswarteschlange — Vertrauen im Shop schützen.',
'products.add': 'Produkt hinzufügen', 'products.add': 'Produkt hinzufügen',
'products.edit': 'Bearbeiten', 'products.edit': 'Bearbeiten',
'products.editTitle': 'Produkt bearbeiten', 'products.editTitle': 'Produkt bearbeiten',
@@ -237,6 +263,11 @@ export const DE_TRANSLATIONS: TranslationMap = {
'orders.markCollected': 'Kommissioniert', 'orders.markCollected': 'Kommissioniert',
'orders.collected': 'Kommissioniert', 'orders.collected': 'Kommissioniert',
'orders.showCollected': 'Kommissionierte anzeigen', 'orders.showCollected': 'Kommissionierte anzeigen',
'orders.pickByOrder': 'Nach Bestellung',
'orders.pickByProduct': 'Nach Produkt',
'orders.pickTotalQty': 'Gesamt zu holen: {{count}}',
'orders.collectProduct': 'Alle Positionen holen',
'orders.productCollected': 'Alle Positionen geholt',
'orders.pendingItems': 'Offen', 'orders.pendingItems': 'Offen',
'orders.collectedItems': 'Kommissioniert', 'orders.collectedItems': 'Kommissioniert',
'orders.orderWarehouse': 'Lager für Bestellung', 'orders.orderWarehouse': 'Lager für Bestellung',
@@ -294,6 +325,7 @@ export const DE_TRANSLATIONS: TranslationMap = {
'promotions.kpiActive': 'Aktive Aktionen', 'promotions.kpiActive': 'Aktive Aktionen',
'promotions.kpiRedemptions': 'Einlösungen (Seite)', 'promotions.kpiRedemptions': 'Einlösungen (Seite)',
'promotions.kpiCodes': 'Codes (Seite)', 'promotions.kpiCodes': 'Codes (Seite)',
'promotions.kpiDiscount': 'Gewährte Rabatte (30 Tage)',
'promotions.sectionBenefit': 'Vorteil', 'promotions.sectionBenefit': 'Vorteil',
'promotions.sectionRequirements': 'Voraussetzungen', 'promotions.sectionRequirements': 'Voraussetzungen',
'promotions.sectionLimits': 'Nutzungslimits', 'promotions.sectionLimits': 'Nutzungslimits',
@@ -330,6 +362,7 @@ export const DE_TRANSLATIONS: TranslationMap = {
'newsletter.searchCampaign': 'Kampagnen suchen…', 'newsletter.searchCampaign': 'Kampagnen suchen…',
'newsletter.joined': 'Beigetreten', 'newsletter.joined': 'Beigetreten',
'newsletter.sent': 'Gesendet', 'newsletter.sent': 'Gesendet',
'newsletter.recipients': 'Empfänger',
'newsletter.unsubscribed': 'Abgemeldet', 'newsletter.unsubscribed': 'Abgemeldet',
'newsletter.status.sent': 'Gesendet', 'newsletter.status.sent': 'Gesendet',
'newsletter.status.draft': 'Entwurf', 'newsletter.status.draft': 'Entwurf',
@@ -429,6 +462,8 @@ export const DE_TRANSLATIONS: TranslationMap = {
'settings.title': 'Einstellungen', 'settings.title': 'Einstellungen',
'settings.purpose': 'settings.purpose':
'Shop-Konfiguration — lokal gespeichert bis Settings-APIs existieren.', 'Shop-Konfiguration — lokal gespeichert bis Settings-APIs existieren.',
'settings.errorLoad': 'Einstellungen konnten nicht geladen werden',
'settings.errorSave': 'Einstellungen konnten nicht gespeichert werden',
'settings.save': 'Änderungen speichern', 'settings.save': 'Änderungen speichern',
'settings.navStore': 'Shop-Profil', 'settings.navStore': 'Shop-Profil',
'settings.navLocales': 'Sprachen & Währung', 'settings.navLocales': 'Sprachen & Währung',
@@ -437,10 +472,36 @@ export const DE_TRANSLATIONS: TranslationMap = {
'settings.navIntegrations': 'Integrationen', 'settings.navIntegrations': 'Integrationen',
'settings.navEmail': 'E-Mail', 'settings.navEmail': 'E-Mail',
'settings.navAccount': 'Mein Konto', 'settings.navAccount': 'Mein Konto',
'settings.integrationsTitle': 'Marktplätze & Rechnungen', 'settings.integrationsTitle': 'Zahlungen, Carrier & Marktplätze',
'settings.integrationsHint': 'settings.integrationsHint':
'Allegro-, Amazon- und iFirma-Zugangsdaten. Sync und Auto-Rechnung nutzen diese Einstellungen, sobald die Connectoren aktiv sind.', 'PayU, InPost, DHL, DPD, Allegro, Amazon und iFirma — in der Datenbank. Leeres Geheimnisfeld behält den gespeicherten Wert.',
'settings.secretSaved': 'gespeichert', 'settings.secretSaved': 'gespeichert',
'settings.shippingSandbox': 'Shipping-Sandbox / Test-APIs nutzen',
'settings.live': 'Live',
'settings.payuPosId': 'PayU POS-ID',
'settings.payuClientId': 'PayU Client-ID',
'settings.payuClientSecret': 'PayU Client-Secret',
'settings.payuSecondKey': 'PayU Second Key (MD5)',
'settings.payuOauthUrl': 'PayU OAuth-URL',
'settings.payuOrdersUrl': 'PayU Orders-URL',
'settings.payuNotifyUrl': 'PayU Notify-(Webhook)-URL',
'settings.payuContinueUrl': 'PayU Continue-URL (Basis)',
'settings.inPostOrgId': 'InPost Organisations-ID',
'settings.inPostShipXToken': 'InPost ShipX-Token',
'settings.inPostShipXBaseUrl': 'InPost ShipX Base-URL',
'settings.inPostGeowidgetConfig': 'Geowidget-Config',
'settings.inPostGeowidgetSdk': 'Geowidget SDK Base-URL',
'settings.inPostGeowidgetPublic': 'Geowidget Public-Token',
'settings.inPostGeowidgetSandbox': 'Geowidget Sandbox-Token',
'settings.dhlApiKey': 'DHL API-Key',
'settings.dhlApiSecret': 'DHL API-Secret',
'settings.dhlApiBaseUrl': 'DHL API Base-URL',
'settings.dhlWidgetUrl': 'DHL Parcelshop-Karten-URL',
'settings.dpdLogin': 'DPD Login',
'settings.dpdPassword': 'DPD Passwort',
'settings.dpdFid': 'DPD FID',
'settings.dpdApiBaseUrl': 'DPD API Base-URL',
'settings.dpdWidgetUrl': 'DPD Pudofinder-URL',
'settings.allegroClientId': 'Allegro Client-ID', 'settings.allegroClientId': 'Allegro Client-ID',
'settings.allegroClientSecret': 'Allegro Client-Secret', 'settings.allegroClientSecret': 'Allegro Client-Secret',
'settings.allegroSellerId': 'Allegro Verkäufer-ID', 'settings.allegroSellerId': 'Allegro Verkäufer-ID',
@@ -514,7 +575,7 @@ export const DE_TRANSLATIONS: TranslationMap = {
'settings.mode': 'Modus', 'settings.mode': 'Modus',
'settings.sandbox': 'Sandbox', 'settings.sandbox': 'Sandbox',
'settings.shippingHint': 'settings.shippingHint':
'Zugangsdaten bleiben in der Server-Konfiguration — hier nur Sichtbarkeitsflags.', 'Carrier hier aktivieren. API-Tokens und Widget-URLs unter Integrationen.',
'settings.emailTitle': 'E-Mail / Newsletter', 'settings.emailTitle': 'E-Mail / Newsletter',
'settings.fromName': 'Absendername', 'settings.fromName': 'Absendername',
'settings.fromAddress': 'Absenderadresse', 'settings.fromAddress': 'Absenderadresse',
+34 -3
View File
@@ -261,6 +261,11 @@ export const EN_TRANSLATIONS: TranslationMap = {
'orders.markCollected': 'Mark collected', 'orders.markCollected': 'Mark collected',
'orders.collected': 'Collected', 'orders.collected': 'Collected',
'orders.showCollected': 'Show collected', 'orders.showCollected': 'Show collected',
'orders.pickByOrder': 'By order',
'orders.pickByProduct': 'By product',
'orders.pickTotalQty': 'Total to pick: {{count}}',
'orders.collectProduct': 'Collect all lines',
'orders.productCollected': 'All lines collected',
'orders.pendingItems': 'Pending', 'orders.pendingItems': 'Pending',
'orders.collectedItems': 'Collected', 'orders.collectedItems': 'Collected',
'orders.orderWarehouse': 'Warehouse for order', 'orders.orderWarehouse': 'Warehouse for order',
@@ -463,10 +468,36 @@ export const EN_TRANSLATIONS: TranslationMap = {
'settings.navIntegrations': 'Integrations', 'settings.navIntegrations': 'Integrations',
'settings.navEmail': 'Email', 'settings.navEmail': 'Email',
'settings.navAccount': 'My account', 'settings.navAccount': 'My account',
'settings.integrationsTitle': 'Marketplace & invoicing', 'settings.integrationsTitle': 'Payments, carriers & marketplaces',
'settings.integrationsHint': 'settings.integrationsHint':
'Configure Allegro, Amazon, and iFirma credentials. Order sync and auto-invoicing use these settings once connectors are live.', 'PayU, InPost, DHL, DPD, Allegro, Amazon, and iFirma — stored in the database. Leave a secret blank to keep the saved value.',
'settings.secretSaved': 'saved', 'settings.secretSaved': 'saved',
'settings.shippingSandbox': 'Use shipping sandbox / test APIs',
'settings.live': 'Live',
'settings.payuPosId': 'PayU POS ID',
'settings.payuClientId': 'PayU client ID',
'settings.payuClientSecret': 'PayU client secret',
'settings.payuSecondKey': 'PayU second key (MD5)',
'settings.payuOauthUrl': 'PayU OAuth URL',
'settings.payuOrdersUrl': 'PayU orders URL',
'settings.payuNotifyUrl': 'PayU notify (webhook) URL',
'settings.payuContinueUrl': 'PayU continue URL base',
'settings.inPostOrgId': 'InPost organization ID',
'settings.inPostShipXToken': 'InPost ShipX token',
'settings.inPostShipXBaseUrl': 'InPost ShipX base URL',
'settings.inPostGeowidgetConfig': 'Geowidget config',
'settings.inPostGeowidgetSdk': 'Geowidget SDK base URL',
'settings.inPostGeowidgetPublic': 'Geowidget public token',
'settings.inPostGeowidgetSandbox': 'Geowidget sandbox token',
'settings.dhlApiKey': 'DHL API key',
'settings.dhlApiSecret': 'DHL API secret',
'settings.dhlApiBaseUrl': 'DHL API base URL',
'settings.dhlWidgetUrl': 'DHL parcel shop map URL',
'settings.dpdLogin': 'DPD login',
'settings.dpdPassword': 'DPD password',
'settings.dpdFid': 'DPD FID',
'settings.dpdApiBaseUrl': 'DPD API base URL',
'settings.dpdWidgetUrl': 'DPD pudofinder URL',
'settings.allegroClientId': 'Allegro client ID', 'settings.allegroClientId': 'Allegro client ID',
'settings.allegroClientSecret': 'Allegro client secret', 'settings.allegroClientSecret': 'Allegro client secret',
'settings.allegroSellerId': 'Allegro seller ID', 'settings.allegroSellerId': 'Allegro seller ID',
@@ -540,7 +571,7 @@ export const EN_TRANSLATIONS: TranslationMap = {
'settings.mode': 'Mode', 'settings.mode': 'Mode',
'settings.sandbox': 'Sandbox', 'settings.sandbox': 'Sandbox',
'settings.shippingHint': 'settings.shippingHint':
'Credentials stay in server config — this UI only stores visibility flags locally.', 'Enable carriers here. API tokens and widget URLs are configured under Integrations.',
'settings.emailTitle': 'Email / newsletter', 'settings.emailTitle': 'Email / newsletter',
'settings.fromName': 'From name', 'settings.fromName': 'From name',
'settings.fromAddress': 'From address', 'settings.fromAddress': 'From address',
+49 -3
View File
@@ -53,6 +53,20 @@ export const PL_TRANSLATIONS: TranslationMap = {
'dashboard.errorLoad': 'Nie udało się wczytać pulpitu', 'dashboard.errorLoad': 'Nie udało się wczytać pulpitu',
'dashboard.alertLowStock': 'SKU z niskim stanem', 'dashboard.alertLowStock': 'SKU z niskim stanem',
'dashboard.alertPending': 'oczekuje na płatność', 'dashboard.alertPending': 'oczekuje na płatność',
'dashboard.alertReviews': 'opinii do moderacji',
'dashboard.viewOrders': 'Zobacz zamówienia',
'dashboard.openPickList': 'Otwórz listę kompletacji',
'dashboard.pendingFulfillment': 'Do realizacji',
'dashboard.pendingFulfillmentHint': 'Wymaga uwagi',
'dashboard.lowStockHint': 'Próg ≤ 5',
'dashboard.needsAction': 'Wymaga działania — zamówienia',
'dashboard.pendingReviews': 'Opinie do moderacji',
'dashboard.moderate': 'Moderuj',
'dashboard.restock': 'Uzupełnij',
'dashboard.noActionOrders': 'Brak zamówień wymagających działania.',
'dashboard.noLowStock': 'Stany magazynowe wyglądają dobrze.',
'dashboard.noPendingReviews': 'Brak opinii oczekujących.',
'orders.order': 'Zamówienie',
'orders.bulkSelected': 'Wybrano {count}', 'orders.bulkSelected': 'Wybrano {count}',
'orders.bulkPick': 'Kompletuj / pick', 'orders.bulkPick': 'Kompletuj / pick',
'orders.bulkInvoices': 'Generuj faktury', 'orders.bulkInvoices': 'Generuj faktury',
@@ -247,6 +261,11 @@ export const PL_TRANSLATIONS: TranslationMap = {
'orders.markCollected': 'Skompletowano', 'orders.markCollected': 'Skompletowano',
'orders.collected': 'Skompletowane', 'orders.collected': 'Skompletowane',
'orders.showCollected': 'Pokaż skompletowane', 'orders.showCollected': 'Pokaż skompletowane',
'orders.pickByOrder': 'Wg zamówień',
'orders.pickByProduct': 'Wg produktów',
'orders.pickTotalQty': 'Razem do pobrania: {{count}}',
'orders.collectProduct': 'Skompletuj wszystkie',
'orders.productCollected': 'Wszystkie skompletowane',
'orders.pendingItems': 'Do kompletacji', 'orders.pendingItems': 'Do kompletacji',
'orders.collectedItems': 'Skompletowane', 'orders.collectedItems': 'Skompletowane',
'orders.orderWarehouse': 'Magazyn dla zamówienia', 'orders.orderWarehouse': 'Magazyn dla zamówienia',
@@ -341,6 +360,7 @@ export const PL_TRANSLATIONS: TranslationMap = {
'newsletter.searchCampaign': 'Szukaj kampanii…', 'newsletter.searchCampaign': 'Szukaj kampanii…',
'newsletter.joined': 'Dołączył', 'newsletter.joined': 'Dołączył',
'newsletter.sent': 'Wysłano', 'newsletter.sent': 'Wysłano',
'newsletter.recipients': 'Odbiorcy',
'newsletter.unsubscribed': 'Wypisany', 'newsletter.unsubscribed': 'Wypisany',
'newsletter.status.sent': 'Wysłana', 'newsletter.status.sent': 'Wysłana',
'newsletter.status.draft': 'Szkic', 'newsletter.status.draft': 'Szkic',
@@ -451,10 +471,36 @@ export const PL_TRANSLATIONS: TranslationMap = {
'settings.navIntegrations': 'Integracje', 'settings.navIntegrations': 'Integracje',
'settings.navEmail': 'Email', 'settings.navEmail': 'Email',
'settings.navAccount': 'Moje konto', 'settings.navAccount': 'Moje konto',
'settings.integrationsTitle': 'Marketplace i faktury', 'settings.integrationsTitle': 'Płatności, przewoźnicy i marketplace',
'settings.integrationsHint': 'settings.integrationsHint':
'Konfiguracja Allegro, Amazon i iFirma. Synchronizacja zamówień i auto-faktury użyją tych ustawień po włączeniu konektorów.', 'PayU, InPost, DHL, DPD, Allegro, Amazon i iFirma — zapis w bazie. Puste tajne pole zostawia dotychczasową wartość.',
'settings.secretSaved': 'zapisany', 'settings.secretSaved': 'zapisany',
'settings.shippingSandbox': 'Używaj sandbox / testowych API wysyłki',
'settings.live': 'Produkcja',
'settings.payuPosId': 'PayU POS ID',
'settings.payuClientId': 'PayU client ID',
'settings.payuClientSecret': 'PayU client secret',
'settings.payuSecondKey': 'PayU second key (MD5)',
'settings.payuOauthUrl': 'PayU OAuth URL',
'settings.payuOrdersUrl': 'PayU orders URL',
'settings.payuNotifyUrl': 'PayU notify (webhook) URL',
'settings.payuContinueUrl': 'PayU continue URL (baza)',
'settings.inPostOrgId': 'InPost organization ID',
'settings.inPostShipXToken': 'InPost ShipX token',
'settings.inPostShipXBaseUrl': 'InPost ShipX base URL',
'settings.inPostGeowidgetConfig': 'Geowidget config',
'settings.inPostGeowidgetSdk': 'Geowidget SDK base URL',
'settings.inPostGeowidgetPublic': 'Geowidget public token',
'settings.inPostGeowidgetSandbox': 'Geowidget sandbox token',
'settings.dhlApiKey': 'DHL API key',
'settings.dhlApiSecret': 'DHL API secret',
'settings.dhlApiBaseUrl': 'DHL API base URL',
'settings.dhlWidgetUrl': 'DHL mapa Parcelshop',
'settings.dpdLogin': 'DPD login',
'settings.dpdPassword': 'DPD hasło',
'settings.dpdFid': 'DPD FID',
'settings.dpdApiBaseUrl': 'DPD API base URL',
'settings.dpdWidgetUrl': 'DPD pudofinder URL',
'settings.allegroClientId': 'Allegro client ID', 'settings.allegroClientId': 'Allegro client ID',
'settings.allegroClientSecret': 'Allegro client secret', 'settings.allegroClientSecret': 'Allegro client secret',
'settings.allegroSellerId': 'Allegro seller ID', 'settings.allegroSellerId': 'Allegro seller ID',
@@ -528,7 +574,7 @@ export const PL_TRANSLATIONS: TranslationMap = {
'settings.mode': 'Tryb', 'settings.mode': 'Tryb',
'settings.sandbox': 'Sandbox', 'settings.sandbox': 'Sandbox',
'settings.shippingHint': 'settings.shippingHint':
'Dane dostępowe zostają w konfiguracji serwera — tu tylko flagi widoczności.', 'Włącz przewoźników tutaj. Tokeny API i URL widgetów ustawisz w Integracjach.',
'settings.emailTitle': 'Email / newsletter', 'settings.emailTitle': 'Email / newsletter',
'settings.fromName': 'Nazwa nadawcy', 'settings.fromName': 'Nazwa nadawcy',
'settings.fromAddress': 'Adres nadawcy', 'settings.fromAddress': 'Adres nadawcy',
@@ -222,7 +222,11 @@
> >
{{ 'products.save' | translate }} {{ 'products.save' | translate }}
</button> </button>
<a class="btn btn-secondary" routerLink="/products"> <a
class="btn btn-secondary"
routerLink="/products"
[queryParams]="selectedId() ? { categoryId: selectedId() } : {}"
>
{{ 'categories.viewProducts' | translate }} {{ 'categories.viewProducts' | translate }}
</a> </a>
</div> </div>
@@ -0,0 +1,112 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
LucideMapPinned,
LucidePackage,
LucidePenLine,
LucideStore,
LucideTag,
LucideTruck,
} from '@lucide/angular';
import { TranslatePipe } from '../../core/i18n/translate.pipe';
import {
OrderSource,
ShippingCarrier,
ShippingServiceType,
} from './order.models';
/**
* Compact channel + delivery icons for order tables and headers.
*/
@Component({
selector: 'app-order-channel-icons',
standalone: true,
imports: [
TranslatePipe,
LucideStore,
LucideTag,
LucidePackage,
LucidePenLine,
LucideTruck,
LucideMapPinned,
],
template: `
<div class="order-meta-icons" [class.order-meta-icons--with-labels]="showLabels()">
<span
class="meta-icon channel-badge"
[attr.data-channel]="channel()"
[title]="'orders.source.' + channel() | translate"
>
@switch (channel()) {
@case ('allegro') {
<svg lucideTag [size]="iconSize()" aria-hidden="true"></svg>
}
@case ('amazon') {
<svg lucidePackage [size]="iconSize()" aria-hidden="true"></svg>
}
@case ('manual') {
<svg lucidePenLine [size]="iconSize()" aria-hidden="true"></svg>
}
@default {
<svg lucideStore [size]="iconSize()" aria-hidden="true"></svg>
}
}
@if (showLabels()) {
<span class="meta-icon__label">
{{ 'orders.source.' + channel() | translate }}
</span>
}
</span>
<span
class="meta-icon delivery-badge"
[attr.data-carrier]="carrier()"
[attr.data-service]="service()"
[title]="
('orders.carrier.' + carrier() | translate) +
' · ' +
('orders.service.' + service() | translate)
"
>
@if (service() === 'courier') {
<svg lucideTruck [size]="iconSize()" aria-hidden="true"></svg>
} @else {
<svg lucideMapPinned [size]="iconSize()" aria-hidden="true"></svg>
}
@if (showLabels()) {
<span class="meta-icon__label">
{{ 'orders.carrier.' + carrier() | translate }}
·
{{ 'orders.service.' + service() | translate }}
</span>
}
</span>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrderChannelIconsComponent {
readonly source = input<OrderSource | string | null | undefined>('shop');
readonly shippingCarrier = input<
ShippingCarrier | string | null | undefined
>('inPost');
readonly shippingServiceType = input<
ShippingServiceType | string | null | undefined
>('parcelLocker');
readonly showLabels = input(false);
readonly iconSize = input(14);
protected channel(): OrderSource {
const value = this.source() || 'shop';
return value as OrderSource;
}
protected carrier(): ShippingCarrier {
const value = this.shippingCarrier() || 'inPost';
return value as ShippingCarrier;
}
protected service(): ShippingServiceType {
const value = this.shippingServiceType() || 'parcelLocker';
return value as ShippingServiceType;
}
}
@@ -18,22 +18,14 @@
} }
</h2> </h2>
@if (detail(); as d) { @if (detail(); as d) {
<div class="order-meta-icons" style="margin-top: 6px"> <div style="margin-top: 6px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px">
<span <app-order-channel-icons
class="channel-badge" [source]="d.source"
[attr.data-channel]="d.source || 'shop'" [shippingCarrier]="d.shippingCarrier"
> [shippingServiceType]="d.shippingServiceType"
{{ 'orders.source.' + (d.source || 'shop') | translate }} [showLabels]="true"
</span> [iconSize]="15"
<span />
class="delivery-badge"
[attr.data-carrier]="d.shippingCarrier"
[attr.data-service]="d.shippingServiceType"
>
{{ 'orders.carrier.' + d.shippingCarrier | translate }}
·
{{ 'orders.service.' + d.shippingServiceType | translate }}
</span>
@if (d.externalOrderId) { @if (d.externalOrderId) {
<span class="cell-muted text-xs font-mono"> <span class="cell-muted text-xs font-mono">
{{ d.externalOrderId }} {{ d.externalOrderId }}
@@ -15,6 +15,7 @@ import { TranslatePipe } from '../../core/i18n/translate.pipe';
import { ApiService } from '../../core/api.service'; import { ApiService } from '../../core/api.service';
import { API_BASE_URL } from '../../core/constants'; import { API_BASE_URL } from '../../core/constants';
import { AdminSkeletonComponent } from '../../shared/admin-skeleton.component'; import { AdminSkeletonComponent } from '../../shared/admin-skeleton.component';
import { OrderChannelIconsComponent } from './order-channel-icons.component';
import { import {
AdminOrderDetail, AdminOrderDetail,
AdminOrderDetailLine, AdminOrderDetailLine,
@@ -40,6 +41,7 @@ import {
TranslatePipe, TranslatePipe,
I18nCurrencyPipe, I18nCurrencyPipe,
AdminSkeletonComponent, AdminSkeletonComponent,
OrderChannelIconsComponent,
], ],
templateUrl: './order-detail-modal.component.html', templateUrl: './order-detail-modal.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -1,4 +1,9 @@
import { PickListLine, PickListOrderGroup, PickListWarehouseQty } from './order.models'; import {
PickListLine,
PickListOrderGroup,
PickListProductGroup,
PickListWarehouseQty,
} from './order.models';
export interface CollectLinePayload { export interface CollectLinePayload {
orderLineId: string; orderLineId: string;
@@ -36,6 +41,66 @@ export function groupPickListByOrder(
); );
} }
/**
* Aggregates pick lines by product variant so pickers can
* pull total units once (e.g. Gold 1 + Gold 2 → Gold 3).
*/
export function groupPickListByProduct(
items: PickListLine[],
): PickListProductGroup[] {
const groups = new Map<string, PickListProductGroup>();
for (const line of items) {
const key = line.productVariantId || `${line.sku}|${line.productName}`;
const existing = groups.get(key);
if (existing) {
existing.lines.push(line);
existing.totalQuantity += line.quantity;
if (line.isCollected) {
existing.collectedQuantity += line.quantity;
} else {
existing.pendingQuantity += line.quantity;
}
mergeWarehouseStock(existing.warehouseStock, line.warehouseStock);
continue;
}
groups.set(key, {
productVariantId: line.productVariantId,
productName: line.productName,
variantColor: line.variantColor,
sku: line.sku,
totalQuantity: line.quantity,
pendingQuantity: line.isCollected ? 0 : line.quantity,
collectedQuantity: line.isCollected ? line.quantity : 0,
lines: [line],
warehouseStock: line.warehouseStock.map((ws) => ({ ...ws })),
});
}
return [...groups.values()].sort((a, b) => {
const byName = a.productName.localeCompare(b.productName);
if (byName !== 0) {
return byName;
}
return a.sku.localeCompare(b.sku);
});
}
function mergeWarehouseStock(
target: PickListWarehouseQty[],
incoming: PickListWarehouseQty[],
): void {
const map = new Map(target.map((ws) => [ws.warehouseId, ws]));
for (const ws of incoming) {
if (!map.has(ws.warehouseId)) {
const copy = { ...ws };
map.set(ws.warehouseId, copy);
target.push(copy);
}
}
}
export function canCollectPickLine( export function canCollectPickLine(
line: PickListLine, line: PickListLine,
warehouseId: string, warehouseId: string,
@@ -159,6 +159,18 @@ export interface PickListOrderGroup {
collectedCount: number; collectedCount: number;
} }
export interface PickListProductGroup {
productVariantId: string;
productName: string;
variantColor: string;
sku: string;
totalQuantity: number;
pendingQuantity: number;
collectedQuantity: number;
lines: PickListLine[];
warehouseStock: PickListWarehouseQty[];
}
export interface OrderInvoice { export interface OrderInvoice {
invoiceNumber: string; invoiceNumber: string;
issueDate: string; issueDate: string;
@@ -175,33 +175,11 @@
</td> </td>
<td class="px-4 py-3 font-mono text-xs">{{ o.orderNumber }}</td> <td class="px-4 py-3 font-mono text-xs">{{ o.orderNumber }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="order-meta-icons"> <app-order-channel-icons
<span [source]="o.source"
class="channel-badge" [shippingCarrier]="o.shippingCarrier"
[attr.data-channel]="o.source || 'shop'" [shippingServiceType]="o.shippingServiceType"
[title]="'orders.source.' + (o.source || 'shop') | translate" />
>
{{ 'orders.sourceShort.' + (o.source || 'shop') | translate }}
</span>
<span
class="delivery-badge"
[attr.data-carrier]="o.shippingCarrier || 'inPost'"
[attr.data-service]="o.shippingServiceType || 'parcelLocker'"
[title]="
('orders.carrier.' + (o.shippingCarrier || 'inPost') | translate) +
' · ' +
('orders.service.' + (o.shippingServiceType || 'parcelLocker') | translate)
"
>
{{ 'orders.carrierShort.' + (o.shippingCarrier || 'inPost') | translate }}
·
{{
o.shippingServiceType === 'courier'
? ('orders.serviceShort.courier' | translate)
: ('orders.serviceShort.parcelLocker' | translate)
}}
</span>
</div>
</td> </td>
<td class="px-4 py-3 text-iled-grey-dark"> <td class="px-4 py-3 text-iled-grey-dark">
{{ o.createdAt | date: 'yyyy-MM-dd HH:mm' }} {{ o.createdAt | date: 'yyyy-MM-dd HH:mm' }}
@@ -268,7 +246,25 @@
· {{ 'orders.pendingItems' | translate }}: {{ pl.pendingCount }} · {{ 'orders.pendingItems' | translate }}: {{ pl.pendingCount }}
· {{ 'orders.collectedItems' | translate }}: {{ pl.collectedCount }} · {{ 'orders.collectedItems' | translate }}: {{ pl.collectedCount }}
</p> </p>
<div class="flex gap-2"> <div class="flex flex-wrap items-center gap-3">
<div class="tabs" role="tablist" style="margin: 0">
<button
type="button"
class="tab"
[class.is-active]="pickListMode() === 'order'"
(click)="setPickListMode('order')"
>
{{ 'orders.pickByOrder' | translate }}
</button>
<button
type="button"
class="tab"
[class.is-active]="pickListMode() === 'product'"
(click)="setPickListMode('product')"
>
{{ 'orders.pickByProduct' | translate }}
</button>
</div>
<label class="flex items-center gap-2 text-sm"> <label class="flex items-center gap-2 text-sm">
<input <input
type="checkbox" type="checkbox"
@@ -287,6 +283,7 @@
</div> </div>
</div> </div>
@if (pickListMode() === 'order') {
@for (group of pickListGroups(pl.items); track group.orderId) { @for (group of pickListGroups(pl.items); track group.orderId) {
<section class="mb-6 border border-iled-grey-light"> <section class="mb-6 border border-iled-grey-light">
<div <div
@@ -419,6 +416,155 @@
{{ 'orders.pickListEmpty' | translate }} {{ 'orders.pickListEmpty' | translate }}
</p> </p>
} }
} @else {
@for (
group of pickListProductGroups(pl.items);
track group.productVariantId || group.sku
) {
<section class="mb-6 border border-iled-grey-light">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-iled-grey-light bg-iled-bg px-4 py-3"
>
<div>
<h3 class="font-semibold">
{{ group.productName }}
@if (group.variantColor) {
<span class="font-normal text-iled-grey-dark">
({{ group.variantColor }})
</span>
}
</h3>
<p class="text-xs text-iled-grey-dark font-mono">{{ group.sku }}</p>
<p class="mt-1 text-sm">
{{
'orders.pickTotalQty'
| translate: { count: group.totalQuantity }
}}
· {{ 'orders.pendingItems' | translate }}:
{{ group.pendingQuantity }}
· {{ 'orders.collectedItems' | translate }}:
{{ group.collectedQuantity }}
</p>
</div>
@if (group.pendingQuantity > 0) {
<div class="flex flex-wrap items-end gap-2">
<div>
<label class="mb-1 block text-xs text-iled-grey-dark">
{{ 'orders.orderWarehouse' | translate }}
</label>
<select
class="border border-iled-grey-light px-2 py-1.5 text-xs"
[value]="getProductWarehouse(group)"
(change)="
setProductWarehouse(group, $any($event.target).value)
"
>
<option value="">{{ 'orders.selectWarehouse' | translate }}</option>
@for (ws of group.warehouseStock; track ws.warehouseId) {
<option [value]="ws.warehouseId">
{{ ws.warehouseName }} ({{ ws.warehouseCode }}) —
{{ ws.quantity }}
</option>
}
</select>
</div>
<button
type="button"
class="btn btn-primary btn-sm"
[disabled]="
!canCollectProductGroup(group) ||
collectingOrderId() === (group.productVariantId || group.sku)
"
(click)="handleCollectProduct(group)"
>
{{ 'orders.collectProduct' | translate }}
</button>
</div>
} @else {
<span class="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
{{ 'orders.productCollected' | translate }}
</span>
}
</div>
<table class="w-full text-left text-sm">
<thead class="border-b border-iled-grey-light">
<tr>
<th class="px-3 py-2">{{ 'orders.order' | translate }}</th>
<th class="px-3 py-2">{{ 'orders.qty' | translate }}</th>
<th class="px-3 py-2">{{ 'orders.warehouseStock' | translate }}</th>
<th class="px-3 py-2">{{ 'orders.pickFrom' | translate }}</th>
<th class="px-3 py-2">{{ 'orders.status' | translate }}</th>
</tr>
</thead>
<tbody>
@for (line of group.lines; track line.orderLineId) {
<tr
class="border-b border-iled-grey-light"
[class.bg-green-50]="line.isCollected"
>
<td class="px-3 py-2 font-mono text-xs">
#{{ line.orderNumber }}
</td>
<td class="px-3 py-2 font-bold">{{ line.quantity }}</td>
<td class="px-3 py-2">
@for (ws of line.warehouseStock; track ws.warehouseId) {
<div class="text-xs">
{{ ws.warehouseName }} ({{ ws.warehouseCode }}):
<strong>{{ ws.quantity }}</strong>
</div>
} @empty {
<span class="text-iled-grey-dark">—</span>
}
</td>
<td class="px-3 py-2">
@if (!line.isCollected) {
<select
class="border border-iled-grey-light px-2 py-1 text-xs"
[value]="getWarehouse(line.orderLineId)"
(change)="setWarehouse(line.orderLineId, $any($event.target).value)"
>
<option value="">{{ 'orders.selectWarehouse' | translate }}</option>
@for (ws of line.warehouseStock; track ws.warehouseId) {
<option [value]="ws.warehouseId">
{{ ws.warehouseName }} ({{ ws.quantity }})
</option>
}
</select>
} @else if (line.pickedFromWarehouseName) {
<span class="text-xs font-medium">
{{ line.pickedFromWarehouseName }}
({{ line.pickedFromWarehouseCode }})
</span>
}
</td>
<td class="px-3 py-2">
@if (line.isCollected) {
<span class="inline-block rounded bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">
{{ 'orders.collected' | translate }}
</span>
} @else {
<button
type="button"
class="btn btn-secondary btn-sm"
[disabled]="!canCollect(line) || collectingLineId() === line.orderLineId"
(click)="handleCollect(line)"
>
{{ 'orders.markCollected' | translate }}
</button>
}
</td>
</tr>
}
</tbody>
</table>
</section>
} @empty {
<p class="py-8 text-center text-iled-grey-dark">
{{ 'orders.pickListEmpty' | translate }}
</p>
}
}
} }
</div> </div>
} }
@@ -12,6 +12,7 @@ import { ActivatedRoute } from '@angular/router';
import { I18nCurrencyPipe } from '../../core/i18n/i18n-currency.pipe'; import { I18nCurrencyPipe } from '../../core/i18n/i18n-currency.pipe';
import { TranslatePipe } from '../../core/i18n/translate.pipe'; import { TranslatePipe } from '../../core/i18n/translate.pipe';
import { ApiService } from '../../core/api.service'; import { ApiService } from '../../core/api.service';
import { OrderChannelIconsComponent } from './order-channel-icons.component';
import { OrderDetailModalComponent } from './order-detail-modal.component'; import { OrderDetailModalComponent } from './order-detail-modal.component';
import { OrderInvoiceModalComponent } from './order-invoice-modal.component'; import { OrderInvoiceModalComponent } from './order-invoice-modal.component';
import { import {
@@ -26,6 +27,7 @@ import {
PickListResult, PickListResult,
PickListLine, PickListLine,
PickListOrderGroup, PickListOrderGroup,
PickListProductGroup,
} from './order.models'; } from './order.models';
import { import {
applyOrderWarehouse, applyOrderWarehouse,
@@ -35,6 +37,7 @@ import {
canUseWarehouseForOrder, canUseWarehouseForOrder,
defaultWarehouseForLine, defaultWarehouseForLine,
groupPickListByOrder, groupPickListByOrder,
groupPickListByProduct,
orderWarehouseOptions, orderWarehouseOptions,
} from './order-pick.utils'; } from './order-pick.utils';
import { AdminListToolbarComponent } from '../../shared/admin-list-toolbar.component'; import { AdminListToolbarComponent } from '../../shared/admin-list-toolbar.component';
@@ -43,6 +46,7 @@ import { AdminSkeletonComponent } from '../../shared/admin-skeleton.component';
import { ToastService } from '../../shared/toast.service'; import { ToastService } from '../../shared/toast.service';
type OrdersTab = 'list' | 'pickList'; type OrdersTab = 'list' | 'pickList';
type PickListMode = 'order' | 'product';
type SortBy = 'date' | 'total' | 'number'; type SortBy = 'date' | 'total' | 'number';
type SortDir = 'asc' | 'desc'; type SortDir = 'asc' | 'desc';
@@ -58,6 +62,7 @@ type SortDir = 'asc' | 'desc';
AdminListToolbarComponent, AdminListToolbarComponent,
AdminPaginationComponent, AdminPaginationComponent,
AdminSkeletonComponent, AdminSkeletonComponent,
OrderChannelIconsComponent,
OrderDetailModalComponent, OrderDetailModalComponent,
OrderInvoiceModalComponent, OrderInvoiceModalComponent,
], ],
@@ -78,6 +83,7 @@ export class OrdersAdminComponent implements OnInit {
readonly pickList = signal<PickListResult | null>(null); readonly pickList = signal<PickListResult | null>(null);
readonly pickListLoading = signal(false); readonly pickListLoading = signal(false);
readonly pickListMode = signal<PickListMode>('order');
readonly showCollected = signal(false); readonly showCollected = signal(false);
readonly collectingLineId = signal<string | null>(null); readonly collectingLineId = signal<string | null>(null);
readonly collectingOrderId = signal<string | null>(null); readonly collectingOrderId = signal<string | null>(null);
@@ -200,6 +206,10 @@ export class OrdersAdminComponent implements OnInit {
this.loadPickList(); this.loadPickList();
} }
setPickListMode(mode: PickListMode): void {
this.pickListMode.set(mode);
}
setWarehouse(lineId: string, warehouseId: string): void { setWarehouse(lineId: string, warehouseId: string): void {
this.selectedWarehouse.set(lineId, warehouseId); this.selectedWarehouse.set(lineId, warehouseId);
this.#cdr.markForCheck(); this.#cdr.markForCheck();
@@ -217,6 +227,99 @@ export class OrdersAdminComponent implements OnInit {
return groupPickListByOrder(items); return groupPickListByOrder(items);
} }
pickListProductGroups(items: PickListLine[]): PickListProductGroup[] {
return groupPickListByProduct(items);
}
setProductWarehouse(
group: PickListProductGroup,
warehouseId: string,
): void {
for (const line of group.lines) {
if (!line.isCollected) {
this.selectedWarehouse.set(line.orderLineId, warehouseId);
}
}
this.#cdr.markForCheck();
}
getProductWarehouse(group: PickListProductGroup): string {
const pending = group.lines.find((line) => !line.isCollected);
if (!pending) {
return '';
}
return this.getWarehouse(pending.orderLineId);
}
canCollectProductGroup(group: PickListProductGroup): boolean {
const pending = group.lines.filter((line) => !line.isCollected);
if (pending.length === 0) {
return false;
}
const warehouseId = this.getWarehouse(pending[0].orderLineId);
if (!warehouseId) {
return false;
}
if (
!pending.every(
(line) => this.getWarehouse(line.orderLineId) === warehouseId,
)
) {
return false;
}
const needed = pending.reduce((sum, line) => sum + line.quantity, 0);
const stock = group.warehouseStock.find(
(ws) => ws.warehouseId === warehouseId,
);
return !!stock && stock.quantity >= needed;
}
handleCollectProduct(group: PickListProductGroup): void {
if (!this.canCollectProductGroup(group)) {
return;
}
const pending = group.lines.filter((line) => !line.isCollected);
if (pending.length === 0) {
return;
}
this.collectingOrderId.set(group.productVariantId || group.sku);
let remaining = pending.length;
let failed = false;
for (const line of pending) {
const warehouseId = this.getWarehouse(line.orderLineId);
this.#api
.post<void>(`/orders/admin/lines/${line.orderLineId}/collect`, {
warehouseId,
})
.subscribe({
next: () => {
remaining -= 1;
if (remaining === 0) {
this.collectingOrderId.set(null);
if (!failed) {
this.loadPickList();
this.loadOrders();
}
}
},
error: () => {
failed = true;
remaining -= 1;
if (remaining === 0) {
this.collectingOrderId.set(null);
this.loadPickList();
}
},
});
}
}
getOrderWarehouse(orderId: string): string { getOrderWarehouse(orderId: string): string {
return this.selectedOrderWarehouse.get(orderId) ?? ''; return this.selectedOrderWarehouse.get(orderId) ?? '';
} }
@@ -89,6 +89,8 @@ export class ProductsAdminComponent implements OnInit {
this.filterLowStock = this.filterLowStock =
this.#route.snapshot.queryParamMap.get('lowStock') === '1'; this.#route.snapshot.queryParamMap.get('lowStock') === '1';
this.filterCategoryId =
this.#route.snapshot.queryParamMap.get('categoryId') ?? '';
this.loadProducts(); this.loadProducts();
} }
@@ -309,7 +311,11 @@ export class ProductsAdminComponent implements OnInit {
#syncLowStockQuery(): void { #syncLowStockQuery(): void {
void this.#router.navigate([], { void this.#router.navigate([], {
relativeTo: this.#route, relativeTo: this.#route,
queryParams: this.filterLowStock ? { lowStock: '1' } : {}, queryParams: {
lowStock: this.filterLowStock ? '1' : null,
categoryId: this.filterCategoryId || null,
},
queryParamsHandling: 'merge',
replaceUrl: true, replaceUrl: true,
}); });
} }
@@ -234,7 +234,16 @@
<div class="card-head"> <div class="card-head">
<h2>{{ 'settings.shippingTitle' | translate }}</h2> <h2>{{ 'settings.shippingTitle' | translate }}</h2>
</div> </div>
<div class="card-pad"> <div class="card-pad stack" style="gap: 16px">
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
class="check"
name="shippingUseSandbox"
[(ngModel)]="form.shippingUseSandbox"
/>
{{ 'settings.shippingSandbox' | translate }}
</label>
<div class="table-wrap"> <div class="table-wrap">
<table class="data"> <table class="data">
<thead> <thead>
@@ -256,8 +265,17 @@
/> />
</td> </td>
<td> <td>
<span class="badge badge-warning"> <span
{{ 'settings.sandbox' | translate }} class="badge"
[class.badge-warning]="form.shippingUseSandbox"
[class.badge-success]="!form.shippingUseSandbox"
>
{{
(form.shippingUseSandbox
? 'settings.sandbox'
: 'settings.live'
) | translate
}}
</span> </span>
</td> </td>
</tr> </tr>
@@ -272,8 +290,17 @@
/> />
</td> </td>
<td> <td>
<span class="badge badge-warning"> <span
{{ 'settings.sandbox' | translate }} class="badge"
[class.badge-warning]="form.shippingUseSandbox"
[class.badge-success]="!form.shippingUseSandbox"
>
{{
(form.shippingUseSandbox
? 'settings.sandbox'
: 'settings.live'
) | translate
}}
</span> </span>
</td> </td>
</tr> </tr>
@@ -288,15 +315,24 @@
/> />
</td> </td>
<td> <td>
<span class="badge badge-warning"> <span
{{ 'settings.sandbox' | translate }} class="badge"
[class.badge-warning]="form.shippingUseSandbox"
[class.badge-success]="!form.shippingUseSandbox"
>
{{
(form.shippingUseSandbox
? 'settings.sandbox'
: 'settings.live'
) | translate
}}
</span> </span>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<p class="field-hint" style="margin: 14px 0 0"> <p class="field-hint" style="margin: 0">
{{ 'settings.shippingHint' | translate }} {{ 'settings.shippingHint' | translate }}
</p> </p>
</div> </div>
@@ -313,6 +349,335 @@
{{ 'settings.integrationsHint' | translate }} {{ 'settings.integrationsHint' | translate }}
</p> </p>
<section class="stack" style="gap: 12px">
<div style="display: flex; align-items: center; gap: 10px">
<span class="badge badge-info">PayU</span>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
class="check"
name="payuSandbox"
[(ngModel)]="form.payU.useSandbox"
/>
{{ 'settings.sandbox' | translate }}
</label>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="field">
<label for="payu-pos">{{ 'settings.payuPosId' | translate }}</label>
<input
id="payu-pos"
class="input"
name="payuPosId"
[(ngModel)]="form.payU.posId"
/>
</div>
<div class="field">
<label for="payu-client">{{ 'settings.payuClientId' | translate }}</label>
<input
id="payu-client"
class="input"
name="payuClientId"
[(ngModel)]="form.payU.clientId"
/>
</div>
<div class="field">
<label for="payu-secret">
{{ 'settings.payuClientSecret' | translate }}
@if (form.payU.hasClientSecret) {
<span class="cell-muted">({{ 'settings.secretSaved' | translate }})</span>
}
</label>
<input
id="payu-secret"
class="input"
type="password"
name="payuClientSecret"
autocomplete="new-password"
[(ngModel)]="form.payU.clientSecret"
[placeholder]="form.payU.hasClientSecret ? '••••••••' : ''"
/>
</div>
<div class="field">
<label for="payu-second">
{{ 'settings.payuSecondKey' | translate }}
@if (form.payU.hasSecondKey) {
<span class="cell-muted">({{ 'settings.secretSaved' | translate }})</span>
}
</label>
<input
id="payu-second"
class="input"
type="password"
name="payuSecondKey"
autocomplete="new-password"
[(ngModel)]="form.payU.secondKey"
[placeholder]="form.payU.hasSecondKey ? '••••••••' : ''"
/>
</div>
<div class="field">
<label for="payu-oauth">{{ 'settings.payuOauthUrl' | translate }}</label>
<input
id="payu-oauth"
class="input"
name="payuOauthUrl"
[(ngModel)]="form.payU.oauthUrl"
/>
</div>
<div class="field">
<label for="payu-orders">{{ 'settings.payuOrdersUrl' | translate }}</label>
<input
id="payu-orders"
class="input"
name="payuOrdersUrl"
[(ngModel)]="form.payU.ordersUrl"
/>
</div>
<div class="field">
<label for="payu-notify">{{ 'settings.payuNotifyUrl' | translate }}</label>
<input
id="payu-notify"
class="input"
name="payuNotifyUrl"
[(ngModel)]="form.payU.notifyUrl"
/>
</div>
<div class="field">
<label for="payu-continue">{{ 'settings.payuContinueUrl' | translate }}</label>
<input
id="payu-continue"
class="input"
name="payuContinueUrl"
[(ngModel)]="form.payU.continueUrl"
/>
</div>
</div>
</section>
<hr style="border: 0; border-top: 1px solid var(--border)" />
<section class="stack" style="gap: 12px">
<div style="display: flex; align-items: center; gap: 10px">
<span class="delivery-badge" data-carrier="inPost">InPost</span>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="field">
<label for="inpost-org">{{ 'settings.inPostOrgId' | translate }}</label>
<input
id="inpost-org"
class="input"
name="inPostOrgId"
[(ngModel)]="form.inPost.organizationId"
/>
</div>
<div class="field">
<label for="inpost-token">
{{ 'settings.inPostShipXToken' | translate }}
@if (form.inPost.hasShipXToken) {
<span class="cell-muted">({{ 'settings.secretSaved' | translate }})</span>
}
</label>
<input
id="inpost-token"
class="input"
type="password"
name="inPostShipXToken"
autocomplete="new-password"
[(ngModel)]="form.inPost.shipXToken"
[placeholder]="form.inPost.hasShipXToken ? '••••••••' : ''"
/>
</div>
<div class="field">
<label for="inpost-base">{{ 'settings.inPostShipXBaseUrl' | translate }}</label>
<input
id="inpost-base"
class="input"
name="inPostShipXBaseUrl"
[(ngModel)]="form.inPost.shipXBaseUrl"
/>
</div>
<div class="field">
<label for="inpost-config">{{ 'settings.inPostGeowidgetConfig' | translate }}</label>
<input
id="inpost-config"
class="input"
name="inPostGeowidgetConfig"
[(ngModel)]="form.inPost.geowidgetConfig"
/>
</div>
<div class="field">
<label for="inpost-sdk">{{ 'settings.inPostGeowidgetSdk' | translate }}</label>
<input
id="inpost-sdk"
class="input"
name="inPostGeowidgetSdk"
[(ngModel)]="form.inPost.geowidgetSdkBaseUrl"
/>
</div>
<div class="field">
<label for="inpost-geo-pub">
{{ 'settings.inPostGeowidgetPublic' | translate }}
@if (form.inPost.hasGeowidgetPublicToken) {
<span class="cell-muted">({{ 'settings.secretSaved' | translate }})</span>
}
</label>
<input
id="inpost-geo-pub"
class="input"
type="password"
name="inPostGeowidgetPublic"
autocomplete="new-password"
[(ngModel)]="form.inPost.geowidgetPublicToken"
[placeholder]="form.inPost.hasGeowidgetPublicToken ? '••••••••' : ''"
/>
</div>
<div class="field">
<label for="inpost-geo-sb">
{{ 'settings.inPostGeowidgetSandbox' | translate }}
@if (form.inPost.hasGeowidgetSandboxToken) {
<span class="cell-muted">({{ 'settings.secretSaved' | translate }})</span>
}
</label>
<input
id="inpost-geo-sb"
class="input"
type="password"
name="inPostGeowidgetSandbox"
autocomplete="new-password"
[(ngModel)]="form.inPost.geowidgetSandboxToken"
[placeholder]="form.inPost.hasGeowidgetSandboxToken ? '••••••••' : ''"
/>
</div>
</div>
</section>
<hr style="border: 0; border-top: 1px solid var(--border)" />
<section class="stack" style="gap: 12px">
<div style="display: flex; align-items: center; gap: 10px">
<span class="delivery-badge" data-carrier="dhl">DHL</span>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="field">
<label for="dhl-key">
{{ 'settings.dhlApiKey' | translate }}
@if (form.dhl.hasApiKey) {
<span class="cell-muted">({{ 'settings.secretSaved' | translate }})</span>
}
</label>
<input
id="dhl-key"
class="input"
type="password"
name="dhlApiKey"
autocomplete="new-password"
[(ngModel)]="form.dhl.apiKey"
[placeholder]="form.dhl.hasApiKey ? '••••••••' : ''"
/>
</div>
<div class="field">
<label for="dhl-secret">
{{ 'settings.dhlApiSecret' | translate }}
@if (form.dhl.hasApiSecret) {
<span class="cell-muted">({{ 'settings.secretSaved' | translate }})</span>
}
</label>
<input
id="dhl-secret"
class="input"
type="password"
name="dhlApiSecret"
autocomplete="new-password"
[(ngModel)]="form.dhl.apiSecret"
[placeholder]="form.dhl.hasApiSecret ? '••••••••' : ''"
/>
</div>
<div class="field">
<label for="dhl-base">{{ 'settings.dhlApiBaseUrl' | translate }}</label>
<input
id="dhl-base"
class="input"
name="dhlApiBaseUrl"
[(ngModel)]="form.dhl.apiBaseUrl"
/>
</div>
<div class="field">
<label for="dhl-widget">{{ 'settings.dhlWidgetUrl' | translate }}</label>
<input
id="dhl-widget"
class="input"
name="dhlWidgetUrl"
[(ngModel)]="form.dhl.widgetUrl"
/>
</div>
</div>
</section>
<hr style="border: 0; border-top: 1px solid var(--border)" />
<section class="stack" style="gap: 12px">
<div style="display: flex; align-items: center; gap: 10px">
<span class="delivery-badge" data-carrier="dpd">DPD</span>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="field">
<label for="dpd-login">{{ 'settings.dpdLogin' | translate }}</label>
<input
id="dpd-login"
class="input"
name="dpdLogin"
[(ngModel)]="form.dpd.login"
/>
</div>
<div class="field">
<label for="dpd-pass">
{{ 'settings.dpdPassword' | translate }}
@if (form.dpd.hasPassword) {
<span class="cell-muted">({{ 'settings.secretSaved' | translate }})</span>
}
</label>
<input
id="dpd-pass"
class="input"
type="password"
name="dpdPassword"
autocomplete="new-password"
[(ngModel)]="form.dpd.password"
[placeholder]="form.dpd.hasPassword ? '••••••••' : ''"
/>
</div>
<div class="field">
<label for="dpd-fid">{{ 'settings.dpdFid' | translate }}</label>
<input
id="dpd-fid"
class="input"
name="dpdFid"
[(ngModel)]="form.dpd.fid"
/>
</div>
<div class="field">
<label for="dpd-base">{{ 'settings.dpdApiBaseUrl' | translate }}</label>
<input
id="dpd-base"
class="input"
name="dpdApiBaseUrl"
[(ngModel)]="form.dpd.apiBaseUrl"
/>
</div>
<div class="field">
<label for="dpd-widget">{{ 'settings.dpdWidgetUrl' | translate }}</label>
<input
id="dpd-widget"
class="input"
name="dpdWidgetUrl"
[(ngModel)]="form.dpd.widgetUrl"
/>
</div>
</div>
</section>
<hr style="border: 0; border-top: 1px solid var(--border)" />
<section class="stack" style="gap: 12px"> <section class="stack" style="gap: 12px">
<div style="display: flex; align-items: center; gap: 10px"> <div style="display: flex; align-items: center; gap: 10px">
<span class="channel-badge" data-channel="allegro">Allegro</span> <span class="channel-badge" data-channel="allegro">Allegro</span>
@@ -36,6 +36,7 @@ interface StoreSettingsApi {
locales: { pl: boolean; en: boolean; de: boolean; cs: boolean }; locales: { pl: boolean; en: boolean; de: boolean; cs: boolean };
lowStockThreshold: number; lowStockThreshold: number;
carriers: { inPost: boolean; dhl: boolean; dpd: boolean }; carriers: { inPost: boolean; dhl: boolean; dpd: boolean };
shippingUseSandbox: boolean;
emailFromName: string; emailFromName: string;
emailFromAddress: string; emailFromAddress: string;
emailReplyTo: string; emailReplyTo: string;
@@ -61,6 +62,39 @@ interface StoreSettingsApi {
invoiceSeries?: string | null; invoiceSeries?: string | null;
autoInvoice: boolean; autoInvoice: boolean;
}; };
inPost: {
hasShipXToken: boolean;
organizationId?: string | null;
shipXBaseUrl?: string | null;
hasGeowidgetPublicToken: boolean;
hasGeowidgetSandboxToken: boolean;
geowidgetConfig?: string | null;
geowidgetSdkBaseUrl?: string | null;
};
dhl: {
hasApiKey: boolean;
hasApiSecret: boolean;
apiBaseUrl?: string | null;
widgetUrl?: string | null;
};
dpd: {
login?: string | null;
hasPassword: boolean;
fid?: string | null;
apiBaseUrl?: string | null;
widgetUrl?: string | null;
};
payU: {
useSandbox: boolean;
posId?: string | null;
clientId?: string | null;
hasClientSecret: boolean;
hasSecondKey: boolean;
oauthUrl?: string | null;
ordersUrl?: string | null;
notifyUrl?: string | null;
continueUrl?: string | null;
};
} }
@Component({ @Component({
@@ -77,12 +111,7 @@ export class SettingsAdminComponent implements OnInit {
readonly section = signal<SettingsSection>('store'); readonly section = signal<SettingsSection>('store');
readonly loading = signal(false); readonly loading = signal(false);
readonly saving = signal(false); readonly saving = signal(false);
form: AdminSettings = { form: AdminSettings = this.#cloneDefaults();
...DEFAULT_ADMIN_SETTINGS,
allegro: { ...DEFAULT_ADMIN_SETTINGS.allegro },
amazon: { ...DEFAULT_ADMIN_SETTINGS.amazon },
ifirma: { ...DEFAULT_ADMIN_SETTINGS.ifirma },
};
ngOnInit(): void { ngOnInit(): void {
this.load(); this.load();
@@ -133,6 +162,19 @@ export class SettingsAdminComponent implements OnInit {
}); });
} }
#cloneDefaults(): AdminSettings {
return {
...DEFAULT_ADMIN_SETTINGS,
allegro: { ...DEFAULT_ADMIN_SETTINGS.allegro },
amazon: { ...DEFAULT_ADMIN_SETTINGS.amazon },
ifirma: { ...DEFAULT_ADMIN_SETTINGS.ifirma },
inPost: { ...DEFAULT_ADMIN_SETTINGS.inPost },
dhl: { ...DEFAULT_ADMIN_SETTINGS.dhl },
dpd: { ...DEFAULT_ADMIN_SETTINGS.dpd },
payU: { ...DEFAULT_ADMIN_SETTINGS.payU },
};
}
#fromApi(res: StoreSettingsApi): AdminSettings { #fromApi(res: StoreSettingsApi): AdminSettings {
return { return {
storeName: res.storeName, storeName: res.storeName,
@@ -153,6 +195,7 @@ export class SettingsAdminComponent implements OnInit {
dhl: res.carriers.dhl, dhl: res.carriers.dhl,
dpd: res.carriers.dpd, dpd: res.carriers.dpd,
}, },
shippingUseSandbox: res.shippingUseSandbox ?? true,
emailFromName: res.emailFromName, emailFromName: res.emailFromName,
emailFromAddress: res.emailFromAddress, emailFromAddress: res.emailFromAddress,
emailReplyTo: res.emailReplyTo, emailReplyTo: res.emailReplyTo,
@@ -182,6 +225,48 @@ export class SettingsAdminComponent implements OnInit {
autoInvoice: res.ifirma?.autoInvoice ?? false, autoInvoice: res.ifirma?.autoInvoice ?? false,
hasApiKey: res.ifirma?.hasApiKey ?? false, hasApiKey: res.ifirma?.hasApiKey ?? false,
}, },
inPost: {
shipXToken: '',
organizationId: res.inPost?.organizationId ?? '',
shipXBaseUrl: res.inPost?.shipXBaseUrl ?? '',
geowidgetPublicToken: '',
geowidgetSandboxToken: '',
geowidgetConfig: res.inPost?.geowidgetConfig ?? 'parcelCollect',
geowidgetSdkBaseUrl: res.inPost?.geowidgetSdkBaseUrl ?? '',
hasShipXToken: res.inPost?.hasShipXToken ?? false,
hasGeowidgetPublicToken: res.inPost?.hasGeowidgetPublicToken ?? false,
hasGeowidgetSandboxToken:
res.inPost?.hasGeowidgetSandboxToken ?? false,
},
dhl: {
apiKey: '',
apiSecret: '',
apiBaseUrl: res.dhl?.apiBaseUrl ?? '',
widgetUrl: res.dhl?.widgetUrl ?? 'https://parcelshop.dhl.pl/mapa',
hasApiKey: res.dhl?.hasApiKey ?? false,
hasApiSecret: res.dhl?.hasApiSecret ?? false,
},
dpd: {
login: res.dpd?.login ?? '',
password: '',
fid: res.dpd?.fid ?? '',
apiBaseUrl: res.dpd?.apiBaseUrl ?? '',
widgetUrl: res.dpd?.widgetUrl ?? 'https://pudofinder.dpd.com.pl/',
hasPassword: res.dpd?.hasPassword ?? false,
},
payU: {
useSandbox: res.payU?.useSandbox ?? true,
posId: res.payU?.posId ?? '',
clientId: res.payU?.clientId ?? '',
clientSecret: '',
secondKey: '',
oauthUrl: res.payU?.oauthUrl ?? '',
ordersUrl: res.payU?.ordersUrl ?? '',
notifyUrl: res.payU?.notifyUrl ?? '',
continueUrl: res.payU?.continueUrl ?? '',
hasClientSecret: res.payU?.hasClientSecret ?? false,
hasSecondKey: res.payU?.hasSecondKey ?? false,
},
}; };
} }
@@ -196,6 +281,7 @@ export class SettingsAdminComponent implements OnInit {
locales: form.locales, locales: form.locales,
lowStockThreshold: form.lowStockThreshold, lowStockThreshold: form.lowStockThreshold,
carriers: form.carriers, carriers: form.carriers,
shippingUseSandbox: form.shippingUseSandbox,
emailFromName: form.emailFromName, emailFromName: form.emailFromName,
emailFromAddress: form.emailFromAddress, emailFromAddress: form.emailFromAddress,
emailReplyTo: form.emailReplyTo, emailReplyTo: form.emailReplyTo,
@@ -219,6 +305,39 @@ export class SettingsAdminComponent implements OnInit {
invoiceSeries: form.ifirma.invoiceSeries || null, invoiceSeries: form.ifirma.invoiceSeries || null,
autoInvoice: form.ifirma.autoInvoice, autoInvoice: form.ifirma.autoInvoice,
}, },
inPost: {
shipXToken: form.inPost.shipXToken || null,
organizationId: form.inPost.organizationId || null,
shipXBaseUrl: form.inPost.shipXBaseUrl || null,
geowidgetPublicToken: form.inPost.geowidgetPublicToken || null,
geowidgetSandboxToken: form.inPost.geowidgetSandboxToken || null,
geowidgetConfig: form.inPost.geowidgetConfig || null,
geowidgetSdkBaseUrl: form.inPost.geowidgetSdkBaseUrl || null,
},
dhl: {
apiKey: form.dhl.apiKey || null,
apiSecret: form.dhl.apiSecret || null,
apiBaseUrl: form.dhl.apiBaseUrl || null,
widgetUrl: form.dhl.widgetUrl || null,
},
dpd: {
login: form.dpd.login || null,
password: form.dpd.password || null,
fid: form.dpd.fid || null,
apiBaseUrl: form.dpd.apiBaseUrl || null,
widgetUrl: form.dpd.widgetUrl || null,
},
payU: {
useSandbox: form.payU.useSandbox,
posId: form.payU.posId || null,
clientId: form.payU.clientId || null,
clientSecret: form.payU.clientSecret || null,
secondKey: form.payU.secondKey || null,
oauthUrl: form.payU.oauthUrl || null,
ordersUrl: form.payU.ordersUrl || null,
notifyUrl: form.payU.notifyUrl || null,
continueUrl: form.payU.continueUrl || null,
},
}; };
} }
} }
@@ -30,6 +30,51 @@ export interface IfirmaSettingsForm {
hasApiKey: boolean; hasApiKey: boolean;
} }
export interface InPostSettingsForm {
shipXToken: string;
organizationId: string;
shipXBaseUrl: string;
geowidgetPublicToken: string;
geowidgetSandboxToken: string;
geowidgetConfig: string;
geowidgetSdkBaseUrl: string;
hasShipXToken: boolean;
hasGeowidgetPublicToken: boolean;
hasGeowidgetSandboxToken: boolean;
}
export interface DhlSettingsForm {
apiKey: string;
apiSecret: string;
apiBaseUrl: string;
widgetUrl: string;
hasApiKey: boolean;
hasApiSecret: boolean;
}
export interface DpdSettingsForm {
login: string;
password: string;
fid: string;
apiBaseUrl: string;
widgetUrl: string;
hasPassword: boolean;
}
export interface PayUSettingsForm {
useSandbox: boolean;
posId: string;
clientId: string;
clientSecret: string;
secondKey: string;
oauthUrl: string;
ordersUrl: string;
notifyUrl: string;
continueUrl: string;
hasClientSecret: boolean;
hasSecondKey: boolean;
}
export interface AdminSettings { export interface AdminSettings {
storeName: string; storeName: string;
supportEmail: string; supportEmail: string;
@@ -40,12 +85,17 @@ export interface AdminSettings {
locales: { pl: boolean; en: boolean; de: boolean; cs: boolean }; locales: { pl: boolean; en: boolean; de: boolean; cs: boolean };
lowStockThreshold: number; lowStockThreshold: number;
carriers: { inPost: boolean; dhl: boolean; dpd: boolean }; carriers: { inPost: boolean; dhl: boolean; dpd: boolean };
shippingUseSandbox: boolean;
emailFromName: string; emailFromName: string;
emailFromAddress: string; emailFromAddress: string;
emailReplyTo: string; emailReplyTo: string;
allegro: AllegroSettingsForm; allegro: AllegroSettingsForm;
amazon: AmazonSettingsForm; amazon: AmazonSettingsForm;
ifirma: IfirmaSettingsForm; ifirma: IfirmaSettingsForm;
inPost: InPostSettingsForm;
dhl: DhlSettingsForm;
dpd: DpdSettingsForm;
payU: PayUSettingsForm;
} }
export const DEFAULT_ADMIN_SETTINGS: AdminSettings = { export const DEFAULT_ADMIN_SETTINGS: AdminSettings = {
@@ -58,6 +108,7 @@ export const DEFAULT_ADMIN_SETTINGS: AdminSettings = {
locales: { pl: true, en: true, de: true, cs: true }, locales: { pl: true, en: true, de: true, cs: true },
lowStockThreshold: 5, lowStockThreshold: 5,
carriers: { inPost: true, dhl: true, dpd: true }, carriers: { inPost: true, dhl: true, dpd: true },
shippingUseSandbox: true,
emailFromName: 'iLED', emailFromName: 'iLED',
emailFromAddress: 'newsletter@iled.pl', emailFromAddress: 'newsletter@iled.pl',
emailReplyTo: 'hello@iled.pl', emailReplyTo: 'hello@iled.pl',
@@ -87,18 +138,54 @@ export const DEFAULT_ADMIN_SETTINGS: AdminSettings = {
autoInvoice: false, autoInvoice: false,
hasApiKey: false, hasApiKey: false,
}, },
inPost: {
shipXToken: '',
organizationId: '',
shipXBaseUrl: '',
geowidgetPublicToken: '',
geowidgetSandboxToken: '',
geowidgetConfig: 'parcelCollect',
geowidgetSdkBaseUrl: '',
hasShipXToken: false,
hasGeowidgetPublicToken: false,
hasGeowidgetSandboxToken: false,
},
dhl: {
apiKey: '',
apiSecret: '',
apiBaseUrl: '',
widgetUrl: 'https://parcelshop.dhl.pl/mapa',
hasApiKey: false,
hasApiSecret: false,
},
dpd: {
login: '',
password: '',
fid: '',
apiBaseUrl: '',
widgetUrl: 'https://pudofinder.dpd.com.pl/',
hasPassword: false,
},
payU: {
useSandbox: true,
posId: '',
clientId: '',
clientSecret: '',
secondKey: '',
oauthUrl: '',
ordersUrl: '',
notifyUrl: '',
continueUrl: '',
hasClientSecret: false,
hasSecondKey: false,
},
}; };
export function loadAdminSettings(): AdminSettings { export function loadAdminSettings(): AdminSettings {
try { try {
const raw = localStorage.getItem(ADMIN_SETTINGS_KEY); const raw = localStorage.getItem(ADMIN_SETTINGS_KEY);
if (!raw) { if (!raw) {
return { return cloneDefaults();
...DEFAULT_ADMIN_SETTINGS,
allegro: { ...DEFAULT_ADMIN_SETTINGS.allegro },
amazon: { ...DEFAULT_ADMIN_SETTINGS.amazon },
ifirma: { ...DEFAULT_ADMIN_SETTINGS.ifirma },
};
} }
const parsed = JSON.parse(raw) as Partial<AdminSettings>; const parsed = JSON.parse(raw) as Partial<AdminSettings>;
return { return {
@@ -109,14 +196,13 @@ export function loadAdminSettings(): AdminSettings {
allegro: { ...DEFAULT_ADMIN_SETTINGS.allegro, ...parsed.allegro }, allegro: { ...DEFAULT_ADMIN_SETTINGS.allegro, ...parsed.allegro },
amazon: { ...DEFAULT_ADMIN_SETTINGS.amazon, ...parsed.amazon }, amazon: { ...DEFAULT_ADMIN_SETTINGS.amazon, ...parsed.amazon },
ifirma: { ...DEFAULT_ADMIN_SETTINGS.ifirma, ...parsed.ifirma }, ifirma: { ...DEFAULT_ADMIN_SETTINGS.ifirma, ...parsed.ifirma },
inPost: { ...DEFAULT_ADMIN_SETTINGS.inPost, ...parsed.inPost },
dhl: { ...DEFAULT_ADMIN_SETTINGS.dhl, ...parsed.dhl },
dpd: { ...DEFAULT_ADMIN_SETTINGS.dpd, ...parsed.dpd },
payU: { ...DEFAULT_ADMIN_SETTINGS.payU, ...parsed.payU },
}; };
} catch { } catch {
return { return cloneDefaults();
...DEFAULT_ADMIN_SETTINGS,
allegro: { ...DEFAULT_ADMIN_SETTINGS.allegro },
amazon: { ...DEFAULT_ADMIN_SETTINGS.amazon },
ifirma: { ...DEFAULT_ADMIN_SETTINGS.ifirma },
};
} }
} }
@@ -127,3 +213,16 @@ export function saveAdminSettings(settings: AdminSettings): void {
String(settings.lowStockThreshold), String(settings.lowStockThreshold),
); );
} }
function cloneDefaults(): AdminSettings {
return {
...DEFAULT_ADMIN_SETTINGS,
allegro: { ...DEFAULT_ADMIN_SETTINGS.allegro },
amazon: { ...DEFAULT_ADMIN_SETTINGS.amazon },
ifirma: { ...DEFAULT_ADMIN_SETTINGS.ifirma },
inPost: { ...DEFAULT_ADMIN_SETTINGS.inPost },
dhl: { ...DEFAULT_ADMIN_SETTINGS.dhl },
dpd: { ...DEFAULT_ADMIN_SETTINGS.dpd },
payU: { ...DEFAULT_ADMIN_SETTINGS.payU },
};
}
+25 -4
View File
@@ -1775,21 +1775,42 @@ table.data tbody tr.is-selected {
gap: 6px; gap: 6px;
} }
.meta-icon,
.channel-badge, .channel-badge,
.delivery-badge { .delivery-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; justify-content: center;
padding: 2px 8px; gap: 6px;
border-radius: 999px; width: 28px;
height: 28px;
padding: 0;
border-radius: 8px;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
line-height: 1.4; line-height: 1;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--surface-muted); background: var(--surface-muted);
color: var(--text-secondary); color: var(--text-secondary);
white-space: nowrap; white-space: nowrap;
flex-shrink: 0;
}
.order-meta-icons--with-labels .meta-icon,
.order-meta-icons--with-labels .channel-badge,
.order-meta-icons--with-labels .delivery-badge {
width: auto;
height: auto;
min-height: 28px;
padding: 4px 10px 4px 8px;
border-radius: 999px;
}
.meta-icon__label {
font-size: 12px;
font-weight: 600;
line-height: 1.2;
} }
.channel-badge[data-channel='shop'] { .channel-badge[data-channel='shop'] {
@@ -48,6 +48,7 @@ public sealed class AdminProductsController(IMediator mediator) : ControllerBase
[HttpPost("images/upload")] [HttpPost("images/upload")]
[RequestSizeLimit(10 * 1024 * 1024)] [RequestSizeLimit(10 * 1024 * 1024)]
[RequestFormLimits(MultipartBodyLengthLimit = 10 * 1024 * 1024)]
public async Task<IActionResult> UploadImage( public async Task<IActionResult> UploadImage(
IFormFile file, IFormFile file,
CancellationToken cancellationToken) CancellationToken cancellationToken)
+8
View File
@@ -47,6 +47,14 @@ builder.Services.AddAuthentication(options =>
}); });
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 10 * 1024 * 1024;
});
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 10 * 1024 * 1024;
});
builder.Services.AddControllers() builder.Services.AddControllers()
.AddJsonOptions(options => .AddJsonOptions(options =>
{ {
@@ -58,79 +58,87 @@ public sealed class GetAnalyticsQueryHandler(IAppDbContext db)
var start = end.AddDays(-days); var start = end.AddDays(-days);
var prevStart = start.AddDays(-days); var prevStart = start.AddDays(-days);
var orders = db.Orders var baseQuery = db.Orders
.AsNoTracking() .AsNoTracking()
.Where(o => !Excluded.Contains(o.Status)); .Where(o => !Excluded.Contains(o.Status));
if (request.Source.HasValue) if (request.Source.HasValue)
{ {
orders = orders.Where(o => o.Source == request.Source.Value); baseQuery = baseQuery.Where(o => o.Source == request.Source.Value);
} }
var current = await AggregateAsync( var currentRows = await baseQuery
orders.Where(o => o.CreatedAt >= start && o.CreatedAt < end),
cancellationToken);
var previous = await AggregateAsync(
orders.Where(o => o.CreatedAt >= prevStart && o.CreatedAt < start),
cancellationToken);
var trendRaw = await orders
.Where(o => o.CreatedAt >= start && o.CreatedAt < end) .Where(o => o.CreatedAt >= start && o.CreatedAt < end)
.GroupBy(o => o.CreatedAt.Date) .Select(o => new OrderAggRow(
.Select(g => new o.Id,
{ o.CreatedAt,
Date = g.Key, o.Total,
Revenue = g.Sum(o => o.Total), o.Source))
Orders = g.Count(),
})
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var trendLookup = trendRaw.ToDictionary(x => x.Date); var previousRows = await baseQuery
.Where(o => o.CreatedAt >= prevStart && o.CreatedAt < start)
.Select(o => new OrderAggRow(
o.Id,
o.CreatedAt,
o.Total,
o.Source))
.ToListAsync(cancellationToken);
var currentOrderIds = currentRows.Select(r => r.Id).ToList();
var previousOrderIds = previousRows.Select(r => r.Id).ToList();
var currentUnits = await SumUnitsAsync(
currentOrderIds,
cancellationToken);
var previousUnits = await SumUnitsAsync(
previousOrderIds,
cancellationToken);
var current = ToKpi(currentRows, currentUnits);
var previous = ToKpi(previousRows, previousUnits);
// Group by calendar day in-memory — Npgsql cannot reliably
// translate CreatedAt.Date for timestamp with time zone.
var trendLookup = currentRows
.GroupBy(r => DateOnly.FromDateTime(r.CreatedAt.ToUniversalTime()))
.ToDictionary(
g => g.Key,
g => (Revenue: g.Sum(x => x.Total), Orders: g.Count()));
var trend = new List<AnalyticsTrendPointDto>(days); var trend = new List<AnalyticsTrendPointDto>(days);
for (var d = start; d < end; d = d.AddDays(1)) for (var d = DateOnly.FromDateTime(start); d < DateOnly.FromDateTime(end); d = d.AddDays(1))
{ {
trendLookup.TryGetValue(d, out var row); trendLookup.TryGetValue(d, out var row);
trend.Add(new AnalyticsTrendPointDto( trend.Add(new AnalyticsTrendPointDto(
d.ToString("yyyy-MM-dd"), d.ToString("yyyy-MM-dd"),
row?.Revenue ?? 0m, row.Revenue,
row?.Orders ?? 0)); row.Orders));
} }
var paidOrderIds = await orders var topRaw = currentOrderIds.Count == 0
.Where(o => o.CreatedAt >= start && o.CreatedAt < end) ? []
.Select(o => o.Id) : await db.Orders
.ToListAsync(cancellationToken); .AsNoTracking()
.Where(o => currentOrderIds.Contains(o.Id))
var topRaw = await db.Orders .SelectMany(o => o.Lines)
.AsNoTracking() .GroupBy(l => new { l.ProductId, l.ProductName, l.Sku })
.Where(o => paidOrderIds.Contains(o.Id)) .Select(g => new
.SelectMany(o => o.Lines) {
.GroupBy(l => new { l.ProductVariantId, l.ProductName, l.Sku }) g.Key.ProductId,
.Select(g => new g.Key.ProductName,
{ g.Key.Sku,
g.Key.ProductName, Units = g.Sum(l => l.Quantity),
g.Key.Sku, Revenue = g.Sum(l => l.UnitPrice * l.Quantity),
Units = g.Sum(l => l.Quantity), })
Revenue = g.Sum(l => l.UnitPrice * l.Quantity), .OrderByDescending(x => x.Revenue)
VariantId = g.Key.ProductVariantId, .Take(10)
}) .ToListAsync(cancellationToken);
.OrderByDescending(x => x.Revenue)
.Take(10)
.ToListAsync(cancellationToken);
var variantIds = topRaw.Select(t => t.VariantId).ToList();
var productIds = await db.ProductVariants
.AsNoTracking()
.Where(v => variantIds.Contains(v.Id))
.Select(v => new { v.Id, v.ProductId })
.ToListAsync(cancellationToken);
var productLookup = productIds.ToDictionary(v => v.Id, v => v.ProductId);
var totalRevenue = current.Revenue == 0 ? 1m : current.Revenue; var totalRevenue = current.Revenue == 0 ? 1m : current.Revenue;
var topProducts = topRaw var topProducts = topRaw
.Select(t => new AnalyticsTopProductDto( .Select(t => new AnalyticsTopProductDto(
productLookup.GetValueOrDefault(t.VariantId), t.ProductId,
t.ProductName, t.ProductName,
t.Sku, t.Sku,
t.Units, t.Units,
@@ -138,15 +146,14 @@ public sealed class GetAnalyticsQueryHandler(IAppDbContext db)
Math.Round(t.Revenue / totalRevenue * 100m, 1))) Math.Round(t.Revenue / totalRevenue * 100m, 1)))
.ToList(); .ToList();
var byChannel = await orders var byChannel = currentRows
.Where(o => o.CreatedAt >= start && o.CreatedAt < end) .GroupBy(r => r.Source)
.GroupBy(o => o.Source)
.Select(g => new AnalyticsChannelBreakdownDto( .Select(g => new AnalyticsChannelBreakdownDto(
g.Key, g.Key,
g.Count(), g.Count(),
g.Sum(o => o.Total))) g.Sum(x => x.Total)))
.OrderByDescending(x => x.Revenue) .OrderByDescending(x => x.Revenue)
.ToListAsync(cancellationToken); .ToList();
return new AnalyticsDto( return new AnalyticsDto(
current, current,
@@ -156,20 +163,35 @@ public sealed class GetAnalyticsQueryHandler(IAppDbContext db)
byChannel); byChannel);
} }
private static async Task<AnalyticsKpiDto> AggregateAsync( private async Task<int> SumUnitsAsync(
IQueryable<Domain.Orders.Order> query, IReadOnlyList<Guid> orderIds,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var orders = await query.CountAsync(cancellationToken); if (orderIds.Count == 0)
var revenue = orders == 0 {
? 0m return 0;
: await query.SumAsync(o => o.Total, cancellationToken); }
var units = orders == 0
? 0
: await query.SelectMany(o => o.Lines)
.SumAsync(l => l.Quantity, cancellationToken);
var aov = orders == 0 ? 0m : Math.Round(revenue / orders, 2);
return await db.Orders
.AsNoTracking()
.Where(o => orderIds.Contains(o.Id))
.SelectMany(o => o.Lines)
.SumAsync(l => (int?)l.Quantity, cancellationToken) ?? 0;
}
private static AnalyticsKpiDto ToKpi(
IReadOnlyList<OrderAggRow> rows,
int units)
{
var orders = rows.Count;
var revenue = orders == 0 ? 0m : rows.Sum(r => r.Total);
var aov = orders == 0 ? 0m : Math.Round(revenue / orders, 2);
return new AnalyticsKpiDto(revenue, orders, aov, units); return new AnalyticsKpiDto(revenue, orders, aov, units);
} }
private sealed record OrderAggRow(
Guid Id,
DateTime CreatedAt,
decimal Total,
OrderSource Source);
} }
@@ -20,7 +20,12 @@ public sealed record StoreSettingsDto(
string EmailReplyTo, string EmailReplyTo,
AllegroSettingsDto Allegro, AllegroSettingsDto Allegro,
AmazonSettingsDto Amazon, AmazonSettingsDto Amazon,
IfirmaSettingsDto Ifirma); IfirmaSettingsDto Ifirma,
InPostSettingsDto InPost,
DhlSettingsDto Dhl,
DpdSettingsDto Dpd,
PayUSettingsDto PayU,
bool ShippingUseSandbox);
public sealed record LocaleFlagsDto(bool Pl, bool En, bool De, bool Cs); public sealed record LocaleFlagsDto(bool Pl, bool En, bool De, bool Cs);
@@ -48,6 +53,39 @@ public sealed record IfirmaSettingsDto(
string? InvoiceSeries, string? InvoiceSeries,
bool AutoInvoice); bool AutoInvoice);
public sealed record InPostSettingsDto(
bool HasShipXToken,
string? OrganizationId,
string? ShipXBaseUrl,
bool HasGeowidgetPublicToken,
bool HasGeowidgetSandboxToken,
string? GeowidgetConfig,
string? GeowidgetSdkBaseUrl);
public sealed record DhlSettingsDto(
bool HasApiKey,
bool HasApiSecret,
string? ApiBaseUrl,
string? WidgetUrl);
public sealed record DpdSettingsDto(
string? Login,
bool HasPassword,
string? Fid,
string? ApiBaseUrl,
string? WidgetUrl);
public sealed record PayUSettingsDto(
bool UseSandbox,
string? PosId,
string? ClientId,
bool HasClientSecret,
bool HasSecondKey,
string? OauthUrl,
string? OrdersUrl,
string? NotifyUrl,
string? ContinueUrl);
public sealed record GetStoreSettingsQuery() : IRequest<StoreSettingsDto>; public sealed record GetStoreSettingsQuery() : IRequest<StoreSettingsDto>;
public sealed class GetStoreSettingsQueryHandler(IAppDbContext db) public sealed class GetStoreSettingsQueryHandler(IAppDbContext db)
@@ -98,7 +136,37 @@ public sealed class GetStoreSettingsQueryHandler(IAppDbContext db)
s.IfirmaLogin, s.IfirmaLogin,
!string.IsNullOrEmpty(s.IfirmaApiKey), !string.IsNullOrEmpty(s.IfirmaApiKey),
s.IfirmaInvoiceSeries, s.IfirmaInvoiceSeries,
s.IfirmaAutoInvoice)); s.IfirmaAutoInvoice),
new InPostSettingsDto(
!string.IsNullOrEmpty(s.InPostShipXToken),
s.InPostOrganizationId,
s.InPostShipXBaseUrl,
!string.IsNullOrEmpty(s.InPostGeowidgetPublicToken),
!string.IsNullOrEmpty(s.InPostGeowidgetSandboxToken),
s.InPostGeowidgetConfig,
s.InPostGeowidgetSdkBaseUrl),
new DhlSettingsDto(
!string.IsNullOrEmpty(s.DhlApiKey),
!string.IsNullOrEmpty(s.DhlApiSecret),
s.DhlApiBaseUrl,
s.DhlWidgetUrl),
new DpdSettingsDto(
s.DpdLogin,
!string.IsNullOrEmpty(s.DpdPassword),
s.DpdFid,
s.DpdApiBaseUrl,
s.DpdWidgetUrl),
new PayUSettingsDto(
s.PayUUseSandbox,
s.PayUPosId,
s.PayUClientId,
!string.IsNullOrEmpty(s.PayUClientSecret),
!string.IsNullOrEmpty(s.PayUSecondKey),
s.PayUOauthUrl,
s.PayUOrdersUrl,
s.PayUNotifyUrl,
s.PayUContinueUrl),
s.ShippingUseSandbox);
} }
public sealed record UpdateStoreSettingsCommand( public sealed record UpdateStoreSettingsCommand(
@@ -116,7 +184,12 @@ public sealed record UpdateStoreSettingsCommand(
string EmailReplyTo, string EmailReplyTo,
UpdateAllegroSettingsDto? Allegro, UpdateAllegroSettingsDto? Allegro,
UpdateAmazonSettingsDto? Amazon, UpdateAmazonSettingsDto? Amazon,
UpdateIfirmaSettingsDto? Ifirma) : IRequest<StoreSettingsDto>; UpdateIfirmaSettingsDto? Ifirma,
UpdateInPostSettingsDto? InPost,
UpdateDhlSettingsDto? Dhl,
UpdateDpdSettingsDto? Dpd,
UpdatePayUSettingsDto? PayU,
bool ShippingUseSandbox) : IRequest<StoreSettingsDto>;
public sealed record UpdateAllegroSettingsDto( public sealed record UpdateAllegroSettingsDto(
bool Enabled, bool Enabled,
@@ -138,6 +211,39 @@ public sealed record UpdateIfirmaSettingsDto(
string? InvoiceSeries, string? InvoiceSeries,
bool AutoInvoice); bool AutoInvoice);
public sealed record UpdateInPostSettingsDto(
string? ShipXToken,
string? OrganizationId,
string? ShipXBaseUrl,
string? GeowidgetPublicToken,
string? GeowidgetSandboxToken,
string? GeowidgetConfig,
string? GeowidgetSdkBaseUrl);
public sealed record UpdateDhlSettingsDto(
string? ApiKey,
string? ApiSecret,
string? ApiBaseUrl,
string? WidgetUrl);
public sealed record UpdateDpdSettingsDto(
string? Login,
string? Password,
string? Fid,
string? ApiBaseUrl,
string? WidgetUrl);
public sealed record UpdatePayUSettingsDto(
bool UseSandbox,
string? PosId,
string? ClientId,
string? ClientSecret,
string? SecondKey,
string? OauthUrl,
string? OrdersUrl,
string? NotifyUrl,
string? ContinueUrl);
public sealed class UpdateStoreSettingsCommandHandler(IAppDbContext db) public sealed class UpdateStoreSettingsCommandHandler(IAppDbContext db)
: IRequestHandler<UpdateStoreSettingsCommand, StoreSettingsDto> : IRequestHandler<UpdateStoreSettingsCommand, StoreSettingsDto>
{ {
@@ -175,16 +281,14 @@ public sealed class UpdateStoreSettingsCommandHandler(IAppDbContext db)
row.EmailFromName = request.EmailFromName.Trim(); row.EmailFromName = request.EmailFromName.Trim();
row.EmailFromAddress = request.EmailFromAddress.Trim(); row.EmailFromAddress = request.EmailFromAddress.Trim();
row.EmailReplyTo = request.EmailReplyTo.Trim(); row.EmailReplyTo = request.EmailReplyTo.Trim();
row.ShippingUseSandbox = request.ShippingUseSandbox;
if (request.Allegro is not null) if (request.Allegro is not null)
{ {
row.AllegroEnabled = request.Allegro.Enabled; row.AllegroEnabled = request.Allegro.Enabled;
row.AllegroClientId = TrimOrNull(request.Allegro.ClientId); row.AllegroClientId = TrimOrNull(request.Allegro.ClientId);
row.AllegroSellerId = TrimOrNull(request.Allegro.SellerId); row.AllegroSellerId = TrimOrNull(request.Allegro.SellerId);
if (!string.IsNullOrWhiteSpace(request.Allegro.ClientSecret)) SetSecret(request.Allegro.ClientSecret, v => row.AllegroClientSecret = v);
{
row.AllegroClientSecret = request.Allegro.ClientSecret.Trim();
}
} }
if (request.Amazon is not null) if (request.Amazon is not null)
@@ -192,15 +296,8 @@ public sealed class UpdateStoreSettingsCommandHandler(IAppDbContext db)
row.AmazonEnabled = request.Amazon.Enabled; row.AmazonEnabled = request.Amazon.Enabled;
row.AmazonSellerId = TrimOrNull(request.Amazon.SellerId); row.AmazonSellerId = TrimOrNull(request.Amazon.SellerId);
row.AmazonMarketplaceId = TrimOrNull(request.Amazon.MarketplaceId); row.AmazonMarketplaceId = TrimOrNull(request.Amazon.MarketplaceId);
if (!string.IsNullOrWhiteSpace(request.Amazon.AccessKey)) SetSecret(request.Amazon.AccessKey, v => row.AmazonAccessKey = v);
{ SetSecret(request.Amazon.SecretKey, v => row.AmazonSecretKey = v);
row.AmazonAccessKey = request.Amazon.AccessKey.Trim();
}
if (!string.IsNullOrWhiteSpace(request.Amazon.SecretKey))
{
row.AmazonSecretKey = request.Amazon.SecretKey.Trim();
}
} }
if (request.Ifirma is not null) if (request.Ifirma is not null)
@@ -209,10 +306,52 @@ public sealed class UpdateStoreSettingsCommandHandler(IAppDbContext db)
row.IfirmaLogin = TrimOrNull(request.Ifirma.Login); row.IfirmaLogin = TrimOrNull(request.Ifirma.Login);
row.IfirmaInvoiceSeries = TrimOrNull(request.Ifirma.InvoiceSeries); row.IfirmaInvoiceSeries = TrimOrNull(request.Ifirma.InvoiceSeries);
row.IfirmaAutoInvoice = request.Ifirma.AutoInvoice; row.IfirmaAutoInvoice = request.Ifirma.AutoInvoice;
if (!string.IsNullOrWhiteSpace(request.Ifirma.ApiKey)) SetSecret(request.Ifirma.ApiKey, v => row.IfirmaApiKey = v);
{ }
row.IfirmaApiKey = request.Ifirma.ApiKey.Trim();
} if (request.InPost is not null)
{
row.InPostOrganizationId = TrimOrNull(request.InPost.OrganizationId);
row.InPostShipXBaseUrl = TrimOrNull(request.InPost.ShipXBaseUrl);
row.InPostGeowidgetConfig = TrimOrNull(request.InPost.GeowidgetConfig);
row.InPostGeowidgetSdkBaseUrl = TrimOrNull(request.InPost.GeowidgetSdkBaseUrl);
SetSecret(request.InPost.ShipXToken, v => row.InPostShipXToken = v);
SetSecret(
request.InPost.GeowidgetPublicToken,
v => row.InPostGeowidgetPublicToken = v);
SetSecret(
request.InPost.GeowidgetSandboxToken,
v => row.InPostGeowidgetSandboxToken = v);
}
if (request.Dhl is not null)
{
row.DhlApiBaseUrl = TrimOrNull(request.Dhl.ApiBaseUrl);
row.DhlWidgetUrl = TrimOrNull(request.Dhl.WidgetUrl);
SetSecret(request.Dhl.ApiKey, v => row.DhlApiKey = v);
SetSecret(request.Dhl.ApiSecret, v => row.DhlApiSecret = v);
}
if (request.Dpd is not null)
{
row.DpdLogin = TrimOrNull(request.Dpd.Login);
row.DpdFid = TrimOrNull(request.Dpd.Fid);
row.DpdApiBaseUrl = TrimOrNull(request.Dpd.ApiBaseUrl);
row.DpdWidgetUrl = TrimOrNull(request.Dpd.WidgetUrl);
SetSecret(request.Dpd.Password, v => row.DpdPassword = v);
}
if (request.PayU is not null)
{
row.PayUUseSandbox = request.PayU.UseSandbox;
row.PayUPosId = TrimOrNull(request.PayU.PosId);
row.PayUClientId = TrimOrNull(request.PayU.ClientId);
row.PayUOauthUrl = TrimOrNull(request.PayU.OauthUrl);
row.PayUOrdersUrl = TrimOrNull(request.PayU.OrdersUrl);
row.PayUNotifyUrl = TrimOrNull(request.PayU.NotifyUrl);
row.PayUContinueUrl = TrimOrNull(request.PayU.ContinueUrl);
SetSecret(request.PayU.ClientSecret, v => row.PayUClientSecret = v);
SetSecret(request.PayU.SecondKey, v => row.PayUSecondKey = v);
} }
row.UpdatedAt = DateTime.UtcNow; row.UpdatedAt = DateTime.UtcNow;
@@ -244,4 +383,12 @@ public sealed class UpdateStoreSettingsCommandHandler(IAppDbContext db)
private static string? TrimOrNull(string? value) => private static string? TrimOrNull(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static void SetSecret(string? incoming, Action<string> assign)
{
if (!string.IsNullOrWhiteSpace(incoming))
{
assign(incoming.Trim());
}
}
} }
@@ -0,0 +1,72 @@
namespace iLED.Application.Common.Interfaces;
/// <summary>
/// Runtime PayU + carrier credentials from StoreSettings,
/// falling back to appsettings when a DB field is empty.
/// </summary>
public interface IIntegrationSettingsAccessor
{
Task<IntegrationRuntimeSettings> GetAsync(
CancellationToken cancellationToken = default);
}
public sealed class IntegrationRuntimeSettings
{
public bool ShippingUseSandbox { get; init; } = true;
public bool CarrierInPost { get; init; } = true;
public bool CarrierDhl { get; init; } = true;
public bool CarrierDpd { get; init; } = true;
public string? InPostShipXToken { get; init; }
public string? InPostOrganizationId { get; init; }
public string? InPostShipXBaseUrl { get; init; }
public string? InPostGeowidgetPublicToken { get; init; }
public string? InPostGeowidgetSandboxToken { get; init; }
public string? InPostGeowidgetConfig { get; init; }
public string? InPostGeowidgetSdkBaseUrl { get; init; }
public string? DhlApiKey { get; init; }
public string? DhlApiSecret { get; init; }
public string? DhlApiBaseUrl { get; init; }
public string? DhlWidgetUrl { get; init; }
public string? DpdLogin { get; init; }
public string? DpdPassword { get; init; }
public string? DpdFid { get; init; }
public string? DpdApiBaseUrl { get; init; }
public string? DpdWidgetUrl { get; init; }
public bool PayUUseSandbox { get; init; } = true;
public string? PayUPosId { get; init; }
public string? PayUClientId { get; init; }
public string? PayUClientSecret { get; init; }
public string? PayUSecondKey { get; init; }
public string? PayUOauthUrl { get; init; }
public string? PayUOrdersUrl { get; init; }
public string? PayUNotifyUrl { get; init; }
public string? PayUContinueUrl { get; init; }
}
@@ -12,7 +12,8 @@ public interface IShippingService
ShipmentRequest request, ShipmentRequest request,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
ShippingWidgetConfigDto GetWidgetConfig(); Task<ShippingWidgetConfigDto> GetWidgetConfigAsync(
CancellationToken cancellationToken = default);
} }
public sealed record ShippingParcelPointSearch( public sealed record ShippingParcelPointSearch(
@@ -17,7 +17,8 @@ public sealed class InitiatePaymentCommandHandler(
IOrderService orderService, IOrderService orderService,
IPaymentService paymentService, IPaymentService paymentService,
IAppDbContext db, IAppDbContext db,
IConfiguration configuration) IConfiguration configuration,
IIntegrationSettingsAccessor integrationSettings)
: IRequestHandler<InitiatePaymentCommand, PaymentResultDto> : IRequestHandler<InitiatePaymentCommand, PaymentResultDto>
{ {
public async Task<PaymentResultDto> Handle( public async Task<PaymentResultDto> Handle(
@@ -27,10 +28,11 @@ public sealed class InitiatePaymentCommandHandler(
var order = await orderService.GetOrderAsync( var order = await orderService.GetOrderAsync(
request.OrderId, cancellationToken); request.OrderId, cancellationToken);
var settings = await integrationSettings.GetAsync(cancellationToken);
var shopBase = configuration["Seo:ShopBaseUrl"] ?? "http://localhost:4200"; var shopBase = configuration["Seo:ShopBaseUrl"] ?? "http://localhost:4200";
var notifyUrl = configuration["PayU:NotifyUrl"] var notifyUrl = settings.PayUNotifyUrl
?? "http://localhost:5000/api/v1/payments/webhook"; ?? "http://localhost:5000/api/v1/payments/webhook";
var continueBase = configuration["PayU:ContinueUrl"] ?? $"{shopBase}/order"; var continueBase = settings.PayUContinueUrl ?? $"{shopBase}/order";
var paymentRequest = new PaymentInitiationRequest( var paymentRequest = new PaymentInitiationRequest(
order.Id, order.Id,
@@ -69,14 +71,15 @@ public sealed record CompleteSandboxPaymentCommand(Guid OrderId) : IRequest<Paym
public sealed class CompleteSandboxPaymentCommandHandler( public sealed class CompleteSandboxPaymentCommandHandler(
IOrderService orderService, IOrderService orderService,
IAppDbContext db, IAppDbContext db,
IConfiguration configuration) IIntegrationSettingsAccessor integrationSettings)
: IRequestHandler<CompleteSandboxPaymentCommand, PaymentResultDto> : IRequestHandler<CompleteSandboxPaymentCommand, PaymentResultDto>
{ {
public async Task<PaymentResultDto> Handle( public async Task<PaymentResultDto> Handle(
CompleteSandboxPaymentCommand request, CompleteSandboxPaymentCommand request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!configuration.GetValue("PayU:UseSandbox", true)) var settings = await integrationSettings.GetAsync(cancellationToken);
if (!settings.PayUUseSandbox)
{ {
return new PaymentResultDto( return new PaymentResultDto(
false, false,
@@ -37,7 +37,7 @@ public sealed class GetShippingWidgetConfigQueryHandler(IShippingService shippin
public Task<ShippingWidgetConfigDto> Handle( public Task<ShippingWidgetConfigDto> Handle(
GetShippingWidgetConfigQuery request, GetShippingWidgetConfigQuery request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
Task.FromResult(shipping.GetWidgetConfig()); shipping.GetWidgetConfigAsync(cancellationToken);
} }
public sealed record CreateShipmentLabelCommand(Guid OrderId) public sealed record CreateShipmentLabelCommand(Guid OrderId)
@@ -73,4 +73,58 @@ public class StoreSettings : BaseEntity
public string? IfirmaInvoiceSeries { get; set; } public string? IfirmaInvoiceSeries { get; set; }
public bool IfirmaAutoInvoice { get; set; } public bool IfirmaAutoInvoice { get; set; }
// ── Shipping credentials ─────────────────────────────────
public bool ShippingUseSandbox { get; set; } = true;
public string? InPostShipXToken { get; set; }
public string? InPostOrganizationId { get; set; }
public string? InPostShipXBaseUrl { get; set; }
public string? InPostGeowidgetPublicToken { get; set; }
public string? InPostGeowidgetSandboxToken { get; set; }
public string? InPostGeowidgetConfig { get; set; }
public string? InPostGeowidgetSdkBaseUrl { get; set; }
public string? DhlApiKey { get; set; }
public string? DhlApiSecret { get; set; }
public string? DhlApiBaseUrl { get; set; }
public string? DhlWidgetUrl { get; set; }
public string? DpdLogin { get; set; }
public string? DpdPassword { get; set; }
public string? DpdFid { get; set; }
public string? DpdApiBaseUrl { get; set; }
public string? DpdWidgetUrl { get; set; }
// ── PayU ─────────────────────────────────────────────────
public bool PayUUseSandbox { get; set; } = true;
public string? PayUPosId { get; set; }
public string? PayUClientId { get; set; }
public string? PayUClientSecret { get; set; }
public string? PayUSecondKey { get; set; }
public string? PayUOauthUrl { get; set; }
public string? PayUOrdersUrl { get; set; }
public string? PayUNotifyUrl { get; set; }
public string? PayUContinueUrl { get; set; }
} }
@@ -56,6 +56,7 @@ public static class DependencyInjection
{ {
client.Timeout = TimeSpan.FromSeconds(60); client.Timeout = TimeSpan.FromSeconds(60);
}); });
services.AddScoped<IIntegrationSettingsAccessor, IntegrationSettingsAccessor>();
services.AddScoped<IPaymentService, PayUPaymentService>(); services.AddScoped<IPaymentService, PayUPaymentService>();
services.AddHostedService<PayUPaymentSyncBackgroundService>(); services.AddHostedService<PayUPaymentSyncBackgroundService>();
services.AddScoped<ShippingCarrierClients>(); services.AddScoped<ShippingCarrierClients>();
@@ -213,6 +213,33 @@ namespace iLED.Infrastructure.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("DhlApiBaseUrl")
.HasColumnType("text");
b.Property<string>("DhlApiKey")
.HasColumnType("text");
b.Property<string>("DhlApiSecret")
.HasColumnType("text");
b.Property<string>("DhlWidgetUrl")
.HasColumnType("text");
b.Property<string>("DpdApiBaseUrl")
.HasColumnType("text");
b.Property<string>("DpdFid")
.HasColumnType("text");
b.Property<string>("DpdLogin")
.HasColumnType("text");
b.Property<string>("DpdPassword")
.HasColumnType("text");
b.Property<string>("DpdWidgetUrl")
.HasColumnType("text");
b.Property<string>("EmailFromAddress") b.Property<string>("EmailFromAddress")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -240,6 +267,27 @@ namespace iLED.Infrastructure.Migrations
b.Property<string>("IfirmaLogin") b.Property<string>("IfirmaLogin")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("InPostGeowidgetConfig")
.HasColumnType("text");
b.Property<string>("InPostGeowidgetPublicToken")
.HasColumnType("text");
b.Property<string>("InPostGeowidgetSandboxToken")
.HasColumnType("text");
b.Property<string>("InPostGeowidgetSdkBaseUrl")
.HasColumnType("text");
b.Property<string>("InPostOrganizationId")
.HasColumnType("text");
b.Property<string>("InPostShipXBaseUrl")
.HasColumnType("text");
b.Property<string>("InPostShipXToken")
.HasColumnType("text");
b.Property<string>("LegalName") b.Property<string>("LegalName")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -259,6 +307,36 @@ namespace iLED.Infrastructure.Migrations
b.Property<int>("LowStockThreshold") b.Property<int>("LowStockThreshold")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("PayUClientId")
.HasColumnType("text");
b.Property<string>("PayUClientSecret")
.HasColumnType("text");
b.Property<string>("PayUContinueUrl")
.HasColumnType("text");
b.Property<string>("PayUNotifyUrl")
.HasColumnType("text");
b.Property<string>("PayUOauthUrl")
.HasColumnType("text");
b.Property<string>("PayUOrdersUrl")
.HasColumnType("text");
b.Property<string>("PayUPosId")
.HasColumnType("text");
b.Property<string>("PayUSecondKey")
.HasColumnType("text");
b.Property<bool>("PayUUseSandbox")
.HasColumnType("boolean");
b.Property<bool>("ShippingUseSandbox")
.HasColumnType("boolean");
b.Property<string>("StoreName") b.Property<string>("StoreName")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -0,0 +1,280 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace iLED.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class ShippingPayUIntegrations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "DhlApiBaseUrl",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DhlApiKey",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DhlApiSecret",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DhlWidgetUrl",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DpdApiBaseUrl",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DpdFid",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DpdLogin",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DpdPassword",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DpdWidgetUrl",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InPostGeowidgetConfig",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InPostGeowidgetPublicToken",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InPostGeowidgetSandboxToken",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InPostGeowidgetSdkBaseUrl",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InPostOrganizationId",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InPostShipXBaseUrl",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InPostShipXToken",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PayUClientId",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PayUClientSecret",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PayUContinueUrl",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PayUNotifyUrl",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PayUOauthUrl",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PayUOrdersUrl",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PayUPosId",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PayUSecondKey",
table: "StoreSettings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "PayUUseSandbox",
table: "StoreSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "ShippingUseSandbox",
table: "StoreSettings",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DhlApiBaseUrl",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "DhlApiKey",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "DhlApiSecret",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "DhlWidgetUrl",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "DpdApiBaseUrl",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "DpdFid",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "DpdLogin",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "DpdPassword",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "DpdWidgetUrl",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "InPostGeowidgetConfig",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "InPostGeowidgetPublicToken",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "InPostGeowidgetSandboxToken",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "InPostGeowidgetSdkBaseUrl",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "InPostOrganizationId",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "InPostShipXBaseUrl",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "InPostShipXToken",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "PayUClientId",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "PayUClientSecret",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "PayUContinueUrl",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "PayUNotifyUrl",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "PayUOauthUrl",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "PayUOrdersUrl",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "PayUPosId",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "PayUSecondKey",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "PayUUseSandbox",
table: "StoreSettings");
migrationBuilder.DropColumn(
name: "ShippingUseSandbox",
table: "StoreSettings");
}
}
}
@@ -4,7 +4,6 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using iLED.Application.Common.Interfaces; using iLED.Application.Common.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace iLED.Infrastructure.Services; namespace iLED.Infrastructure.Services;
@@ -15,7 +14,7 @@ namespace iLED.Infrastructure.Services;
/// </summary> /// </summary>
public sealed class DpdPudoClient( public sealed class DpdPudoClient(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IConfiguration configuration, IIntegrationSettingsAccessor integrationSettings,
ILogger<DpdPudoClient> logger) ILogger<DpdPudoClient> logger)
{ {
private const string DefaultBaseUrl = "https://pudofinder.dpd.com.pl"; private const string DefaultBaseUrl = "https://pudofinder.dpd.com.pl";
@@ -90,7 +89,7 @@ public sealed class DpdPudoClient(
private async Task<IReadOnlyList<PudoPoint>> GetCatalogueAsync( private async Task<IReadOnlyList<PudoPoint>> GetCatalogueAsync(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var baseUrl = GetBaseUrl(); var baseUrl = await GetBaseUrlAsync(cancellationToken);
if (Cache.TryGetValue(baseUrl, out var cached) && if (Cache.TryGetValue(baseUrl, out var cached) &&
cached.ExpiresAt > DateTimeOffset.UtcNow) cached.ExpiresAt > DateTimeOffset.UtcNow)
{ {
@@ -130,7 +129,7 @@ public sealed class DpdPudoClient(
} }
var client = httpClientFactory.CreateClient(nameof(DpdPudoClient)); var client = httpClientFactory.CreateClient(nameof(DpdPudoClient));
var baseUrl = GetBaseUrl(); var baseUrl = await GetBaseUrlAsync(cancellationToken);
var url = $"{baseUrl}/ajax/points/details?lang=pl"; var url = $"{baseUrl}/ajax/points/details?lang=pl";
using var content = new FormUrlEncodedContent( using var content = new FormUrlEncodedContent(
@@ -161,9 +160,10 @@ public sealed class DpdPudoClient(
StringComparer.OrdinalIgnoreCase); StringComparer.OrdinalIgnoreCase);
} }
private string GetBaseUrl() private async Task<string> GetBaseUrlAsync(CancellationToken cancellationToken)
{ {
var configured = configuration["Shipping:Dpd:WidgetUrl"]; var settings = await integrationSettings.GetAsync(cancellationToken);
var configured = settings.DpdWidgetUrl;
if (string.IsNullOrWhiteSpace(configured)) if (string.IsNullOrWhiteSpace(configured))
{ {
return DefaultBaseUrl; return DefaultBaseUrl;
@@ -11,19 +11,20 @@ namespace iLED.Infrastructure.Services;
public sealed class PayUPaymentService( public sealed class PayUPaymentService(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IConfiguration configuration, IIntegrationSettingsAccessor integrationSettings,
ILogger<PayUPaymentService> logger) : IPaymentService ILogger<PayUPaymentService> logger) : IPaymentService
{ {
public async Task<PaymentInitiationResult> InitiatePaymentAsync( public async Task<PaymentInitiationResult> InitiatePaymentAsync(
PaymentInitiationRequest request, PaymentInitiationRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var posId = configuration["PayU:PosId"]; var settings = await integrationSettings.GetAsync(cancellationToken);
var clientId = configuration["PayU:ClientId"]; var posId = settings.PayUPosId;
var clientSecret = configuration["PayU:ClientSecret"]; var clientId = settings.PayUClientId;
var oauthUrl = configuration["PayU:OAuthUrl"]; var clientSecret = settings.PayUClientSecret;
var ordersUrl = configuration["PayU:OrdersUrl"]; var oauthUrl = settings.PayUOauthUrl;
var useSandbox = configuration.GetValue("PayU:UseSandbox", true); var ordersUrl = settings.PayUOrdersUrl;
var useSandbox = settings.PayUUseSandbox;
var hasCredentials = var hasCredentials =
!string.IsNullOrWhiteSpace(posId) && !string.IsNullOrWhiteSpace(posId) &&
!string.IsNullOrWhiteSpace(clientId) && !string.IsNullOrWhiteSpace(clientId) &&
@@ -40,7 +41,7 @@ public sealed class PayUPaymentService(
false, false,
null, null,
null, null,
"Configure PayU:PosId, ClientId, ClientSecret and URLs."); "Configure PayU credentials in Settings → Integrations.");
} }
var externalId = $"PAYU-{Guid.NewGuid():N}"; var externalId = $"PAYU-{Guid.NewGuid():N}";
@@ -211,10 +212,11 @@ public sealed class PayUPaymentService(
"No PayU order id to sync."); "No PayU order id to sync.");
} }
var clientId = configuration["PayU:ClientId"]; var settings = await integrationSettings.GetAsync(cancellationToken);
var clientSecret = configuration["PayU:ClientSecret"]; var clientId = settings.PayUClientId;
var oauthUrl = configuration["PayU:OAuthUrl"]; var clientSecret = settings.PayUClientSecret;
var ordersUrl = configuration["PayU:OrdersUrl"]; var oauthUrl = settings.PayUOauthUrl;
var ordersUrl = settings.PayUOrdersUrl;
if (string.IsNullOrWhiteSpace(clientId) || if (string.IsNullOrWhiteSpace(clientId) ||
string.IsNullOrWhiteSpace(clientSecret) || string.IsNullOrWhiteSpace(clientSecret) ||
@@ -318,20 +320,21 @@ public sealed class PayUPaymentService(
} }
} }
public Task<PaymentWebhookResult> HandleWebhookAsync( public async Task<PaymentWebhookResult> HandleWebhookAsync(
string payload, string payload,
string signature, string signature,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
try try
{ {
var secondKey = configuration["PayU:SecondKey"]; var settings = await integrationSettings.GetAsync(cancellationToken);
var secondKey = settings.PayUSecondKey;
if (!string.IsNullOrWhiteSpace(secondKey) && if (!string.IsNullOrWhiteSpace(secondKey) &&
!VerifySignature(payload, signature, secondKey)) !VerifySignature(payload, signature, secondKey))
{ {
logger.LogWarning("PayU webhook signature mismatch"); logger.LogWarning("PayU webhook signature mismatch");
return Task.FromResult(new PaymentWebhookResult( return new PaymentWebhookResult(
false, null, null, false, "Invalid signature")); false, null, null, false, "Invalid signature");
} }
using var doc = JsonDocument.Parse(payload); using var doc = JsonDocument.Parse(payload);
@@ -362,18 +365,18 @@ public sealed class PayUPaymentService(
"COMPLETED", "COMPLETED",
StringComparison.OrdinalIgnoreCase); StringComparison.OrdinalIgnoreCase);
return Task.FromResult(new PaymentWebhookResult( return new PaymentWebhookResult(
true, true,
orderId, orderId,
payuOrderId ?? extId, payuOrderId ?? extId,
completed, completed,
null)); null);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "PayU webhook parse error"); logger.LogError(ex, "PayU webhook parse error");
return Task.FromResult(new PaymentWebhookResult( return new PaymentWebhookResult(
false, null, null, false, ex.Message)); false, null, null, false, ex.Message);
} }
} }
@@ -0,0 +1,112 @@
using iLED.Application.Common.Interfaces;
using iLED.Domain.Admin;
using iLED.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace iLED.Infrastructure.Services;
public sealed class IntegrationSettingsAccessor(
ApplicationDbContext db,
IConfiguration configuration) : IIntegrationSettingsAccessor
{
public async Task<IntegrationRuntimeSettings> GetAsync(
CancellationToken cancellationToken = default)
{
var row = await db.StoreSettings
.AsNoTracking()
.OrderBy(s => s.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return From(row, configuration);
}
internal static IntegrationRuntimeSettings From(
StoreSettings? row,
IConfiguration configuration)
{
return new IntegrationRuntimeSettings
{
ShippingUseSandbox = row?.ShippingUseSandbox
?? configuration.GetValue("Shipping:UseSandbox", true),
CarrierInPost = row?.CarrierInPost ?? true,
CarrierDhl = row?.CarrierDhl ?? true,
CarrierDpd = row?.CarrierDpd ?? true,
InPostShipXToken = Prefer(
row?.InPostShipXToken,
configuration["Shipping:InPost:ShipXToken"]),
InPostOrganizationId = Prefer(
row?.InPostOrganizationId,
configuration["Shipping:InPost:OrganizationId"]),
InPostShipXBaseUrl = Prefer(
row?.InPostShipXBaseUrl,
configuration["Shipping:InPost:ShipXBaseUrl"]),
InPostGeowidgetPublicToken = Prefer(
row?.InPostGeowidgetPublicToken,
configuration["Shipping:InPost:GeowidgetPublicToken"]),
InPostGeowidgetSandboxToken = Prefer(
row?.InPostGeowidgetSandboxToken,
configuration["Shipping:InPost:GeowidgetSandboxToken"]),
InPostGeowidgetConfig = Prefer(
row?.InPostGeowidgetConfig,
configuration["Shipping:InPost:GeowidgetConfig"]),
InPostGeowidgetSdkBaseUrl = Prefer(
row?.InPostGeowidgetSdkBaseUrl,
configuration["Shipping:InPost:GeowidgetSdkBaseUrl"]),
DhlApiKey = Prefer(
row?.DhlApiKey,
configuration["Shipping:Dhl:ApiKey"]),
DhlApiSecret = Prefer(
row?.DhlApiSecret,
configuration["Shipping:Dhl:ApiSecret"]),
DhlApiBaseUrl = Prefer(
row?.DhlApiBaseUrl,
configuration["Shipping:Dhl:ApiBaseUrl"]),
DhlWidgetUrl = Prefer(
row?.DhlWidgetUrl,
configuration["Shipping:Dhl:WidgetUrl"]),
DpdLogin = Prefer(
row?.DpdLogin,
configuration["Shipping:Dpd:Login"]),
DpdPassword = Prefer(
row?.DpdPassword,
configuration["Shipping:Dpd:Password"]),
DpdFid = Prefer(
row?.DpdFid,
configuration["Shipping:Dpd:Fid"]),
DpdApiBaseUrl = Prefer(
row?.DpdApiBaseUrl,
configuration["Shipping:Dpd:ApiBaseUrl"]),
DpdWidgetUrl = Prefer(
row?.DpdWidgetUrl,
configuration["Shipping:Dpd:WidgetUrl"]),
PayUUseSandbox = row?.PayUUseSandbox
?? configuration.GetValue("PayU:UseSandbox", true),
PayUPosId = Prefer(row?.PayUPosId, configuration["PayU:PosId"]),
PayUClientId = Prefer(
row?.PayUClientId,
configuration["PayU:ClientId"]),
PayUClientSecret = Prefer(
row?.PayUClientSecret,
configuration["PayU:ClientSecret"]),
PayUSecondKey = Prefer(
row?.PayUSecondKey,
configuration["PayU:SecondKey"]),
PayUOauthUrl = Prefer(
row?.PayUOauthUrl,
configuration["PayU:OAuthUrl"]),
PayUOrdersUrl = Prefer(
row?.PayUOrdersUrl,
configuration["PayU:OrdersUrl"]),
PayUNotifyUrl = Prefer(
row?.PayUNotifyUrl,
configuration["PayU:NotifyUrl"]),
PayUContinueUrl = Prefer(
row?.PayUContinueUrl,
configuration["PayU:ContinueUrl"]),
};
}
private static string? Prefer(string? primary, string? fallback) =>
!string.IsNullOrWhiteSpace(primary) ? primary.Trim() : fallback;
}
@@ -12,6 +12,7 @@ namespace iLED.Infrastructure.Services;
public sealed class ShippingCarrierClients( public sealed class ShippingCarrierClients(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IConfiguration configuration, IConfiguration configuration,
IIntegrationSettingsAccessor integrationSettings,
ILogger<ShippingCarrierClients> logger, ILogger<ShippingCarrierClients> logger,
ShippingLabelStorage labelStorage) ShippingLabelStorage labelStorage)
{ {
@@ -31,14 +32,15 @@ public sealed class ShippingCarrierClients(
ShipmentRequest request, ShipmentRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var token = configuration["Shipping:InPost:ShipXToken"]; var settings = await integrationSettings.GetAsync(cancellationToken);
var orgId = configuration["Shipping:InPost:OrganizationId"]; var token = settings.InPostShipXToken;
var baseUrl = configuration["Shipping:InPost:ShipXBaseUrl"] var orgId = settings.InPostOrganizationId;
var baseUrl = settings.InPostShipXBaseUrl
?? "https://api-shipx-pl.easypack24.net"; ?? "https://api-shipx-pl.easypack24.net";
if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(orgId)) if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(orgId))
{ {
return Fail("Configure Shipping:InPost:ShipXToken and OrganizationId."); return Fail("Configure InPost ShipX token and organization in Settings → Integrations.");
} }
var isLocker = request.ServiceType == ShippingServiceType.ParcelLocker; var isLocker = request.ServiceType == ShippingServiceType.ParcelLocker;
@@ -188,15 +190,16 @@ public sealed class ShippingCarrierClients(
ShipmentRequest request, ShipmentRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var apiKey = configuration["Shipping:Dhl:ApiKey"]; var settings = await integrationSettings.GetAsync(cancellationToken);
var apiSecret = configuration["Shipping:Dhl:ApiSecret"]; var apiKey = settings.DhlApiKey;
var baseUrl = configuration["Shipping:Dhl:ApiBaseUrl"]; var apiSecret = settings.DhlApiSecret;
var baseUrl = settings.DhlApiBaseUrl;
if (string.IsNullOrWhiteSpace(apiKey) || if (string.IsNullOrWhiteSpace(apiKey) ||
string.IsNullOrWhiteSpace(apiSecret) || string.IsNullOrWhiteSpace(apiSecret) ||
string.IsNullOrWhiteSpace(baseUrl)) string.IsNullOrWhiteSpace(baseUrl))
{ {
return Fail("Configure Shipping:Dhl:ApiKey, ApiSecret and ApiBaseUrl."); return Fail("Configure DHL API key, secret and base URL in Settings → Integrations.");
} }
var payload = new var payload = new
@@ -301,17 +304,18 @@ public sealed class ShippingCarrierClients(
ShipmentRequest request, ShipmentRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var login = configuration["Shipping:Dpd:Login"]; var settings = await integrationSettings.GetAsync(cancellationToken);
var password = configuration["Shipping:Dpd:Password"]; var login = settings.DpdLogin;
var fid = configuration["Shipping:Dpd:Fid"]; var password = settings.DpdPassword;
var baseUrl = configuration["Shipping:Dpd:ApiBaseUrl"]; var fid = settings.DpdFid;
var baseUrl = settings.DpdApiBaseUrl;
if (string.IsNullOrWhiteSpace(login) || if (string.IsNullOrWhiteSpace(login) ||
string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(password) ||
string.IsNullOrWhiteSpace(fid) || string.IsNullOrWhiteSpace(fid) ||
string.IsNullOrWhiteSpace(baseUrl)) string.IsNullOrWhiteSpace(baseUrl))
{ {
return Fail("Configure Shipping:Dpd:Login, Password, Fid and ApiBaseUrl."); return Fail("Configure DPD login, password, FID and API URL in Settings → Integrations.");
} }
// DPD PL PackageService-style XML envelope (simplified create). // DPD PL PackageService-style XML envelope (simplified create).
@@ -9,6 +9,7 @@ namespace iLED.Infrastructure.Services;
public sealed class ShippingService( public sealed class ShippingService(
IConfiguration configuration, IConfiguration configuration,
IIntegrationSettingsAccessor integrationSettings,
ILogger<ShippingService> logger, ILogger<ShippingService> logger,
ShippingLabelStorage labelStorage, ShippingLabelStorage labelStorage,
ShippingCarrierClients carrierClients, ShippingCarrierClients carrierClients,
@@ -50,16 +51,17 @@ public sealed class ShippingService(
return points; return points;
} }
public ShippingWidgetConfigDto GetWidgetConfig() public async Task<ShippingWidgetConfigDto> GetWidgetConfigAsync(
CancellationToken cancellationToken = default)
{ {
var useSandbox = configuration.GetValue("Shipping:UseSandbox", true); var settings = await integrationSettings.GetAsync(cancellationToken);
var useSandbox = settings.ShippingUseSandbox;
var inPostToken = useSandbox var inPostToken = useSandbox
? configuration["Shipping:InPost:GeowidgetSandboxToken"] ? settings.InPostGeowidgetSandboxToken
?? configuration["Shipping:InPost:GeowidgetPublicToken"] ?? settings.InPostGeowidgetPublicToken
: configuration["Shipping:InPost:GeowidgetPublicToken"]; : settings.InPostGeowidgetPublicToken;
var inPostConfig = configuration["Shipping:InPost:GeowidgetConfig"] var inPostConfig = settings.InPostGeowidgetConfig ?? "parcelCollect";
?? "parcelCollect"; var inPostSdk = settings.InPostGeowidgetSdkBaseUrl;
var inPostSdk = configuration["Shipping:InPost:GeowidgetSdkBaseUrl"];
if (string.IsNullOrWhiteSpace(inPostSdk)) if (string.IsNullOrWhiteSpace(inPostSdk))
{ {
inPostSdk = useSandbox inPostSdk = useSandbox
@@ -67,11 +69,11 @@ public sealed class ShippingService(
: "https://geowidget.inpost.pl"; : "https://geowidget.inpost.pl";
} }
var dhlEnabled = configuration.GetValue("Shipping:Dhl:WidgetEnabled", true); var dhlEnabled = settings.CarrierDhl;
var dhlUrl = configuration["Shipping:Dhl:WidgetUrl"] var dhlUrl = settings.DhlWidgetUrl
?? "https://parcelshop.dhl.pl/mapa"; ?? "https://parcelshop.dhl.pl/mapa";
var dpdEnabled = configuration.GetValue("Shipping:Dpd:WidgetEnabled", true); var dpdEnabled = settings.CarrierDpd;
var dpdUrl = configuration["Shipping:Dpd:WidgetUrl"] var dpdUrl = settings.DpdWidgetUrl
?? "https://pudofinder.dpd.com.pl/"; ?? "https://pudofinder.dpd.com.pl/";
if (dpdUrl.Contains("pickup.dpd.pl", StringComparison.OrdinalIgnoreCase)) if (dpdUrl.Contains("pickup.dpd.pl", StringComparison.OrdinalIgnoreCase))
{ {
@@ -79,9 +81,10 @@ public sealed class ShippingService(
dpdUrl = "https://pudofinder.dpd.com.pl/"; dpdUrl = "https://pudofinder.dpd.com.pl/";
} }
var inPostEnabled = settings.CarrierInPost;
return new ShippingWidgetConfigDto( return new ShippingWidgetConfigDto(
useSandbox, useSandbox,
string.IsNullOrWhiteSpace(inPostToken) !inPostEnabled || string.IsNullOrWhiteSpace(inPostToken)
? null ? null
: new InPostGeowidgetConfigDto( : new InPostGeowidgetConfigDto(
inPostToken, inPostToken,