5 Commits

Author SHA1 Message Date
89d74428b2 FaKrosnoAngular
* Added project
2026-08-08 13:39:17 +02:00
0a7399f015 Add documentation generator script for FaKrosnoManagement
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 19:19:06 +02:00
775c7a2d5c * Removed comments
All checks were successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2026-04-10 07:12:09 +02:00
7c61f6c80f * Removed comment
All checks were successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2026-04-10 06:59:20 +02:00
6fac50225a Update .woodpecker.yml 2026-04-10 04:29:31 +00:00
75 changed files with 16109 additions and 3 deletions

View File

@@ -0,0 +1,841 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Skrypt generujący dokumentację PDF projektu FaKrosno Management.
Uruchomienie: python3 generate_docs.py
"""
from fpdf import FPDF
import os
FONT_PATH = "/Library/Fonts/Arial Unicode.ttf"
OUTPUT_FILE = os.path.join(os.path.dirname(__file__), "dokumentacja_fakrosno.pdf")
# ──────────────────────────────────────────────────────────────────────────────
# Pomocnicze struktury danych opis każdego pliku z komentarzami
# ──────────────────────────────────────────────────────────────────────────────
DOCS = [
# ═══════════════════════════════════════════════════════════════════
("PUNKT WEJŚCIA I ROUTING APLIKACJI", None, None),
# ═══════════════════════════════════════════════════════════════════
("main.tsx", "Główny punkt wejścia aplikacji (odpowiednik index.html + bootstrap w Blazorze)", [
("1", "import { StrictMode } from 'react'",
"Importuje tryb ścisły React'a włącza dodatkowe ostrzeżenia podczas programowania."),
("2", "import { createRoot } from 'react-dom/client'",
"Importuje funkcję tworzącą korzeń aplikacji React w drzewie DOM (React 18+)."),
("3", "import { BrowserRouter } from 'react-router-dom'",
"Importuje router URL-owy: śledzi adres przeglądarki i aktualizuje widok bez przeładowania strony."),
("4", "import { QueryClient, QueryClientProvider } from '@tanstack/react-query'",
"QueryClient zarządza pamięcią podręczną zapytań HTTP. QueryClientProvider udostępnia go całej aplikacji."),
("5", "import { ReactQueryDevtools } from '@tanstack/react-query-devtools'",
"Narzędzie deweloperskie do podglądu stanu zapytań (widoczne tylko w trybie development)."),
("6", "import App from './App'",
"Główny komponent aplikacji zawierający definicję tras (routingu)."),
("7", "import { initSyncfusion } from '@/lib/syncfusion'",
"Importuje funkcję inicjalizującą bibliotekę tabel/siatek Syncfusion z polską lokalizacją."),
("8", "import { Toaster } from '@/components/ui/Toaster'",
"Komponent wyświetlający powiadomienia (np. 'Zapisano', 'Błąd') w rogu ekranu."),
("9", "import { ErrorBoundary } from '@/components/ErrorBoundary'",
"Komponent-otoczka chwytający nieobsłużone błędy React i pokazujący przyjazny komunikat zamiast białego ekranu."),
("10", "import './index.css'",
"Globalne style CSS (Tailwind) definiuje bazowe reguły wyglądu całej strony."),
("12", "initSyncfusion()",
"Wywołuje inicjalizację Syncfusion rejestruje klucz licencyjny i ustawia język polski."),
("14-22", "const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false } } })",
"Tworzy klienta zarządzającego pamięcią podręczną danych. Ustawienia: dane są 'świeże' przez 30 sekund, przy błędzie ponawia raz, NIE odświeża po powrocie do zakładki przeglądarki."),
("24-25", "const rootElement = document.getElementById('root')",
"Szuka w HTML elementu <div id='root'> to kontener, w którym wyrenderuje się cała aplikacja React. Jeśli nie istnieje, rzuca błędem."),
("27-39", "createRoot(rootElement).render(...)",
"Renderuje całą aplikację: StrictMode (ostrzeżenia dev) → ErrorBoundary (siatka bezpieczeństwa) → QueryClientProvider (cache HTTP) → BrowserRouter (routing URL) → App (strony) + Toaster (powiadomienia) + ReactQueryDevtools (narzędzie dev)."),
]),
("App.tsx", "Definicja wszystkich tras (stron) aplikacji odpowiednik routingu w Blazorze", [
("1-6", "Importy bazowe",
"lazy wczytuje strony dopiero gdy są potrzebne (oszczędza czas ładowania). Suspense pokazuje 'kółko ładowania' gdy strona się wczytuje. Routes/Route definiują mapę adresów URL → strony."),
("9-45", "const LoginPage = lazy(...), const RegisterPage = lazy(...) ...",
"Definiuje 15 stron aplikacji jako 'leniwie ładowane'. Oznacza to, że kod strony (np. SchedulerPage.tsx) jest pobierany z serwera dopiero gdy użytkownik po raz pierwszy wejdzie na daną stronę nie przy starcie aplikacji."),
("47-101", "export default function App()",
"Główna funkcja aplikacji. Renderuje NavigatorBridge (obsługa przekierowań z kodu JavaScript) oraz drzewo tras:"),
("56-58", "Route path='/login', '/register', '/unauthorized'",
"Trasy publiczne dostępne bez logowania. Każda ładuje odpowiednią stronę."),
("61-95", "Route element={<ProtectedRoute />}",
"Trasy chronione ProtectedRoute sprawdza czy użytkownik jest zalogowany. Jeśli nie przekierowuje na /login. W środku AppShell (szablon z bocznym menu i nagłówkiem)."),
("63-76", "Route index, Route path='ScheduleOrder/:id', 'Products', 'Warehouse/...'",
"Strony dostępne dla zalogowanych pracowników: harmonogramy, produkty, magazyn i listy pakowe."),
("79-93", "Route element={<AdminRoute />}",
"Trasy tylko dla administratorów: zamówienia klienta, EDI, tłumaczenia, zarządzanie użytkownikami, harmonogram zadań."),
("97", "Route path='*'",
"Trasa 'złap wszystko' jeśli adres URL nie pasuje do żadnej trasy, wyświetla stronę 404."),
]),
# ═══════════════════════════════════════════════════════════════════
("WARSTWA API KOMUNIKACJA Z SERWEREM", None, None),
# ═══════════════════════════════════════════════════════════════════
("src/api/client.ts", "Bazowy klient HTTP fundament całej komunikacji z serwerem .NET", [
("1-4", "Importy",
"axios biblioteka do zapytań HTTP (jak fetch, ale z dodatkowymi funkcjami). Importuje stałe, sklep autoryzacji i system powiadomień."),
("7", "export type FieldErrors = Record<string, string[]>",
"Typ opisujący błędy walidacji pól formularza zwracane przez serwer (słownik: nazwa_pola → lista_błędów)."),
("9-19", "export class ApiError extends Error",
"Niestandardowa klasa błędu API. Przechowuje: message (treść błędu), status (kod HTTP np. 404), fieldErrors (błędy pól formularza z serwera). Używana w całej aplikacji do obsługi błędów."),
("25-32", "let navigator, registerNavigator, navigate",
"Mechanizm przekierowań: gdy Axios złapie błąd 401/403, musi przekierować użytkownika na /login lub /unauthorized. Nie może bezpośrednio używać React Router (jest poza komponentem), więc przechowuje referencję do funkcji navigate, którą rejestruje komponent NavigatorBridge."),
("34-37", "export const apiClient = axios.create(...)",
"Tworzy instancję Axios z domyślnym adresem serwera API (np. http://localhost:5001) i nagłówkiem Accept: application/json."),
("40-46", "apiClient.interceptors.request.use(...)",
"Interceptor żądań (uruchamiany przed KAŻDYM zapytaniem HTTP): pobiera token JWT ze sklepu i dołącza go do nagłówka Authorization: Bearer TOKEN. Dzięki temu każde zapytanie jest automatycznie autoryzowane."),
("52-60", "export function reqConfig(...)",
"Buduje obiekt konfiguracyjny dla Axios: opcjonalnie dodaje signal (do anulowania zapytania) i params (parametry URL np. ?id=123). Zwraca tylko zdefiniowane właściwości."),
("63-66", "export async function apiGet<T>(...)",
"Uproszczona funkcja GET: wysyła zapytanie i zwraca od razu dane (data) zamiast całego obiektu odpowiedzi Axios."),
("75-79", "function extractMessage(...)",
"Wyciąga czytelny komunikat błędu z odpowiedzi serwera: obsługuje zarówno JSON (ProblemDetails z .NET) jak i zwykły tekst."),
("82-120", "apiClient.interceptors.response.use(...)",
"Interceptor odpowiedzi centralna obsługa błędów HTTP:\n• 401 Unauthorized: wylogowuje użytkownika i przekierowuje na /login ('Sesja wygasła')\n• 403 Forbidden: przekierowuje na /unauthorized ('Brak uprawnień')\n• 422/400: błędy walidacji formularza\n• 5xx: błąd serwera pokazuje toast 'Błąd serwera'\n• status 0: brak połączenia z siecią"),
]),
("src/api/users.ts", "Funkcje API dla użytkowników i autoryzacji", [
("4-10", "export async function login(...)",
"Wysyła login i hasło do serwera POST /api/Users/login. Zwraca token JWT i datę wygaśnięcia."),
("12-18", "export interface RegisterPayload",
"Interfejs opisujący dane potrzebne do rejestracji: login, email, imię, nazwisko, hasło."),
("21-23", "export async function register(...)",
"Wysyła dane rejestracji do POST /api/Users/register. Hasło jest wysyłane jako tekst (szyfrowane po stronie serwera przez BCrypt)."),
("26-28", "export async function changePassword(...)",
"Zmienia hasło zalogowanego użytkownika. POST /api/Users/change-password. Serwer identyfikuje użytkownika z tokenu JWT."),
("30-32", "export async function getUsers(...)",
"Pobiera listę wszystkich użytkowników. GET /api/Users."),
("34-36", "export async function getUserByUsername(...)",
"Pobiera konkretnego użytkownika po nazwie loginu. GET /api/Users/by-username/?username=..."),
("38-40", "export async function addUser(...)",
"Dodaje nowego użytkownika. POST /api/Users."),
("42-52", "export interface TemporaryPasswordResponse, AdminCreateUserPayload",
"Typy dla operacji administratora: odpowiedź z tymczasowym hasłem oraz dane nowego użytkownika."),
("55-63", "export async function adminCreateUser(...)",
"Administrator tworzy nowego użytkownika. Serwer generuje tymczasowe hasło i zwraca je jednorazowo. POST /api/Users/admin-create."),
("66-72", "export async function adminResetPassword(...)",
"Administrator resetuje hasło użytkownika. Serwer generuje nowe tymczasowe hasło i zwraca je jednorazowo. POST /api/Users/admin-reset-password."),
("74-76", "export async function updateUser(...)",
"Aktualizuje dane użytkownika. PUT /api/Users."),
("78-80", "export async function deleteUser(...)",
"Usuwa użytkownika po jego identyfikatorze (rowPointer). DELETE /api/Users/?id=..."),
]),
("src/api/customerOrders.ts", "Funkcje API dla zamówień klientów (Syteline)", [
("4-6", "getCustomerOrders(signal?)",
"Pobiera listę wszystkich zamówień klientów z Syteline. GET /api/CustomerOrders. Signal to token anulowania gdy użytkownik opuści stronę, zapytanie jest przerywane."),
("8-16", "getCustomerOrderByNumber(customerOrderNumber, signal?)",
"Pobiera szczegóły jednego zamówienia po jego numerze (używane na stronie szczegółów zamówienia). GET /api/CustomerOrders/by-order-number/?customerOrderNumber=..."),
("19-27", "getCustomerOrderByCoNumber(customerOrderNumber, signal?)",
"Pobiera zamówienie po numerze CO z Syteline (używane przy tworzeniu listy pakowej). GET /api/CustomerOrders/by-co-number/?customerOrderNumber=..."),
]),
("src/api/ediCustomerOrders.ts", "Funkcje API dla zamówień EDI", [
("5-7", "getEdiCustomerOrders(signal?)",
"Pobiera wszystkie zamówienia EDI. GET /api/EdiCustomerOrders."),
("9-17", "getEdiCustomerOrderByNumber(customerOrderNumber, signal?)",
"Pobiera jedno zamówienie EDI po numerze. GET /api/EdiCustomerOrders/by-order-number/?customerOrderNumber=..."),
("25-44", "sendEdiOrderToSyteline(rowPointer, displayNumber)",
"Wysyła zamówienie EDI do systemu Syteline. POST /api/EdiCustomerOrders/send-to-syteline. Jeśli operacja się powiodła zwraca status=1 z numerem zamówienia. Jeśli błąd próbuje pobrać logi błędów z serwera i zwraca status=0 z wiadomością błędu."),
]),
("src/api/ediTranslations.ts", "Funkcje API dla powiązań zamówień EDI z Syteline", [
("4-11", "getEdiTranslations(signal?)",
"Pobiera listę wszystkich powiązań (mapowań) zamówień EDI z zamówieniami Syteline. GET /api/EdiCustomerOrdersTranslations."),
("13-15", "deleteEdiTranslation(id)",
"Usuwa konkretne powiązanie po ID. DELETE /api/EdiCustomerOrdersTranslations/?id=..."),
]),
("src/api/errorLog.ts", "Funkcje API dla logów błędów", [
("9-17", "getErrorLogs(customerOrderNumber, signal?)",
"Pobiera logi błędów dla danego zamówienia. UWAGA: zgodnie z komentarzem w kodzie, ta funkcja uderza w ten sam endpoint co zamówienia klientów to znany błąd z oryginalnej aplikacji Blazor, zachowany dla zachowania zgodności."),
]),
("src/api/functions.ts", "Funkcje API dla uprawnień/funkcji systemowych", [
("4-6", "getFunctions(signal?)", "Pobiera listę wszystkich funkcji systemowych. GET /api/Functions."),
("8-10", "addFunction(fn)", "Dodaje nową funkcję. POST /api/Functions."),
("12-14", "updateFunction(fn)", "Aktualizuje istniejącą funkcję. PUT /api/Functions."),
("16-18", "deleteFunction(id)", "Usuwa funkcję po ID. DELETE /api/Functions/?id=..."),
]),
("src/api/products.ts", "Funkcje API dla produktów", [
("4-9", "getProductsByIndex(indexName, signal?)",
"Wyszukuje produkty po indeksie/nazwie. GET /api/Product/by-index?indexName=... Używane na stronie Produkty."),
("11-13", "updateProduct(product)",
"Aktualizuje dane produktu (np. kod FA). PUT /api/Product."),
]),
("src/api/roles.ts", "Funkcje API dla ról użytkowników", [
("4-6", "getRoles(signal?)", "Pobiera listę wszystkich ról. GET /api/Roles."),
("8-10", "addRole(role)", "Dodaje nową rolę. POST /api/Roles."),
("12-14", "updateRole(role)", "Aktualizuje rolę. PUT /api/Roles."),
("16-18", "deleteRole(id)", "Usuwa rolę. DELETE /api/Roles/?id=..."),
]),
("src/api/scheduleOrders.ts", "Funkcje API dla harmonogramów zamówień (DELFOR)", [
("4-6", "getScheduleOrders(signal?)",
"Pobiera listę wszystkich harmonogramów DELFOR (zamówień cyklicznych). GET /api/ScheduleOrders."),
("8-13", "getScheduleOrder(scheduleOrderId, signal?)",
"Pobiera jeden harmonogram ze szczegółami (pozycje i daty). GET /api/ScheduleOrders/{id}."),
]),
("src/api/scheduler.ts", "Funkcje API dla harmonogramu zadań automatycznych (Hangfire)", [
("4-6", "getTaskSchedulers(signal?)", "Pobiera listę zaplanowanych zadań. GET /api/HangfireJobs/."),
("8-10", "addTaskScheduler(task)", "Dodaje nowe zadanie cykliczne. POST /api/HangfireJobs/add."),
("12-14", "updateTaskScheduler(task)", "Aktualizuje zadanie. POST /api/HangfireJobs/update."),
("16-18", "deleteTaskScheduler(task)", "Usuwa zadanie. POST /api/HangfireJobs/delete."),
]),
("src/api/warehouse.ts", "Funkcje API dla modułu magazynowego (WZ i listy pakowe)", [
("13-15", "getWzHeaderById(id, signal?)", "Pobiera nagłówek dokumentu WZ po ID (GUID). GET /api/WzHeader/by-id?id=..."),
("17-19", "getAllClients(signal?)", "Pobiera listę klientów magazynowych. GET /api/WzClient."),
("21-30", "getAllClientWzs(customerNumber, customerSequence, signal?)",
"Pobiera transakcje materiałowe (dokumenty WZ) dla konkretnego klienta. GET /api/WzHeader/by-customer-number."),
("32-41", "getAllClientWzHeaders(customerNumber, customerSequence, signal?)",
"Pobiera nagłówki list pakowych dla klienta. GET /api/WzHeader/all-wz-headers."),
("43-45", "createWzHeader(header)", "Tworzy nowy nagłówek dokumentu WZ. POST /api/WzHeader."),
("47-49", "addEmailsToWzHeader(id, emailAddresses)",
"Zapisuje adresy e-mail przy nagłówku WZ (do wysyłki). POST /api/WzHeader/add-emails?id=..."),
("51-59", "getCustomerOrder(customerOrderNumber, signal?)",
"Pobiera zamówienie klienta po numerze CO (używane przy tworzeniu wierszy WZ). GET /api/CustomerOrders/by-co-number."),
("61-67", "getItem(itemNumber, customerNumber, signal?)",
"Pobiera powiązanie pozycjaklient (numer klienta, EAN13, kod FA). GET /api/ItemCust."),
("69-87", "getWzRowsMeyleByHeaderId / getWzRowsMarelliByHeaderId",
"Pobierają wiersze listy pakowej dla Meyle lub Marelli po ID nagłówka."),
("89-95", "createWzRowsMeyle / createWzRowsMarelli",
"Tworzą nowe wiersze listy pakowej (Meyle: POST /api/WzRowMeyle, Marelli: POST /api/WzRowMarelli)."),
("97-103", "updateWzRowsMeyle / updateWzRowsMarelli",
"Aktualizują istniejące wiersze listy pakowej."),
("106-124", "getTransactionModels(signal?)",
"Pobiera transakcje materiałowe z numerem karty kontrolnej. Grupuje wyniki w słownik: klucz = numer_karty_kontrolnej → lista transakcji. Używane w skanerowaniu kart kontrolnych przy tworzeniu listy pakowej."),
("126-134", "generateXlsForMeyle / generateXlsForMarelli",
"Wywołuje generowanie pliku Excel dla listy pakowej Meyle lub Marelli na serwerze. GET /api/ExcelGenerator/generate-meyle lub generate-marelli."),
]),
# ═══════════════════════════════════════════════════════════════════
("MAGAZYN STANU (STORE) GLOBALNE DANE APLIKACJI", None, None),
# ═══════════════════════════════════════════════════════════════════
("src/store/authStore.ts", "Magazyn autoryzacji zarządza sesją zalogowanego użytkownika", [
("1-3", "Importy",
"zustand biblioteka do zarządzania globalnym stanem (lżejsza alternatywa dla Redux). persist middleware zapisujące stan w localStorage (dane przetrwają odświeżenie strony). jwtDecode dekoduje token JWT bez weryfikacji podpisu."),
("6-12", "interface JwtClaims",
"Opisuje pola dostępne w tokenie JWT: sub (ID użytkownika), unique_name (nazwa), exp (czas wygaśnięcia w Unix timestamp). Pole [claim: string]: unknown pozwala na dodatkowe niestandardowe pola."),
("14-19", "export interface AuthUser",
"Dane zalogowanego użytkownika dostępne w aplikacji: name (wyświetlana nazwa) i claims (surowe dane z tokenu JWT)."),
("21-28", "interface AuthState",
"Struktura stanu autoryzacji: token (JWT), user (dane użytkownika), isAuthenticated (bool), setToken (funkcja zapisu), logout (funkcja wylogowania)."),
("30-43", "function decode(token)",
"Prywatna funkcja dekodująca token JWT: sprawdza czy token nie wygasł (exp * 1000 < teraz?). Wyciąga nazwę użytkownika z kilku możliwych pól JWT. Zwraca null jeśli token nieważny lub wygasły."),
("51-82", "export const useAuthStore = create(...)(...)",
"Tworzy globalny magazyn stanu autoryzacji z persist (zapisywanie w localStorage pod kluczem 'authToken'). Zawiera: setToken zapisuje token, dekoduje go, jeśli nieważny czyści stan; logout czyści token i dane użytkownika; onRehydrateStorage przy starcie aplikacji weryfikuje zapisany token (czy nie wygasł)."),
("85-87", "export function getAuthToken()",
"Odczytuje token JWT poza komponentem React (używane przez interceptor Axios). Wzorzec: useAuthStore.getState().token."),
]),
("src/store/toastStore.ts", "Magazyn powiadomień (toastów) system komunikatów dla użytkownika", [
("3", "export type ToastVariant",
"Typ wariantu powiadomienia: success (zielony), error (czerwony), info (niebieski), warning (żółty)."),
("5-10", "export interface Toast",
"Struktura powiadomienia: id (unikalny identyfikator), variant (typ), title (tytuł), description (opcjonalny opis)."),
("18-30", "export const useToastStore = create(...)",
"Magazyn powiadomień: toasts lista aktywnych powiadomień; push dodaje nowe powiadomienie, generuje UUID, po 5 sekundach automatycznie je usuwa; dismiss usuwa powiadomienie po kliknięciu X."),
("32-42", "export const toast = { success, error, info, warning }",
"Imperatywne API do wyświetlania powiadomień poza komponentami React (np. w interceptorze Axios). Przykład: toast.error('Błąd serwera', 'Spróbuj ponownie później')."),
]),
("src/store/uiStore.ts", "Magazyn interfejsu użytkownika stan wizualny aplikacji", [
("5-14", "interface UiState",
"Stan UI: sidebarOpen (czy boczne menu jest otwarte na mobile), setSidebarOpen/toggleSidebar (kontrola menu), selectedWarehouseClientId (ostatnio wybrany klient w module magazynowym zapamiętywany w localStorage)."),
("16-30", "export const useUiStore = create(...)(...)",
"Magazyn z persist: selectedWarehouseClientId jest zapisywany w localStorage (użytkownik nie musi wybierać klienta przy każdej wizycie). Stan menu mobilnego NIE jest zapisywany (zawsze zamknięte po odświeżeniu)."),
]),
# ═══════════════════════════════════════════════════════════════════
("BIBLIOTEKA POMOCNICZA (LIB)", None, None),
# ═══════════════════════════════════════════════════════════════════
("src/lib/cn.ts", "Łączenie klas CSS", [
("2-4", "export function cn(...values)",
"Funkcja łącząca klasy CSS Tailwind. Przyjmuje dowolną liczbę argumentów (string lub false/null/undefined), filtruje wartości fałszywe i łączy pozostałe spacjami. Przykład: cn('px-4', isActive && 'bg-blue-600', undefined) → 'px-4 bg-blue-600'."),
]),
("src/lib/combinations.ts", "Algorytm znajdowania kombinacji sum (backtracking)", [
("6-33", "export function findCombinations<T>(records, targetSum, getQuantity)",
"Implementuje algorytm backtrackingu do znajdowania podzbiorów rekordów, których sumy ilości są równe zadanej wartości docelowej. Używany w skanerowaniu palet przy tworzeniu list pakowych. Port z oryginalnej funkcji Blazor FindCombinations. Parametry: records lista elementów, targetSum szukana suma, getQuantity funkcja pobierająca ilość z elementu. Zwraca tablicę tablic każda to zestaw elementów sumujący się do targetSum."),
]),
("src/lib/constants.ts", "Stałe konfiguracyjne aplikacji", [
("2-3", "API_BASE_URL",
"Adres bazowy API .NET. Pobiera wartość ze zmiennej środowiskowej VITE_API_BASE_URL (z pliku .env) lub używa domyślnego http://localhost:5001."),
("6", "AUTH_TOKEN_STORAGE_KEY = 'authToken'",
"Klucz localStorage do przechowywania tokenu JWT. Zachowany z oryginalnej aplikacji Blazor (Blazored.LocalStorage)."),
("9", "UI_STORAGE_KEY = 'fakrosno-ui'",
"Klucz localStorage do przechowywania ustawień UI (wybrany klient magazynu)."),
("15-16", "SYNCFUSION_LICENSE_KEY",
"Klucz licencyjny do biblioteki Syncfusion (tabele, siatki). Skopiowany z oryginalnej aplikacji Blazor."),
]),
("src/lib/format.ts", "Funkcje formatowania dat i liczb", [
("8-12", "parseApiDate(value)",
"Parsuje datę z formatu ISO 8601 (jak zwraca API .NET) do obiektu Date JavaScript. Zwraca null dla pustych wartości lub nieprawidłowych dat. Zawsze używaj tej funkcji zamiast new Date(str) dla dat z API."),
("15-18", "formatDate(value, pattern?)",
"Formatuje datę do wyświetlenia. Domyślny format: dd.MM.yyyy (np. 15.06.2024). Zwraca 'N/A' dla null/undefined."),
("21-24", "formatDateTime(value)",
"Formatuje datę z godziną: yyyy-MM-dd HH:mm:ss (np. 2024-06-15 14:30:00). Zwraca 'N/A' dla null."),
("27-29", "toApiDate(value)", "Konwertuje obiekt Date do formatu ISO 8601 do wysłania do API."),
("32-34", "toApiDateOnly(value)", "Konwertuje datę do formatu yyyy-MM-dd (tylko data, bez czasu)."),
("40-43", "formatDecimal(value, fractionDigits?)",
"Formatuje liczbę dziesiętną z zadaną precyzją (domyślnie 2 miejsca po przecinku). Używa biblioteki decimal.js, która eliminuje błędy zaokrąglania liczb zmiennoprzecinkowych. Zwraca 'N/A' dla null."),
]),
("src/lib/queryKeys.ts", "Klucze pamięci podręcznej zapytań HTTP (TanStack Query)", [
("2-21", "export const queryKeys = { ... }",
"Scentralizowany słownik kluczy cache dla wszystkich typów zapytań. TanStack Query identyfikuje zapytania po kluczu jeśli dwa miejsca w aplikacji używają tego samego klucza, dzielą jeden cache. Przykład: queryKeys.scheduleOrders → ['scheduleOrders'], queryKeys.scheduleOrder(5) → ['scheduleOrders', 5]. Pozwala łatwo unieważnić cache po operacjach zapisu (invalidateQueries)."),
]),
("src/lib/status.ts", "Tłumaczenie kodów statusów zamówień", [
("5-20", "export function translateStatus(status)",
"Tłumaczy skrótowe kody statusów Syteline na czytelne opisy po polsku: O→Zamówione, S→Zatrzymane, P→Planowane, C→Zakończone, F→Wypełnione. Funkcja pomocnicza po stronie klienta (serwer zazwyczaj zwraca już przetłumaczony status w polu translatedStatus)."),
]),
("src/lib/syncfusion.ts", "Inicjalizacja biblioteki tabel Syncfusion", [
("4", "let registered = false",
"Flaga zapobiegająca wielokrotnemu wywoływaniu inicjalizacji (singleton)."),
("7-37", "export function initSyncfusion()",
"Jednorazowo inicjalizuje Syncfusion: registerLicense rejestruje klucz licencyjny; setCulture('pl') ustawia język polski; L10n.load({ pl: {...} }) ładuje polskie tłumaczenia interfejsu siatek: 'Brak rekordów do wyświetlenia', 'Zapisz', 'Anuluj', etykiety stron itp."),
]),
# ═══════════════════════════════════════════════════════════════════
("ROUTER NAWIGACJA I OCHRONA TRAS", None, None),
# ═══════════════════════════════════════════════════════════════════
("src/router/navConfig.ts", "Konfiguracja elementów menu bocznego", [
("13-21", "export interface NavItem",
"Opisuje element menu: label (widoczna etykieta), to (docelowy URL), icon (ikona z biblioteki Lucide), admin (czy tylko dla administratora), end (czy dopasowanie URL musi być dokładne)."),
("23-32", "export const navItems: NavItem[]",
"Lista wszystkich pozycji menu: Harmonogramy (strona główna /), Produkty, Magazyn dla wszystkich użytkowników. Zamówienia klienta, Zamówienia EDI, Tłumaczenia, Użytkownicy, Harmonogram zadań tylko dla administratorów (admin: true)."),
]),
("src/router/NavigatorBridge.tsx", "Mostek łączący React Router z klientem API", [
("6-12", "export function NavigatorBridge()",
"Komponent renderujący pusty element (null nic nie widać na ekranie). Jego jedynym zadaniem jest zarejestrowanie funkcji navigate z React Router w kliencie Axios. Dzięki temu, gdy Axios złapie błąd 401, może przekierować użytkownika na /login bez pełnego przeładowania strony. Montowany raz w App.tsx."),
]),
("src/router/ProtectedRoute.tsx", "Ochrona tras przed nieautoryzowanym dostępem", [
("5-15", "function getRoles()",
"Wyciąga role użytkownika z zdekodowanego tokenu JWT. Obsługuje zarówno pojedynczą rolę (string) jak i tablicę ról (string[]). Szuka w standardowym polu Microsoft JWT lub w polu role/roles."),
("17-24", "function isAdmin()",
"Sprawdza czy użytkownik ma rolę 'admin' lub 'administrator'. Jeśli JWT nie zawiera żadnych ról (stary format API), zakłada że użytkownik jest adminem (dla wstecznej zgodności z Blazorem)."),
("27-34", "export function ProtectedRoute()",
"Komponent chroniący trasy: sprawdza isAuthenticated ze sklepu. Jeśli nie zalogowany przekierowuje na /login, zapamiętując obecny adres URL (by po zalogowaniu wrócić). Jeśli zalogowany renderuje Outlet (zawartość trasy)."),
("37-47", "export function AdminRoute()",
"Komponent chroniący trasy administracyjne: wymaga zarówno zalogowania jak i roli admina. Jeśli brak zalogowania /login. Jeśli brak uprawnień /unauthorized."),
]),
# ═══════════════════════════════════════════════════════════════════
("KOMPONENTY INTERFEJSU UŻYTKOWNIKA", None, None),
# ═══════════════════════════════════════════════════════════════════
("src/components/ErrorBoundary.tsx", "Komponent chwytający błędy React (Error Boundary)", [
("13-44", "export class ErrorBoundary extends Component<Props, State>",
"Klasowy komponent React (jedyna forma obsługi błędów w React). getDerivedStateFromError przechwytuje błąd i zapisuje go w stanie. componentDidCatch loguje błąd do konsoli. render jeśli wystąpił błąd: pokazuje 'Coś poszło nie tak' z przyciskami 'Odśwież stronę' i 'Spróbuj ponownie'. Jeśli nie ma błędu renderuje children (normalna zawartość). Zamontowany na szczycie drzewa komponentów w main.tsx oraz wewnątrz AppShell dla każdej strony."),
]),
("src/components/layout/AppShell.tsx", "Główny szablon aplikacji po zalogowaniu", [
("8-24", "export function AppShell()",
"Buduje układ strony: flex h-screen (zajmuje pełen ekran). Sidebar (boczne menu) + div z Header (nagłówek) i main (treść strony). Wewnątrz main: ErrorBoundary + Suspense (z animacją ładowania PageSkeleton) + Outlet (tu renderuje się aktualna strona)."),
]),
("src/components/layout/Header.tsx", "Górny pasek nawigacyjny", [
("7-45", "export function Header()",
"Pasek nagłówka: po lewej przycisk hamburger (tylko na mobile, toggle bocznego menu) + tytuł 'System zarządzania zamówieniami'. Po prawej ikona użytkownika z jego nazwą + przycisk 'Wyloguj' (wywołuje logout() i przekierowuje na /login)."),
]),
("src/components/layout/PageHeader.tsx", "Nagłówek strony z tytułem i akcjami", [
("3-21", "export function PageHeader({ title, description, actions })",
"Prosty komponent nagłówka strony: tytuł (h1), opcjonalny opis (p), opcjonalne przyciski akcji (np. 'Powrót', 'Nowy'). Responsywny: na mobile wierszowo, na desktop kolumnowo z przyciskami po prawej."),
]),
("src/components/layout/Sidebar.tsx", "Boczny panel nawigacyjny", [
("6-82", "export function Sidebar()",
"Sidebar wyświetla listę pozycji menu z navConfig.ts. Dzieli je na dwie sekcje: 'Główne' i 'Administracja'. Każda pozycja to NavLink automatycznie dodaje klasę bg-blue-600 gdy URL pasuje (aktywna strona). Na desktop: stały panel 256px. Na mobile: wysuwana szuflada (fixed, full-height) z ciemnym tłem kliknięcie tła zamyka menu."),
]),
("src/components/ui/Badge.tsx", "Komponent etykietki/plakietki", [
("14-25", "export function Badge({ tone, children })",
"Wyświetla małą zaokrągloną plakietkę z tekstem. Pięć kolorów (tone): gray, blue, green, red, amber. Przykład użycia: <Badge tone='blue'>5</Badge> niebieska plakietka z liczbą 5 (używana przy liczbie pozycji harmonogramu)."),
]),
("src/components/ui/Button.tsx", "Komponent przycisku", [
("8-12", "export interface ButtonProps",
"Rozszerzenie standardowych właściwości przycisku HTML o: variant (styl), size (rozmiar), loading (stan ładowania pokazuje kółko obrotu)."),
("14-20", "variantClasses, sizeClasses",
"Słowniki klas CSS dla każdego wariantu i rozmiaru. Warianty: primary (niebieski), secondary (biały z ramką), danger (czerwony), success (zielony), ghost (przezroczysty)."),
("28-50", "export const Button = forwardRef(...)",
"Komponent przycisku z forwardRef (można przekazać ref do DOM). Gdy loading=true: wyłącza kliknięcie i pokazuje obracającą się ikonę Loader2. Łączy klasy CSS na podstawie wariantu i rozmiaru."),
]),
("src/components/ui/Card.tsx", "Komponenty karty/panelu", [
("4-30", "Card, CardHeader, CardTitle, CardContent, CardFooter",
"Zestaw komponentów tworzących kartę: Card kontener z zaokrąglonymi rogami i cieniem. CardHeader nagłówek z dolną linią. CardTitle niebieski tytuł h2. CardContent obszar treści z paddingiem. CardFooter stopka z górną linią (np. '© 2024 FA Krosno')."),
]),
("src/components/ui/EmptyState.tsx", "Komponent pustego stanu", [
("4-25", "export function EmptyState({ icon, title, message, action })",
"Wyświetlany gdy lista danych jest pusta lub wystąpił błąd ładowania. Pokazuje ikonę (domyślnie skrzynkę Inbox), tytuł i opcjonalny opis. Przykład: 'Brak harmonogramów', 'Nie ma jeszcze żadnych zamówień'."),
]),
("src/components/ui/Input.tsx", "Komponent pola tekstowego formularza", [
("9-43", "export const Input = forwardRef(...)",
"Pole tekstowe z automatycznym ID (useId), etykietą i obsługą błędu walidacji. Gdy error jest podany: czerwona ramka + komunikat błędu + aria-describedby (dostępność). Używany w formularzach logowania, rejestracji itp."),
]),
("src/components/ui/Modal.tsx", "Komponent okna modalnego (dialogu)", [
("17-93", "export function Modal({ open, onClose, title, children, footer })",
"Okno modalne z pełną obsługą dostępności: pułapka focusu (Tab/Shift+Tab porusza tylko wewnątrz modala), Escape zamyka, kliknięcie tła zamyka. Po zamknięciu focus wraca do poprzedniego elementu. Renderowany przez createPortal (poza normalnym drzewem DOM). Zawiera: nagłówek z tytułem i X, treść (children), stopkę z przyciskami."),
]),
("src/components/ui/Skeleton.tsx", "Komponenty szkieletowego ładowania (loading state)", [
("3-10", "Skeleton", "Animowany szary prostokąt (pulsowanie) placeholder dla ładujących się treści."),
("13-23", "GridSkeleton({ rows })", "Skeleton tabeli: nagłówek + N wierszy. Wyświetlany podczas ładowania danych do siatek."),
("26-34", "PageSkeleton()", "Skeleton całej strony: nagłówek + duży blok treści. Używany jako fallback Suspense w AppShell."),
("38-49", "FullPageSpinner()", "Kółko obrotu na pełnym ekranie używany gdy lazy-loaded strony się wczytują (przed AppShell)."),
]),
("src/components/ui/Toaster.tsx", "Komponent wyświetlający powiadomienia", [
("5-10", "const config",
"Słownik konfiguracji dla każdego wariantu powiadomienia: ikona i kolory tła/tekstu."),
("12-51", "export function Toaster()",
"Kontener powiadomień: stały, w prawym dolnym rogu (fixed bottom-4 right-4). Nasłuchuje na zmiany w toastStore. Każde powiadomienie: ikona + tytuł + opis + przycisk X. Znika automatycznie po 5 sekundach lub po kliknięciu X."),
]),
# ═══════════════════════════════════════════════════════════════════
("TYPY DANYCH API", None, None),
# ═══════════════════════════════════════════════════════════════════
("src/types/api/common.ts", "Wspólne typy opakowań odpowiedzi API", [
("7-12", "interface PagedResult<T>",
"Wynik stronicowania: items (lista elementów), totalCount (łączna liczba), pageNumber, pageSize."),
("14-19", "interface ApiResponse<T>",
"Ogólna odpowiedź API: data (dane lub null), success (bool), message (komunikat), errors (błędy walidacji)."),
("21-24", "interface ValidationError", "Błąd walidacji: field (pole formularza) i message (treść błędu)."),
("26", "type SortOrder = 'asc' | 'desc'", "Typ kierunku sortowania."),
("29", "type OrderStatusCode",
"Typ kodów statusów zamówień Syteline: O, S, P, C, F lub dowolny inny string."),
]),
("src/types/api/faKrosno.ts", "Typy DTO z modelu danych FaKrosno (EF)", [
("8-12", "PurchaserDto", "Nabywca: id, purchaserCode (kod nabywcy), purchaserDesc (opis)."),
("15-21", "RecipientDto", "Odbiorca: id, purchaserID (powiązany nabywca), recipientCode, recipientDesc. Zawiera zagnieżdżony obiekt PurchaserDto."),
("24-31", "ProductDto", "Produkt: id, recipientID, recipientIdx (indeks odbiorcy), faIdx (indeks FA), recipientName. Zawiera RecipientDto."),
("34-42", "ScheduleOrderMiscDto", "Dodatkowe dane harmonogramu: type, value, label, display (czy wyświetlać). Pole text = '{Label}: {Value}' generowane przez serwer."),
("44-64", "ScheduleOrderDetailMiscDto, ScheduleOrderDetailDetailMiscDto",
"Analogiczne dodatkowe dane dla pozycji i szczegółów pozycji harmonogramu."),
("67-81", "ScheduleOrderDetailDetailDto", "Szczegół pozycji harmonogramu DELFOR: ilość (qty), zakres dat (dateFromdateTo), typ harmonogramu (sccType), status, data wysyłki."),
("87-99", "ScheduleOrderDetailDto", "Pozycja harmonogramu: kody produktów (SC/SH), numer zamówienia, odbiorca/nabywca. Zawiera tablicę ScheduleOrderDetailDetailDto."),
("105-120", "ScheduleOrderDto", "Pełny harmonogram DELFOR: numer PO, identyfikator zamówienia, odbiorca, data aktualizacji, typ dokumentu. Zawiera pozycje i dane miscellaneous."),
]),
("src/types/api/syteline.ts", "Typy DTO z modelu danych Syteline (ERP)", [
("9-39", "CustomerOrderLineItemDto",
"Pozycja-zwolnienie zamówienia klienta z Syteline: numery CO/linia/zwolnienie, pozycja, ilość, cena, koszt, kody EDI, daty (due/release/promise)."),
("42-64", "CustomerOrderLineDto",
"Linia zamówienia: pozycja, łączna ilość (blanketQty), ceny, daty, kody EDI dla adresu/typu pudełka/przeznaczenia. Zawiera tablicę CustomerOrderLineItemDto."),
("67-94", "CustomerOrderDto",
"Pełne zamówienie klienta: numer CO, klient, kontakt, data, status, magazyn, kody EDI (gate/recipient/seller/sender/buyer). Zawiera linie i tłumaczenia EDI."),
("97-124", "EdiCustomerOrderLineItemDto",
"Pozycja-zwolnienie zamówienia EDI: podobna do CustomerOrderLineItemDto ale z polami EDI-specific (routingCode, deliveryCallNumber, unloadingPoint, palletCode, palletNumber)."),
("127-146", "EdiCustomerOrderLineDto", "Linia zamówienia EDI: pozycja, ilość, cena, typ pudełka, adres, przeznaczenie."),
("149-177", "EdiCustomerOrderDto",
"Pełne zamówienie EDI: numer, klient, daty, status, kody EDI, slOrderNumber (numer w Syteline po wysłaniu), sentToSl ('TAK'/'NIE')."),
("183-197", "EdiCustomerOrderTranslateDto",
"Powiązanie (tłumaczenie) zamówienia EDI z zamówieniem Syteline i harmonogramem DELFOR: coEdiOrder, coRowPointer, coCoNum (nr Syteline), ediCoCoNum (nr EDI)."),
("200-212", "ErrorLogDto", "Log błędu z Syteline: numer transakcji, numer błędu, komunikat, daty."),
("215-227", "WzRowMeyleDto", "Wiersz listy pakowej Meyle: ID, nagłówek, numer zamówienia, pozycja, ilość, numer palety, nr WZ, numer części."),
("230-242", "WzRowMarelliDto", "Wiersz listy pakowej Marelli: ID, nagłówek, typ, pozycja, numer inżyniera, ilość, numer zamówienia."),
("245-253", "WzHeaderDto", "Nagłówek dokumentu WZ: ID, klient, data, adresy email, numery WZ. Zawiera wiersze Meyle i Marelli."),
("256-264", "WzClientDto", "Klient magazynowy: ID, numer klienta, sekwencja, nazwa, skrócona nazwa, logo Base64."),
("267-280", "MaterialTransactionDto", "Transakcja materiałowa z Syteline: grupa, numer transakcji, pozycja, data, ilość, magazyn, numer zamówienia, numer karty kontrolnej."),
("283-292", "ItemCustDto", "Powiązanie pozycjaklient: numer pozycji FA, numer klienta, numer pozycji klienta (custItem), kod EAN13."),
]),
("src/types/api/ordersManagement.ts", "Typy DTO z modelu zarządzania zamówieniami", [
("8-12", "RoleDto", "Rola użytkownika: id, name (nazwa), rowPointer (GUID identyfikator)."),
("15-20", "FunctionDto", "Funkcja systemowa (uprawnienie): id, roleId (do jakiej roli należy), name, rowPointer."),
("26-30", "UserRoleDto", "Powiązanie użytkownikrola: userId, roleId, rowPointer."),
("37-55", "UserDto",
"Pełny obiekt użytkownika systemu: login, passwordHash (UWAGA BEZPIECZEŃSTWA: serwer nie powinien zwracać hasha hasła), isTemporaryPassword, isActive, daty aktywności, email, imię, nazwisko, data utworzenia, liczba nieudanych logowań, blokada konta, lista ról."),
("61-67", "TaskSchedulerDetailDto", "Wpis historii uruchomień zadania: data uruchomienia (jobRunDate), log (treść logu)."),
("70-82", "TaskSchedulerDto", "Zaplanowane zadanie automatyczne: nazwa, ścieżka, wyrażenie cron (cronOptions), daty aktywności, ostatnie i następne uruchomienie, historia uruchomień."),
]),
("src/types/api/local.ts", "Lokalne typy modeli odpowiedzi API", [
("7-10", "LoginResponseDto", "Odpowiedź na żądanie logowania: token (JWT lub null) i expires (data wygaśnięcia)."),
("13-18", "ResponseModel", "Ogólny model odpowiedzi: status (1=sukces, 0=błąd), identifier (numer zamówienia), message (wiadomość błędu), externalIdentifier (zewnętrzny numer)."),
("24-28", "TransactionModel", "Model wartościowy dla skanowania kart kontrolnych: numer części, numer pozycji, ilość."),
]),
# ═══════════════════════════════════════════════════════════════════
("FUNKCJONALNOŚCI (FEATURES) STRONY APLIKACJI", None, None),
# ═══════════════════════════════════════════════════════════════════
("src/features/auth/schemas/login.schema.ts", "Schemat walidacji formularza logowania", [
("3-6", "loginSchema = z.object(...)",
"Schemat Zod walidujący dane logowania: login (wymagany, min 1 znak), password (wymagany, min 1 znak). Biblioteka Zod automatycznie generuje błędy walidacji po polsku."),
("8", "type LoginFormValues = z.infer<typeof loginSchema>",
"TypeScript automatycznie wyprowadza typ z schematu Zod nie trzeba pisać interfejsu osobno."),
]),
("src/features/auth/schemas/register.schema.ts", "Schemat walidacji formularza rejestracji", [
("3-15", "registerSchema = z.object(...).refine(...)",
"Schemat rejestracji: login (1-50 znaków), email (prawidłowy adres, 1-100 znaków), firstName/lastName (1-50 znaków), password (6-100 znaków), confirmPassword. Warunek dodatkowy (refine): password musi być identyczne z confirmPassword jeśli nie, błąd 'Hasła nie są zgodne' pojawia się przy polu confirmPassword."),
]),
("src/features/auth/schemas/changePassword.schema.ts", "Schemat walidacji zmiany hasła", [
("3-11", "changePasswordSchema = z.object(...).refine(...)",
"Schemat zmiany tymczasowego hasła: newPassword (6-100 znaków) i confirmPassword z weryfikacją zgodności."),
]),
("src/features/auth/hooks/useLogin.ts", "Hook logiki logowania", [
("25-73", "export function useLogin(onAuthenticated)",
"Niestandardowy hook React zarządzający procesem logowania. Używa useMutation z TanStack Query. Kroki: 1) Wywołuje loginRequest (POST /api/Users/login). 2) Jeśli sukces zapisuje token JWT w authStore (setToken). 3) Pobiera dane użytkownika (getUserByUsername) by sprawdzić czy ma tymczasowe hasło. 4) Jeśli tymczasowe ustawia requiresPasswordChange=true (pojawia się formularz zmiany hasła). 5) Jeśli normalne wywołuje onAuthenticated() (przekierowanie na właściwą stronę). Mapuje błędy API na czytelne komunikaty po polsku."),
]),
("src/features/auth/hooks/useRegister.ts", "Hook logiki rejestracji", [
("11-26", "export function useRegister(onSuccess)",
"Hook obsługujący rejestrację konta. Wysyła dane do POST /api/Users/register. Po sukcesie: pokazuje toast 'Konto utworzone, możesz się zalogować' i wywołuje onSuccess() (przekierowanie na /login)."),
]),
("src/features/auth/hooks/useChangeTemporaryPassword.ts", "Hook zmiany tymczasowego hasła", [
("19-45", "export function useChangeTemporaryPassword(onDone)",
"Hook obsługujący pierwszą zmianę hasła po zalogowaniu z hasłem tymczasowym. Wysyła nowe hasło do POST /api/Users/change-password (serwer identyfikuje użytkownika z tokenu JWT). Po sukcesie: pokazuje toast 'Hasło zmienione', wylogowuje użytkownika (logout()), wywołuje onDone() (powrót do /login)."),
]),
("src/features/auth/components/LoginPage.tsx", "Strona logowania", [
("22-101", "export default function LoginPage()",
"Strona logowania. Używa react-hook-form z zodResolver (walidacja przez loginSchema). Sprawdza czy użytkownik jest już zalogowany (wtedy przekierowuje). Jeśli requiresPasswordChange pokazuje ChangePasswordView zamiast formularza logowania. Formularz: pola Login i Hasło, przycisk 'Zaloguj się' z animacją ładowania, komunikat błędu (np. 'Nieprawidłowy login lub hasło'), link do rejestracji."),
("105-167", "function ChangePasswordView()",
"Ekran wyświetlany przy pierwszym logowaniu gdy hasło jest tymczasowe. Formularz: pola 'Nowe hasło' i 'Potwierdź hasło', przycisk 'Zmień hasło'. Po pomyślnej zmianie wylogowanie i powrót do /login."),
]),
("src/features/auth/components/RegisterPage.tsx", "Strona rejestracji", [
("12-94", "export default function RegisterPage()",
"Strona rejestracji nowego konta. Formularz z polami: Login, Email, Imię, Nazwisko, Hasło, Powtórz hasło. Walidacja przez registerSchema (Zod). Po pomyślnej rejestracji: przekierowanie na /login. Wyświetla błędy z serwera (np. 'Login jest zajęty')."),
]),
("src/features/auth/components/UnauthorizedPage.tsx", "Strona braku dostępu (403)", [
("4-17", "export default function UnauthorizedPage()",
"Prosta strona wyświetlana gdy użytkownik nie ma uprawnień do zasobu (403). Ikona tarczy z wykrzyknikiem, tekst 'Brak dostępu' i link powrotu do strony głównej."),
]),
("src/features/auth/components/NotFoundPage.tsx", "Strona 404", [
("4-15", "export default function NotFoundPage()",
"Strona 404 wyświetlana gdy adres URL nie istnieje. Ikona kompasu, tekst '404 Nie znaleziono' i link powrotu do strony głównej."),
]),
("src/features/schedule-orders/ScheduleOrdersPage.tsx", "Strona listy harmonogramów DELFOR", [
("12", "const FILTER_STORAGE_KEY = 'scheduleOrdersGridFilter'",
"Klucz localStorage do zapisywania filtrów tabeli użytkownik nie traci ustawionych filtrów po odświeżeniu."),
("13-20", "const query = useQuery(...)",
"Pobiera listę harmonogramów z API. select sortuje od najnowszych (malejąco wg lastUpdateDate)."),
("22-48", "return (...)",
"Warunkowe renderowanie: ładowanie → GridSkeleton (animacja), błąd → EmptyState 'Błąd ładowania', brak danych → EmptyState 'Brak harmonogramów', dane → Card z ScheduleOrdersTreeGrid."),
]),
("src/features/schedule-orders/ScheduleOrdersTreeGrid.tsx", "Trzypoziomowa tabela harmonogramów DELFOR", [
("73-115", "function DetailDetailGrid(detail)",
"Poziom 3 tabela szczegółów-szczegółów harmonogramu. Kolumny: daty Od/Do, ilość, typ qty, opis, status. Wiersze z typem qty w liście HIGHLIGHT_QTY_TYPES (54, 83, 84) są podświetlone na czerwono. Dwuklik nawiguje do szczegółów harmonogramu."),
("122-179", "function ScheduleOrderDetailsGrid(order)",
"Poziom 2 tabela pozycji harmonogramu. Leniwie ładuje pełne dane harmonogramu (useQuery) dopiero gdy użytkownik rozwinie wiersz. Kopiuje dane nagłówkowe (numer PO, odbiorca) na każdą pozycję (zachowanie z Blazora). Zagnieżdża DetailDetailGrid."),
("197-337", "export function ScheduleOrdersTreeGrid({ data, pageSize, enableFilterPersistence, filterStorageKey })",
"Główna trzypoziomowa tabela: Poziom 1 (główna lista) z funkcjami: stronicowanie, sortowanie, filtrowanie Excel, eksport do XLS, dwuklik → strona szczegółów. Opcjonalne: przyciski 'Zapisz filtry' / 'Usuń filtry' zapisujące stan filtrów w localStorage. getMasterWidget() dostęp do instancji widgetu EJ2 przez DOM (bo React ref zwraca tylko props, nie stan runtime). readLiveFilterColumns() serializuje aktywne filtry (z wykluczeniem cyklicznych referencji). handleDataBound() przywraca zapisane filtry po załadowaniu danych."),
]),
("src/features/schedule-orders/ScheduleOrderDetailPage.tsx", "Strona szczegółów harmonogramu", [
("27-60", "function DetailDetailGrid(props)",
"Tabela szczegółów pozycji: ilość, daty Od/Do, typ SCC, typ ilości, status, data wysyłki."),
("62-139", "export default function ScheduleOrderDetailPage()",
"Strona szczegółów harmonogramu DELFOR: odczytuje ID z parametru URL, pobiera dane (getScheduleOrder). Wyświetla: karta informacyjna (kod odbiorcy, odbiorca, nabywca, data aktualizacji) + tabela pozycji z możliwością rozwinięcia do szczegółów-szczegółów."),
]),
("src/features/products/ProductsPage.tsx", "Strona zarządzania produktami", [
("30-35", "editSettings, toolbar, pageSettings",
"Konfiguracja tabeli Syncfusion: tryb edycji wiersz-po-wierszu (Normal), pasek narzędzi z przyciskami Edytuj/Aktualizuj/Anuluj/Szukaj, 10 wierszy na stronę."),
("39", "const DEFAULT_INDEX = 'Uzupelnij'",
"Domyślny indeks wyszukiwania: 'Uzupelnij' pokazuje produkty czekające na przypisanie kodu FA (zachowanie z Blazora)."),
("41-58", "const query = useQuery(...), const mutation = useMutation(...)",
"Pobieranie produktów po indeksie (searchTerm). mutation zapisuje zaktualizowany produkt na serwerze i odświeża cache."),
("60-128", "return (...)",
"Formularz wyszukiwania: pole tekstowe + przycisk 'Szukaj'. Tabela z edycją inline: ID (tylko do odczytu), Odbiorca (tylko do odczytu), Indeks odbiorcy (tylko do odczytu), Kod FA (edytowalny). Po kliknięciu 'Aktualizuj' w tabeli mutation.mutate() wysyła zmianę do API."),
]),
("src/features/warehouse/WarehousePage.tsx", "Strona modułu magazynowego", [
("51-57", "function clientFlavor(client)",
"Funkcja rozpoznająca typ klienta ('meyle' lub 'marelli') na podstawie nazwy. Używana do wyboru odpowiedniego formatu listy pakowej i ścieżki URL."),
("70-131", "Zapytania i dane",
"clientsQuery lista klientów magazynowych (filtrowana do MAGNETI MARELLI i MEYLE). wzDocsQuery transakcje materiałowe (dokumenty WZ) dla wybranego klienta. headersQuery istniejące listy pakowe klienta. wzDocuments zdeduplikowana lista WZ (jedna pozycja na grupę, zachowanie z Blazora GroupBy)."),
("132-221", "const create = useMutation(...)",
"Główna logika tworzenia listy pakowej: 1) Sprawdza czy zaznaczono wiersze w tabeli. 2) Sprawdza czy nie istnieje już lista dla tych numerów WZ. 3) Tworzy nagłówek WzHeader z losowym UUID. 4) Dla każdej zaznaczonej transakcji: pobiera zamówienie klienta i dane pozycji. 5) Tworzy wiersze Meyle lub Marelli (zależnie od klienta). 6) Przekierowuje na stronę listy pakowej."),
("228-361", "return (...)",
"Interfejs strony: dropdown wyboru klienta → tabela WZ z checkboxami (wielokrotny wybór) → przycisk 'Utwórz Packing List' → tabela istniejących list pakowych z przyciskiem 'Otwórz'. Okna modalne dla błędów: 'Zaznacz przynajmniej jeden rekord' i 'Dla zaznaczonego rekordu istnieje już Packing List'."),
]),
("src/features/warehouse/packing-lists/PackListShell.tsx", "Wspólny szablon strony listy pakowej", [
("23-102", "export function PackListShell({ title, header, toggleTo, toggleLabel, onGenerateXls, children })",
"Szablon używany przez wszystkie cztery strony list pakowych (Meyle pełna/uproszczona, Marelli pełna/uproszczona). Zawiera: nagłówek strony z numerami WZ, link przełączający widok pełny/uproszczony, przycisk 'Generuj XLS' (wywołuje onGenerateXls), pole adresów e-mail z zapisywaniem (addEmailsToWzHeader), slot children (tu renderuje się właściwa tabela). Inicjalizuje pole email z danych nagłówka (render-phase reset pattern)."),
]),
("src/features/warehouse/packing-lists/MeylePackListPage.tsx", "Lista pakowa Meyle widok pełny (edytowalny)", [
("40-121", "export default function MeylePackListPage()",
"Edytowalna lista pakowa dla klienta Meyle. Pobiera nagłówek WZ (headerQuery) i wiersze (rowsQuery). Inicjalizuje lokalny stan rows z danych serwera. save zapisuje wszystkie wiersze: PUT jeśli już istniały (aktualizacja), POST jeśli nowe (tworzenie). Tabela z pełną edycją: nr palety, nr zamówienia, nr pozycji, indeks FA, nr części SL, ilość, nr WZ."),
]),
("src/features/warehouse/packing-lists/MeylePackListSimplePage.tsx", "Lista pakowa Meyle widok uproszczony (tylko odczyt)", [
("21-63", "export default function MeylePackListSimplePage()",
"Uproszczony podgląd listy pakowej Meyle (tylko do odczytu). Pokazuje tylko: nr palety, nr pozycji, indeks FA, ilość. Używa tego samego PackListShell (z przyciskiem 'Generuj XLS' i adresami email)."),
]),
("src/features/warehouse/packing-lists/MarelliPackListPage.tsx", "Lista pakowa Marelli widok pełny (edytowalny)", [
("40-116", "export default function MarelliPackListPage()",
"Edytowalna lista pakowa dla klienta Marelli. Analogiczna do MeylePackListPage, ale z kolumnami: typ, nr inżyniera zamiast nr części SL. Zapisuje przez PUT (update) lub POST (create)."),
]),
("src/features/warehouse/packing-lists/MarelliPackListSimplePage.tsx", "Lista pakowa Marelli widok uproszczony", [
("21-63", "export default function MarelliPackListSimplePage()",
"Uproszczony podgląd listy pakowej Marelli (tylko odczyt): nr palety, nr pozycji, indeks FA, ilość."),
]),
("src/features/customer-orders/CustomerOrdersPage.tsx", "Strona listy zamówień klientów (Syteline)", [
("33-40", "function Field({ label, value })",
"Mały pomocniczy komponent: wyświetla etykietę z podkreśleniem i wartość pogrubioną. Odwzorowanie Blazor <u>label:</u> <b>value</b>."),
("43-80", "export function CustomerOrderSummary(order)",
"Rozwijany szczegół zamówienia (detail template tabeli): dwa kolumny informacji o zamówieniu: numer CO, numer PO klienta, klient, odbiorca, kontakt, data, warunki, wartość, status, magazyn, kody EDI."),
("82-139", "export default function CustomerOrdersPage()",
"Lista zamówień klientów posortowana od najnowszych. Tabela: numer zamówienia, nr klienta, odbiorca, data, status. Dwuklik → strona szczegółów zamówienia. Rozwinięcie wiersza → CustomerOrderSummary."),
]),
("src/features/customer-orders/CustomerOrderDetailPage.tsx", "Strona szczegółów zamówienia klienta", [
("49-78", "function CustomerOrderLineDetail(line)",
"Szczegóły linii zamówienia: numer CO, linia, pozycja, opis, ilość, cena, daty, kody EDI pudełka."),
("81-114", "function CustomerOrderLineItemDetail(item)",
"Szczegóły zwolnienia (line item): numer CO, linia, zwolnienie, pozycja, ilość, cena, daty, magazyn, kody EDI."),
("116-295", "export default function CustomerOrderDetailPage()",
"Strona szczegółów: podsumowanie zamówienia (CustomerOrderSummary) + przycisk 'Pokaż powiązane DELFOR' (lazy-load harmonogramów powiązanych przez EDI translates) + tabela linii zamówienia (z rozwinięciem do szczegółów) + tabela harmonogramów (po kliknięciu linii, z rozwinięciem do szczegółów harmonogramów). Powiązane harmonogramy filtrowane po ID z ediCustomerOrderTranslates."),
]),
("src/features/edi-customer-orders/EdiCustomerOrdersPage.tsx", "Strona listy zamówień EDI", [
("52-87", "export function EdiOrderSummary(order)",
"Rozwijany panel szczegółów zamówienia EDI: dwie kolumny z numerami, datami, statusem, kodami EDI (gate, recipient, sender, seller, buyer)."),
("89-243", "export default function EdiCustomerOrdersPage()",
"Lista zamówień EDI z filtrowaniem (domyślnie tylko niezaksięgowane, poster=0). Checkbox 'Pokaż wszystkie' przełącza widok. Wielokrotny wybór wierszy (checkboxy). Przycisk 'Księguj zaznaczone' uruchamia handleSend. handleSend: sekwencyjnie wysyła każde zaznaczone zamówienie do Syteline, zbiera wyniki, wyświetla okno modalne z sukcesami (zielone) i błędami (czerwone)."),
]),
("src/features/edi-customer-orders/EdiCustomerOrderDetailPage.tsx", "Strona szczegółów zamówienia EDI", [
("45-71", "function EdiLineDetail(line)",
"Szczegóły linii zamówienia EDI: numer, pozycja, opis, ilość, cena, BoxType, adres, przeznaczenie."),
("73-107", "function EdiLineItemDetail(item)",
"Szczegóły zwolnienia EDI: wszystkie pola pozycji + kody EDI (routing, delivery, unloading, destination, pallet)."),
("109-237", "export default function EdiCustomerOrderDetailPage()",
"Strona szczegółów zamówienia EDI: podsumowanie (EdiOrderSummary) + tabela linii (z rozwinięciem) + tabela harmonogramów po wybraniu linii."),
]),
("src/features/translations/TranslationsPage.tsx", "Strona zarządzania powiązaniami zamówień EDI z DELFORami", [
("40-135", "export default function TranslationsPage()",
"Tabela powiązań zamówień EDI z Syteline i DELFORami: DELFOR Id, numer EDI, numer SL, nr PO, liczba zamówień, czy znaleziono, data. Każdy wiersz ma przycisk 'Usuń'. Kliknięcie 'Usuń' → modal potwierdzenia → deleteEdiTranslation → odświeżenie listy."),
]),
("src/features/users/UsersManagerPage.tsx", "Strona zarządzania użytkownikami (zakładki)", [
("57-93", "export default function UsersManagerPage()",
"Strona z trzema zakładkami (tab navigation): Użytkownicy, Role, Funkcje. Przełącza między komponentami UsersGrid, RolesGrid, FunctionsGrid."),
("96-210", "function UsersGrid()",
"Tabela użytkowników z pełną edycją CRUD: kolumny: ID, Login, Email, Imię, Nazwisko, Aktywny, Data utworzenia + przycisk 'Zresetuj hasło'. Nowy wiersz bez ID → adminCreateUser (generuje tymczasowe hasło). Istniejący wiersz → updateUser. Usunięcie → deleteUser. 'Zresetuj hasło' → adminResetPassword. Po create/reset → modal z tymczasowym hasłem."),
("212-268", "function RolesGrid()",
"Tabela ról: ID, Nazwa roli. Pełna edycja: add/edit/delete z rowPointer (UUID)."),
("270-330", "function FunctionsGrid()",
"Tabela funkcji systemowych: ID, ID roli, Nazwa funkcji. Pełna edycja z przypisaniem do roli (roleId)."),
]),
("src/features/scheduler/SchedulerPage.tsx", "Strona harmonogramu zadań automatycznych (Hangfire)", [
("38-59", "function RunLogGrid(props)",
"Tabela historii uruchomień zadania: data uruchomienia i treść logu. Renderowana jako detail template."),
("66-149", "export default function SchedulerPage()",
"Tabela zaplanowanych zadań automatycznych (Hangfire). Tryb edycji Dialog (okno modalne). Kolumny: ID, Nazwa, Ścieżka, Cron (wyrażenie harmonogramu), Ostatnie uruchomienie, Następne uruchomienie. Rozwinięcie wiersza → historia uruchomień. Add/Edit/Delete wysyłają do API HangfireJobs."),
]),
]
# ──────────────────────────────────────────────────────────────────────────────
# Klasa generatora PDF
# ──────────────────────────────────────────────────────────────────────────────
class DocPDF(FPDF):
def __init__(self):
super().__init__()
self.add_font("Arial", "", FONT_PATH)
self.add_font("Arial", "B", FONT_PATH)
self.add_font("Arial", "I", FONT_PATH)
self.set_auto_page_break(auto=True, margin=15)
def header(self):
if self.page_no() == 1:
return
self.set_font("Arial", "I", 8)
self.set_text_color(150, 150, 150)
self.cell(0, 8, "Dokumentacja projektu FaKrosno Management", align="L")
self.cell(0, 8, f"Strona {self.page_no()}", align="R", new_x="LMARGIN", new_y="NEXT")
self.set_text_color(0, 0, 0)
self.ln(2)
def footer(self):
self.set_y(-13)
self.set_font("Arial", "I", 8)
self.set_text_color(150, 150, 150)
self.cell(0, 8, f"FA Krosno Management | Wygenerowano automatycznie | s. {self.page_no()}", align="C")
self.set_text_color(0, 0, 0)
def add_cover(pdf: DocPDF):
pdf.add_page()
pdf.set_fill_color(30, 64, 175)
pdf.rect(0, 0, 210, 297, "F")
pdf.set_text_color(255, 255, 255)
pdf.set_font("Arial", "B", 28)
pdf.set_y(80)
pdf.multi_cell(0, 12, "Dokumentacja\nTechniczna", align="C")
pdf.ln(6)
pdf.set_font("Arial", "B", 18)
pdf.multi_cell(0, 10, "FA Krosno Management", align="C")
pdf.ln(12)
pdf.set_font("Arial", "", 13)
pdf.multi_cell(0, 8, "Szczegółowy opis kodu źródłowego\naplikacji webowej React + TypeScript", align="C")
pdf.ln(20)
pdf.set_font("Arial", "", 11)
pdf.multi_cell(0, 8, "Projekt: FaKrosnoManagement\nStos: React 19 · TypeScript · Vite · Tailwind CSS\nBackend: .NET Web API\nData generacji: 2026-06-01", align="C")
pdf.set_text_color(0, 0, 0)
def add_toc(pdf: DocPDF):
pdf.add_page()
pdf.set_font("Arial", "B", 18)
pdf.cell(0, 12, "Spis treści", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Arial", "", 11)
pdf.ln(4)
sections = []
for item in DOCS:
if item[1] is None:
sections.append(("section", item[0]))
else:
sections.append(("file", item[0]))
for kind, name in sections:
if kind == "section":
pdf.ln(4)
pdf.set_font("Arial", "B", 11)
pdf.set_text_color(30, 64, 175)
pdf.cell(0, 7, f" {name}", new_x="LMARGIN", new_y="NEXT")
pdf.set_text_color(0, 0, 0)
else:
pdf.set_font("Arial", "", 10)
pdf.set_text_color(60, 60, 60)
pdf.cell(8, 6, "", new_x="RIGHT", new_y="TOP")
pdf.cell(0, 6, name, new_x="LMARGIN", new_y="NEXT")
pdf.set_text_color(0, 0, 0)
def add_section_header(pdf: DocPDF, title: str):
pdf.add_page()
pdf.set_fill_color(30, 64, 175)
pdf.set_text_color(255, 255, 255)
pdf.set_font("Arial", "B", 16)
pdf.rect(0, pdf.get_y() - 2, 210, 20, "F")
pdf.cell(0, 16, f" {title}", new_x="LMARGIN", new_y="NEXT")
pdf.set_text_color(0, 0, 0)
pdf.ln(6)
def add_file_section(pdf: DocPDF, filename: str, description: str, lines: list):
# File header
pdf.set_fill_color(241, 245, 249)
pdf.set_draw_color(203, 213, 225)
y = pdf.get_y()
pdf.set_font("Arial", "B", 12)
pdf.set_text_color(30, 64, 175)
pdf.set_fill_color(224, 231, 255)
pdf.cell(0, 10, f" {filename}", fill=True, new_x="LMARGIN", new_y="NEXT")
pdf.set_text_color(0, 0, 0)
pdf.set_font("Arial", "I", 10)
pdf.set_text_color(80, 80, 80)
pdf.multi_cell(0, 6, f" {description}", new_x="LMARGIN", new_y="NEXT")
pdf.set_text_color(0, 0, 0)
pdf.ln(3)
# Lines table
for (lineno, code, explanation) in lines:
if pdf.get_y() > 265:
pdf.add_page()
# Line number / code range
pdf.set_fill_color(248, 250, 252)
pdf.set_font("Arial", "B", 9)
pdf.set_text_color(99, 102, 241)
pdf.cell(30, 6, f"Linia {lineno}", fill=True, new_x="RIGHT", new_y="TOP")
# Code snippet
pdf.set_font("Arial", "I", 8.5)
pdf.set_text_color(55, 65, 81)
code_short = (code[:65] + "...") if len(code) > 68 else code
pdf.cell(0, 6, code_short, new_x="LMARGIN", new_y="NEXT")
# Explanation
pdf.set_font("Arial", "", 10)
pdf.set_text_color(30, 30, 30)
pdf.set_x(10)
# Handle multi-line explanations
for part in explanation.split("\n"):
if pdf.get_y() > 268:
pdf.add_page()
pdf.set_x(12)
pdf.multi_cell(185, 5.5, part, new_x="LMARGIN", new_y="NEXT")
pdf.set_draw_color(226, 232, 240)
pdf.line(10, pdf.get_y(), 200, pdf.get_y())
pdf.ln(2)
pdf.ln(6)
# ──────────────────────────────────────────────────────────────────────────────
# Main
# ──────────────────────────────────────────────────────────────────────────────
def main():
print("Generowanie dokumentacji PDF...")
pdf = DocPDF()
add_cover(pdf)
add_toc(pdf)
for item in DOCS:
if item[1] is None:
# Section header
add_section_header(pdf, item[0])
else:
filename, description, lines = item
if pdf.get_y() > 230:
pdf.add_page()
add_file_section(pdf, filename, description, lines)
pdf.output(OUTPUT_FILE)
print(f"✅ Gotowe! Plik: {OUTPUT_FILE}")
print(f" Stron: {pdf.page}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

44
FaKrosnoManagerAngular/.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

View File

@@ -0,0 +1 @@
legacy-peer-deps=true

View File

@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

View File

@@ -0,0 +1,59 @@
# FaKrosnoManagerAngular
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.0.5.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -0,0 +1,82 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": false
},
"newProjectRoot": "projects",
"projects": {
"FaKrosnoManagerAngular": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "FaKrosnoManagerAngular:build:production"
},
"development": {
"buildTarget": "FaKrosnoManagerAngular:build:development"
}
},
"defaultConfiguration": "development",
"options": {
"port": 5173
}
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}

9015
FaKrosnoManagerAngular/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
{
"name": "fa-krosno-manager-angular",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.13.0",
"dependencies": {
"@angular/animations": "^22.0.5",
"@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0",
"@angular/forms": "^22.0.0",
"@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0",
"@primeuix/themes": "^2.0.3",
"bcryptjs": "^3.0.3",
"chart.js": "^4.5.1",
"primeicons": "^7.0.0",
"primeng": "^22.0.0-rc.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^22.0.5",
"@angular/cli": "^22.0.5",
"@angular/compiler-cli": "^22.0.0",
"@types/bcryptjs": "^2.4.6",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
"typescript": "~6.0.2",
"vitest": "^4.0.8"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Warstwa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="112.8 177 280.4 441.6" style="enable-background:new 112.8 177 280.4 441.6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#706F6F;}
.st1{fill:#B12009;}
</style>
<g>
<path class="st0" d="M133.7,590.8l20.1-19.8h-13.2l-18.1,18.5V571h-9.7v46.9h9.7v-16.1l4.5-4.4l14.5,20.5h12.2L133.7,590.8
L133.7,590.8z M192.9,603.7c-1.1-2.9-2.5-5.1-4.3-6.7c0.2-0.1,0.4-0.1,0.5-0.3c1.4-0.8,2.6-1.8,3.6-2.9c1-1.2,1.7-2.5,2.2-4
s0.8-3.1,0.8-4.8c0-1.9-0.4-3.9-1.1-5.8c-0.8-1.9-1.9-3.6-3.5-4.9c-0.9-0.8-1.9-1.4-2.9-1.9c-1-0.4-2-0.8-3.1-1
c-1.1-0.2-2.1-0.3-3.3-0.4c-1.1,0-2.2-0.1-3.4-0.1h-21v46.9h9.7v-18.4h8.6c0.9,0,1.7,0.1,2.5,0.4c0.8,0.2,1.5,0.6,2.2,1.1
c0.6,0.5,1.2,1.1,1.7,1.7c0.5,0.7,0.9,1.4,1.2,2.3l4.6,12.8h10.2C198.1,617.9,192.9,603.7,192.9,603.7L192.9,603.7z M178,590.5
h-10.8v-10.9h11.6c6.4,0,7.3,2.7,7.3,5.3c0,2.1-0.5,3.5-1.5,4.3C183.7,589.8,181.9,590.5,178,590.5L178,590.5z M247.1,584.7
c-1.1-3-2.7-5.6-4.6-7.7c-2-2.1-4.4-3.8-7.2-5c-2.7-1.2-5.8-1.8-9-1.8s-6.3,0.6-9,1.8c-2.7,1.1-5.2,2.8-7.2,4.9
c-2,2.1-3.6,4.7-4.7,7.7c-1.1,2.9-1.6,6.3-1.6,9.8s0.5,6.8,1.6,9.8s2.7,5.6,4.7,7.7c2,2.1,4.4,3.8,7.2,5c2.8,1.2,5.8,1.7,9.1,1.7
s6.3-0.6,9.1-1.8c2.7-1.2,5.1-2.9,7.1-5.1c2-2.1,3.5-4.7,4.6-7.7c1.1-2.9,1.6-6.2,1.6-9.7C248.8,590.9,248.2,587.6,247.1,584.7
L247.1,584.7z M239.1,594.4c0,2.5-0.3,4.8-1,6.7c-0.6,1.9-1.5,3.5-2.7,4.8c-1.1,1.3-2.5,2.3-4,2.9c-1.6,0.7-3.3,1-5.2,1
s-3.6-0.3-5.2-1c-1.5-0.7-2.8-1.6-3.9-2.9c-1.2-1.3-2-2.9-2.7-4.8c-0.7-1.9-1-4.2-1-6.7s0.3-4.8,1-6.7c0.7-1.9,1.5-3.5,2.7-4.8
c1.1-1.3,2.4-2.2,4-2.9c1.6-0.7,3.3-1,5.2-1c1.8,0,3.5,0.3,5.1,1c1.5,0.6,2.9,1.6,3.9,2.9c1.1,1.3,2,2.9,2.7,4.8
C238.7,589.6,239.1,591.9,239.1,594.4L239.1,594.4z M292.4,599.2c-0.5-1.4-1.2-2.6-2.1-3.6c-0.9-1-1.9-1.9-3.1-2.6
c-1.1-0.7-2.3-1.3-3.5-1.7c-1.2-0.4-2.6-0.9-4.1-1.3s-3.3-0.9-5.3-1.3c-1.9-0.4-3.4-0.8-4.6-1.2c-1.1-0.3-1.9-0.7-2.5-1.1
c-0.5-0.3-0.8-0.6-1-1c-0.2-0.4-0.3-0.9-0.3-1.5c0-1.4,0.6-2.4,1.7-3.2c1.4-1,3.5-1.5,6.2-1.5c7.1,0,8.4,3.3,8.7,6.1l0.2,1.8h9.3
l-0.1-2c-0.1-2.3-0.6-4.4-1.5-6.3c-1-1.9-2.3-3.4-3.9-4.7c-1.6-1.3-3.6-2.2-5.8-2.9c-2.2-0.6-4.6-1-7.2-1c-2.5,0-4.8,0.3-6.8,1
c-2.1,0.7-4,1.6-5.5,2.8c-1.6,1.2-2.8,2.7-3.6,4.4c-0.9,1.7-1.3,3.6-1.3,5.6c0,1.9,0.3,3.5,1,5c0.6,1.5,1.6,2.8,3,4
c1.2,1,2.9,2,4.8,2.8c1.8,0.8,4,1.4,6.5,1.9s4.5,1.1,6.1,1.5c1.5,0.4,2.8,0.9,3.7,1.5c0.7,0.4,1.3,0.9,1.6,1.4s0.4,1.2,0.4,2
c0,0.8-0.2,1.5-0.5,2.1c-0.4,0.6-0.9,1.2-1.6,1.7s-1.6,0.9-2.7,1.2c-1.1,0.3-2.4,0.4-3.8,0.4c-1.5,0-2.9-0.2-4.1-0.5
c-1.2-0.3-2.3-0.8-3.2-1.4s-1.6-1.4-2.2-2.3c-0.6-0.9-0.9-2.1-1-3.4l-0.1-1.8h-7.3h-2v2c0.1,2.4,0.5,4.6,1.4,6.6
c0.9,2,2.2,3.8,4,5.3c1.7,1.5,3.8,2.6,6.3,3.4c2.4,0.8,5.3,1.1,8.5,1.1c2.5,0,4.9-0.3,7-1c2.2-0.7,4.1-1.7,5.7-3
c1.6-1.3,2.9-2.9,3.8-4.7c1-1.9,1.4-3.9,1.4-6.1C293.2,602.1,293,600.6,292.4,599.2L292.4,599.2z M330.3,571v29.5L310.7,571h-8.8
v46.9h9.4v-29.5l19.6,29.5h8.8V571H330.3L330.3,571z M391.6,584.7c-1.1-3-2.7-5.6-4.7-7.7s-4.4-3.8-7.2-5c-2.8-1.2-5.8-1.8-9-1.8
s-6.3,0.6-9,1.8c-2.7,1.1-5.2,2.8-7.2,4.9c-2,2.1-3.6,4.7-4.7,7.7c-1.1,2.9-1.6,6.3-1.6,9.8s0.6,6.8,1.6,9.8c1.1,3,2.7,5.6,4.7,7.7
c2,2.1,4.4,3.8,7.2,5c2.7,1.2,5.8,1.7,9.1,1.7c3.3,0,6.3-0.6,9-1.8c2.8-1.2,5.2-2.9,7.2-5.1c2-2.1,3.5-4.7,4.6-7.7
c1.1-2.9,1.6-6.2,1.6-9.7C393.2,590.9,392.6,587.6,391.6,584.7L391.6,584.7z M383.5,594.4c0,2.5-0.3,4.8-1,6.7
c-0.6,1.9-1.5,3.5-2.7,4.8c-1.1,1.3-2.5,2.3-4,2.9c-1.6,0.7-3.3,1-5.2,1c-1.9,0-3.6-0.3-5.1-1s-2.9-1.6-4-2.9
c-1.1-1.3-2-2.9-2.7-4.8c-0.6-1.9-1-4.2-1-6.7s0.3-4.8,1-6.7c0.7-1.9,1.6-3.5,2.7-4.8c1.1-1.3,2.5-2.2,4-2.9c1.6-0.7,3.3-1,5.2-1
c1.8,0,3.5,0.3,5.1,1c1.5,0.6,2.8,1.6,4,2.9c1.1,1.3,2,2.9,2.7,4.8C383.2,589.6,383.5,591.9,383.5,594.4L383.5,594.4z"/>
<path class="st1" d="M112.8,177v373h274.3V177H112.8L112.8,177z M365.1,528.1H134.7V198.9h230.4V528.1L365.1,528.1z M307.5,303.2
l-62-0.1v78.4h-25.9V281.1h87.9v-53.3H163.9l-0.1,223.8l55.8,55.9V403.6h25.9v103.8l29.6-29.5v-74.2h31.1l12.4,30.9l30-30
L307.5,303.2L307.5,303.2z M275.1,381.5v-55.7l22.2,55.7H275.1L275.1,381.5z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,104 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TableModule } from 'primeng/table';
import { ButtonDirective } from 'primeng/button';
import { InputTextModule } from 'primeng/inputtext';
import { DialogModule } from 'primeng/dialog';
import { ConfirmationService } from 'primeng/api';
import { TaskSchedulerDto } from '../../core/models';
import { HangfireService } from '../../core/services/hangfire.service';
import { generateGuid } from '../../shared/utils/business-logic';
import { PageShellComponent } from '../../shared/components/page-shell/page-shell.component';
import { ContentSectionComponent } from '../../shared/components/content-section/content-section.component';
@Component({
selector: 'app-scheduler',
standalone: true,
imports: [TableModule, ButtonDirective, InputTextModule, DialogModule, FormsModule, DatePipe, PageShellComponent, ContentSectionComponent],
template: `
<app-page-shell
title="Zarządzanie Zadaniami"
subtitle="Konfiguracja zadań harmonogramu Hangfire"
icon="pi pi-clock"
>
<app-content-section title="Zadania harmonogramu" icon="pi pi-calendar" [flush]="true">
<div headerActions>
<button pButton type="button" (click)="startAdd()"><i class="pi pi-plus"></i> Dodaj zadanie</button>
</div>
<p-table [value]="tasks()" [paginator]="true" [rows]="10" dataKey="rowPointer" styleClass="p-datatable-sm" [showGridlines]="true" [stripedRows]="true">
<ng-template #header>
<tr><th>Nazwa</th><th>Ścieżka</th><th>CRON</th><th>Ostatnie Uruchomienie</th><th>Następne Uruchomienie</th><th></th></tr>
</ng-template>
<ng-template #body let-task>
<tr>
<td>{{ task.name }}</td><td>{{ task.path }}</td><td>{{ task.cronOptions }}</td>
<td>{{ task.lastExecution | date: 'dd.MM.yyyy HH:mm' }}</td><td>{{ task.nextExecution | date: 'dd.MM.yyyy HH:mm' }}</td>
<td>
<button pButton type="button" [text]="true" [iconOnly]="true" (click)="startEdit(task)"><i class="pi pi-pencil"></i></button>
<button pButton type="button" severity="danger" [text]="true" [iconOnly]="true" (click)="confirmDelete(task)"><i class="pi pi-trash"></i></button>
</td>
</tr>
</ng-template>
</p-table>
</app-content-section>
</app-page-shell>
<p-dialog header="Zadanie" [(visible)]="showDialog" [modal]="true" [style]="{ width: '520px' }">
<div class="field-group"><label class="field-label">Nazwa</label><input pInputText class="w-full" [(ngModel)]="editTask.name" /></div>
<div class="field-group"><label class="field-label">Ścieżka</label><input pInputText class="w-full" [(ngModel)]="editTask.path" /></div>
<div class="field-group"><label class="field-label">CRON</label><input pInputText class="w-full" [(ngModel)]="editTask.cronOptions" /></div>
<ng-template #footer>
<button pButton type="button" (click)="save()">Zapisz</button>
<button pButton type="button" severity="secondary" (click)="showDialog = false">Anuluj</button>
</ng-template>
</p-dialog>
`,
})
export class SchedulerComponent implements OnInit {
private readonly hangfireService = inject(HangfireService);
private readonly confirmation = inject(ConfirmationService);
readonly tasks = signal<TaskSchedulerDto[]>([]);
showDialog = false;
isNew = false;
editTask: TaskSchedulerDto = this.emptyTask();
async ngOnInit(): Promise<void> { await this.load(); }
startAdd(): void {
this.isNew = true;
this.editTask = this.emptyTask();
this.editTask.rowPointer = generateGuid();
this.showDialog = true;
}
startEdit(task: TaskSchedulerDto): void {
this.isNew = false;
this.editTask = { ...task, taskSchedulerDetails: [...(task.taskSchedulerDetails ?? [])] };
this.showDialog = true;
}
async save(): Promise<void> {
if (this.isNew) await this.hangfireService.add(this.editTask);
else await this.hangfireService.update(this.editTask);
this.showDialog = false;
await this.load();
}
confirmDelete(task: TaskSchedulerDto): void {
this.confirmation.confirm({
message: `Usunąć zadanie ${task.name}?`,
accept: async () => { await this.hangfireService.delete(task); await this.load(); },
});
}
private emptyTask(): TaskSchedulerDto {
return {
id: 0, rowPointer: generateGuid(), name: '', path: '', cronOptions: '',
createDate: new Date().toISOString(), activeFrom: new Date().toISOString(), taskSchedulerDetails: [],
};
}
private async load(): Promise<void> { this.tasks.set(await this.hangfireService.getAll()); }
}

View File

@@ -0,0 +1,228 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { DatePipe } from '@angular/common';
import { TableModule } from 'primeng/table';
import { ButtonDirective } from 'primeng/button';
import { InputTextModule } from 'primeng/inputtext';
import { DialogModule } from 'primeng/dialog';
import { CheckboxModule } from 'primeng/checkbox';
import { ConfirmationService } from 'primeng/api';
import * as bcrypt from 'bcryptjs';
import { FunctionDto, RoleDto, UserDto } from '../../core/models';
import { UserService } from '../../core/services/user.service';
import { RoleService } from '../../core/services/role.service';
import { FunctionService } from '../../core/services/function.service';
import { generateGuid, generateTempPassword } from '../../shared/utils/business-logic';
import { PageShellComponent } from '../../shared/components/page-shell/page-shell.component';
import { ContentSectionComponent } from '../../shared/components/content-section/content-section.component';
@Component({
selector: 'app-users-manager',
standalone: true,
imports: [TableModule, ButtonDirective, InputTextModule, DialogModule, CheckboxModule, FormsModule, DatePipe, PageShellComponent, ContentSectionComponent],
template: `
<app-page-shell
title="Zarządzanie Użytkownikami i Rolami"
subtitle="Administracja kont użytkowników, ról i uprawnień systemowych"
icon="pi pi-users"
>
<app-content-section title="Użytkownicy" icon="pi pi-user" [flush]="true">
<div headerActions>
<button pButton type="button" (click)="startAddUser()"><i class="pi pi-plus"></i> Dodaj użytkownika</button>
</div>
<p-table [value]="users()" [paginator]="true" [rows]="10" dataKey="rowPointer" styleClass="p-datatable-sm" [showGridlines]="true" [stripedRows]="true">
<ng-template #header>
<tr><th>Login</th><th>Email</th><th>Imię</th><th>Nazwisko</th><th>Aktywny</th><th>Utworzono</th><th></th></tr>
</ng-template>
<ng-template #body let-user>
<tr>
<td>{{ user.login }}</td><td>{{ user.email }}</td><td>{{ user.firstName }}</td><td>{{ user.lastName }}</td>
<td>{{ user.isActive ? 'Tak' : 'Nie' }}</td><td>{{ user.createdDate | date: 'dd.MM.yyyy' }}</td>
<td>
<button pButton type="button" [text]="true" [iconOnly]="true" (click)="startEditUser(user)"><i class="pi pi-pencil"></i></button>
<button pButton type="button" [text]="true" (click)="resetPassword(user)"><i class="pi pi-key"></i> Zresetuj haslo</button>
<button pButton type="button" severity="danger" [text]="true" [iconOnly]="true" (click)="confirmDeleteUser(user)"><i class="pi pi-trash"></i></button>
</td>
</tr>
</ng-template>
</p-table>
</app-content-section>
<app-content-section title="Role" icon="pi pi-shield" [flush]="true">
<div headerActions>
<button pButton type="button" (click)="startAddRole()"><i class="pi pi-plus"></i> Dodaj rolę</button>
</div>
<p-table [value]="roles()" [paginator]="true" [rows]="10" dataKey="id" styleClass="p-datatable-sm" [showGridlines]="true" [stripedRows]="true">
<ng-template #header><tr><th>ID</th><th>Nazwa</th><th></th></tr></ng-template>
<ng-template #body let-role>
<tr>
<td>{{ role.id }}</td><td>{{ role.name }}</td>
<td>
<button pButton type="button" [text]="true" [iconOnly]="true" (click)="startEditRole(role)"><i class="pi pi-pencil"></i></button>
<button pButton type="button" severity="danger" [text]="true" [iconOnly]="true" (click)="confirmDeleteRole(role)"><i class="pi pi-trash"></i></button>
</td>
</tr>
</ng-template>
</p-table>
</app-content-section>
<app-content-section title="Funkcje" icon="pi pi-cog" [flush]="true">
<div headerActions>
<button pButton type="button" (click)="startAddFunction()"><i class="pi pi-plus"></i> Dodaj funkcję</button>
</div>
<p-table [value]="functions()" [paginator]="true" [rows]="10" dataKey="id" styleClass="p-datatable-sm" [showGridlines]="true" [stripedRows]="true">
<ng-template #header><tr><th>ID</th><th>ID Roli</th><th>Nazwa Funkcji</th><th></th></tr></ng-template>
<ng-template #body let-fn>
<tr>
<td>{{ fn.id }}</td><td>{{ fn.roleId }}</td><td>{{ fn.name }}</td>
<td>
<button pButton type="button" [text]="true" [iconOnly]="true" (click)="startEditFunction(fn)"><i class="pi pi-pencil"></i></button>
<button pButton type="button" severity="danger" [text]="true" [iconOnly]="true" (click)="confirmDeleteFunction(fn)"><i class="pi pi-trash"></i></button>
</td>
</tr>
</ng-template>
</p-table>
</app-content-section>
</app-page-shell>
<p-dialog header="Użytkownik" [(visible)]="showUserDialog" [modal]="true" [style]="{ width: '480px' }">
<div class="field-group"><label class="field-label">Login</label><input pInputText class="w-full" [(ngModel)]="editUser.login" /></div>
<div class="field-group"><label class="field-label">Email</label><input pInputText class="w-full" [(ngModel)]="editUser.email" /></div>
<div class="field-group"><label class="field-label">Imię</label><input pInputText class="w-full" [(ngModel)]="editUser.firstName" /></div>
<div class="field-group"><label class="field-label">Nazwisko</label><input pInputText class="w-full" [(ngModel)]="editUser.lastName" /></div>
<div class="field-group"><p-checkbox [(ngModel)]="editUser.isActive" binary label="Aktywny" /></div>
<ng-template #footer>
<button pButton type="button" (click)="saveUser()">Zapisz</button>
<button pButton type="button" severity="secondary" (click)="showUserDialog = false">Anuluj</button>
</ng-template>
</p-dialog>
<p-dialog header="Rola" [(visible)]="showRoleDialog" [modal]="true" [style]="{ width: '400px' }">
<div class="field-group"><label class="field-label">Nazwa</label><input pInputText class="w-full" [(ngModel)]="editRole.name" /></div>
<ng-template #footer><button pButton type="button" (click)="saveRole()">Zapisz</button><button pButton type="button" severity="secondary" (click)="showRoleDialog = false">Anuluj</button></ng-template>
</p-dialog>
<p-dialog header="Funkcja" [(visible)]="showFunctionDialog" [modal]="true" [style]="{ width: '400px' }">
<div class="field-group"><label class="field-label">ID Roli</label><input pInputText type="number" class="w-full" [(ngModel)]="editFunction.roleId" /></div>
<div class="field-group"><label class="field-label">Nazwa Funkcji</label><input pInputText class="w-full" [(ngModel)]="editFunction.name" /></div>
<ng-template #footer><button pButton type="button" (click)="saveFunction()">Zapisz</button><button pButton type="button" severity="secondary" (click)="showFunctionDialog = false">Anuluj</button></ng-template>
</p-dialog>
<p-dialog header="Dodano użytkownika!" [(visible)]="showTempPasswordDialog" [modal]="true" [style]="{ width: '500px' }">
<p>Użytkownik <strong>{{ tempLogin }}</strong> został dodany pomyślnie!</p>
<p>Hasło tymczasowe: <strong>{{ tempPassword }}</strong></p>
<ng-template #footer><button pButton type="button" (click)="showTempPasswordDialog = false">OK</button></ng-template>
</p-dialog>
`,
})
export class UsersManagerComponent implements OnInit {
private readonly userService = inject(UserService);
private readonly roleService = inject(RoleService);
private readonly functionService = inject(FunctionService);
private readonly confirmation = inject(ConfirmationService);
readonly users = signal<UserDto[]>([]);
readonly roles = signal<RoleDto[]>([]);
readonly functions = signal<FunctionDto[]>([]);
showUserDialog = false;
showRoleDialog = false;
showFunctionDialog = false;
showTempPasswordDialog = false;
tempLogin = '';
tempPassword = '';
editUser: UserDto = this.emptyUser();
editRole: RoleDto = { id: 0, name: '', rowPointer: '' };
editFunction: FunctionDto = { id: 0, roleId: 0, name: '', rowPointer: '' };
isNewUser = false;
async ngOnInit(): Promise<void> {
await Promise.all([this.loadUsers(), this.loadRoles(), this.loadFunctions()]);
}
startAddUser(): void { this.isNewUser = true; this.editUser = this.emptyUser(); this.showUserDialog = true; }
startEditUser(user: UserDto): void { this.isNewUser = false; this.editUser = { ...user }; this.showUserDialog = true; }
startAddRole(): void { this.editRole = { id: 0, name: '', rowPointer: generateGuid() }; this.showRoleDialog = true; }
startEditRole(role: RoleDto): void { this.editRole = { ...role }; this.showRoleDialog = true; }
startAddFunction(): void { this.editFunction = { id: 0, roleId: 0, name: '', rowPointer: generateGuid() }; this.showFunctionDialog = true; }
startEditFunction(fn: FunctionDto): void { this.editFunction = { ...fn }; this.showFunctionDialog = true; }
async saveUser(): Promise<void> {
if (this.isNewUser) {
const temp = generateTempPassword();
this.editUser.passwordHash = bcrypt.hashSync(temp, 10);
this.editUser.isTemporaryPassword = true;
this.editUser.rowPointer = generateGuid();
this.editUser.createdDate = new Date().toISOString();
this.editUser.activeFrom = new Date().toISOString();
this.editUser.isActive = true;
await this.userService.create(this.editUser);
this.tempLogin = this.editUser.login;
this.tempPassword = temp;
this.showTempPasswordDialog = true;
} else {
await this.userService.update(this.editUser);
}
this.showUserDialog = false;
await this.loadUsers();
}
async resetPassword(user: UserDto): Promise<void> {
const temp = generateTempPassword();
user.passwordHash = bcrypt.hashSync(temp, 10);
user.isTemporaryPassword = true;
await this.userService.update(user);
this.tempLogin = user.login;
this.tempPassword = temp;
this.showTempPasswordDialog = true;
await this.loadUsers();
}
async saveRole(): Promise<void> {
if (this.editRole.id === 0) await this.roleService.create(this.editRole);
else await this.roleService.update(this.editRole);
this.showRoleDialog = false;
await this.loadRoles();
}
async saveFunction(): Promise<void> {
if (this.editFunction.id === 0) await this.functionService.create(this.editFunction);
else await this.functionService.update(this.editFunction);
this.showFunctionDialog = false;
await this.loadFunctions();
}
confirmDeleteUser(user: UserDto): void {
this.confirmation.confirm({
message: `Usunąć użytkownika ${user.login}?`,
accept: async () => { await this.userService.delete(user.rowPointer); await this.loadUsers(); },
});
}
confirmDeleteRole(role: RoleDto): void {
this.confirmation.confirm({
message: `Usunąć rolę ${role.name}?`,
accept: async () => { await this.roleService.delete(role.rowPointer); await this.loadRoles(); },
});
}
confirmDeleteFunction(fn: FunctionDto): void {
this.confirmation.confirm({
message: `Usunąć funkcję ${fn.name}?`,
accept: async () => { await this.functionService.delete(fn.rowPointer); await this.loadFunctions(); },
});
}
private emptyUser(): UserDto {
return {
id: 0, login: '', passwordHash: '', isTemporaryPassword: false, isActive: true,
email: '', firstName: '', lastName: '', createdDate: new Date().toISOString(),
failedLoginAttempts: 0, isLocked: false, rowPointer: generateGuid(), userRoles: [],
};
}
private async loadUsers(): Promise<void> { this.users.set(await this.userService.getAll()); }
private async loadRoles(): Promise<void> { this.roles.set(await this.roleService.getAll()); }
private async loadFunctions(): Promise<void> { this.functions.set(await this.functionService.getAll()); }
}

View File

@@ -0,0 +1,59 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { providePrimeNG } from 'primeng/config';
import { MessageService, ConfirmationService } from 'primeng/api';
import { definePreset } from '@primeuix/themes';
import Aura from '@primeuix/themes/aura';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
import { errorInterceptor } from './core/interceptors/error.interceptor';
import { environment } from '../environments/environment';
const FaPreset = definePreset(Aura, {
semantic: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#1a56db',
600: '#1648b8',
700: '#123a94',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
},
borderRadius: {
none: '0',
xs: '2px',
sm: '4px',
md: '6px',
lg: '8px',
xl: '10px',
},
});
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
provideAnimationsAsync(),
providePrimeNG({
license: environment.primeNgLicense || undefined,
theme: {
preset: FaPreset,
options: {
darkModeSelector: '[data-theme="dark"]',
},
},
}),
MessageService,
ConfirmationService,
],
};

View File

@@ -0,0 +1,32 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./layout/main-layout/main-layout.component').then((m) => m.MainLayoutComponent),
children: [
{ path: '', loadComponent: () => import('./schedule-orders/schedule-orders-list.component').then((m) => m.ScheduleOrdersListComponent) },
{ path: 'Products', loadComponent: () => import('./products/products-list.component').then((m) => m.ProductsListComponent) },
{ path: 'Warehouse', loadComponent: () => import('./warehouse/warehouse.component').then((m) => m.WarehouseComponent) },
{ path: 'ScheduleOrder/:scheduleOrderId', loadComponent: () => import('./schedule-order/schedule-order-detail.component').then((m) => m.ScheduleOrderDetailComponent) },
{ path: 'Warehouse/Meyle/PackList/:WzHeader', loadComponent: () => import('./packing-lists/meyle/meyle-pack-list.component').then((m) => m.MeylePackListComponent) },
{ path: 'Warehouse/Meyle/PackList/:WzHeader/Simple', loadComponent: () => import('./packing-lists/meyle/meyle-pack-list-simple.component').then((m) => m.MeylePackListSimpleComponent) },
{ path: 'Warehouse/Marelli/PackList/:WzHeader', loadComponent: () => import('./packing-lists/marelli/marelli-pack-list.component').then((m) => m.MarelliPackListComponent) },
{ path: 'Warehouse/Marelli/PackList/:WzHeader/Simple', loadComponent: () => import('./packing-lists/marelli/marelli-pack-list-simple.component').then((m) => m.MarelliPackListSimpleComponent) },
{ path: 'Admin/PK/CustomerOrders', loadComponent: () => import('./customer-orders/customer-orders-list.component').then((m) => m.CustomerOrdersListComponent) },
{ path: 'Admin/PK/CustomerOrder/:customerOrderId', loadComponent: () => import('./customer-order/customer-order-detail.component').then((m) => m.CustomerOrderDetailComponent) },
{ path: 'Admin/PK/EdiCustomerOrders', loadComponent: () => import('./edi-customer-orders/edi-customer-orders-list.component').then((m) => m.EdiCustomerOrdersListComponent) },
{ path: 'Admin/PK/EdiCustomerOrder/:customerOrderId', loadComponent: () => import('./edi-customer-order/edi-customer-order-detail.component').then((m) => m.EdiCustomerOrderDetailComponent) },
{ path: 'admin/pk', loadComponent: () => import('./customer-orders-translations/customer-orders-translations.component').then((m) => m.CustomerOrdersTranslationsComponent) },
{ path: 'Admin/PK/UsersManager', loadComponent: () => import('./admin/users-manager/users-manager.component').then((m) => m.UsersManagerComponent) },
{ path: 'Admin/PK/Scheduler', loadComponent: () => import('./admin/scheduler/scheduler.component').then((m) => m.SchedulerComponent) },
],
},
{ path: 'login', loadComponent: () => import('./auth/login/login.component').then((m) => m.LoginComponent) },
{ path: 'register', loadComponent: () => import('./auth/register/register.component').then((m) => m.RegisterComponent) },
{ path: 'Main', loadComponent: () => import('./main/main.component').then((m) => m.MainComponent) },
{ path: 'Unauthorized', loadComponent: () => import('./unauthorized/unauthorized.component').then((m) => m.UnauthorizedComponent) },
{ path: 'Error', loadComponent: () => import('./error/error.component').then((m) => m.ErrorComponent) },
{ path: '**', redirectTo: '' },
];

View File

View File

@@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, FaKrosnoManagerAngular');
});
});

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: `<router-outlet />`,
styles: `:host { display: block; height: 100dvh; }`,
})
export class App {}

View File

@@ -0,0 +1,134 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { InputTextModule } from 'primeng/inputtext';
import { InputPasswordModule } from 'primeng/inputpassword';
import { ButtonDirective } from 'primeng/button';
import * as bcrypt from 'bcryptjs';
import { AuthService } from '../../core/services/auth.service';
import { UserService } from '../../core/services/user.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule, InputTextModule, InputPasswordModule, ButtonDirective, RouterLink],
template: `
<div class="auth-layout">
<aside class="auth-layout__brand">
<div class="auth-layout__brand-logo">
<img src="logo.svg" width="40" height="40" alt="" />
<h1 class="auth-layout__brand-title">FA Krosno Manager</h1>
</div>
<p class="auth-layout__brand-tagline">
System zarządzania zamówieniami DELFOR, magazynem i procesami EDI dla operacji produkcyjnych.
</p>
<div class="auth-layout__features">
<div class="auth-layout__feature"><i class="pi pi-building"></i> Zamówienia DELFOR i harmonogramy</div>
<div class="auth-layout__feature"><i class="pi pi-warehouse"></i> Magazyn i packing listy</div>
<div class="auth-layout__feature"><i class="pi pi-database"></i> Integracja EDI / Syteline</div>
</div>
</aside>
<main class="auth-layout__form">
<div class="auth-card">
<h2 class="auth-card__title">Logowanie</h2>
<p class="auth-card__subtitle">Zaloguj się do systemu FA Krosno Manager</p>
@if (tempPassword()) {
<div class="alert-info mb-3">
Twoje tymczasowe hasło to: <strong>{{ tempPassword() }}</strong>. Użyj go do pierwszego logowania.
</div>
}
<form (ngSubmit)="handleLogin()">
<div class="field-group">
<label class="field-label" for="login">Login</label>
<input pInputText id="login" class="w-full" [(ngModel)]="login" name="login" required placeholder="Wprowadź login" />
</div>
<div class="field-group">
<label class="field-label" for="password">Hasło</label>
<input pInputPassword pInputText id="password" name="password" [(ngModel)]="password" placeholder="Wprowadź hasło" [fluid]="true" class="w-full" />
</div>
<button pButton type="submit" [fluid]="true"><i class="pi pi-sign-in"></i> Zaloguj</button>
@if (errorMessage()) {
<div class="alert-danger mt-3">{{ errorMessage() }}</div>
}
</form>
@if (showChangePassword()) {
<hr class="divider" />
<h3 class="auth-card__title" style="font-size: var(--font-size-lg)">Zmień hasło</h3>
<p class="auth-card__subtitle">Ustaw nowe hasło przed pierwszym użyciem systemu</p>
<form (ngSubmit)="handleChangePassword()">
<div class="field-group">
<label class="field-label" for="newPassword">Nowe hasło</label>
<input pInputPassword pInputText id="newPassword" name="newPassword" [(ngModel)]="newPassword" placeholder="Wprowadź nowe hasło" [fluid]="true" class="w-full" />
</div>
<div class="field-group">
<label class="field-label" for="confirmPassword">Potwierdź hasło</label>
<input pInputPassword pInputText id="confirmPassword" name="confirmPassword" [(ngModel)]="confirmPassword" placeholder="Potwierdź nowe hasło" [fluid]="true" class="w-full" />
</div>
<button pButton type="submit" severity="success" [fluid]="true">Zmień hasło</button>
</form>
}
<p class="text-secondary" style="margin-top: var(--spacing-4); font-size: var(--font-size-sm); text-align: center">
Nie masz konta? <a routerLink="/register">Zarejestruj się</a>
</p>
</div>
</main>
</div>
`,
})
export class LoginComponent implements OnInit {
private readonly auth = inject(AuthService);
private readonly userService = inject(UserService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
login = '';
password = '';
newPassword = '';
confirmPassword = '';
readonly tempPassword = signal('');
readonly errorMessage = signal('');
readonly showChangePassword = signal(false);
ngOnInit(): void {
const param = this.route.snapshot.queryParamMap.get('tempPassword');
if (param) this.tempPassword.set(decodeURIComponent(param));
}
async handleLogin(): Promise<void> {
this.errorMessage.set('');
const user = await this.auth.login(this.login, this.password);
if (!user) {
this.errorMessage.set('Nieprawidłowy login lub hasło');
return;
}
if (user.isTemporaryPassword) {
this.showChangePassword.set(true);
return;
}
this.router.navigate(['/']);
}
async handleChangePassword(): Promise<void> {
if (this.newPassword !== this.confirmPassword) {
this.errorMessage.set('Hasła nie są zgodne');
return;
}
const user = await this.auth.getUserByUsername(this.login);
if (!user) return;
user.passwordHash = bcrypt.hashSync(this.newPassword, 10);
user.isTemporaryPassword = false;
await this.userService.update(user);
this.showChangePassword.set(false);
this.login = '';
this.password = '';
this.newPassword = '';
this.confirmPassword = '';
this.router.navigate(['/login']);
}
}

View File

@@ -0,0 +1,93 @@
import { Component, inject } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { InputTextModule } from 'primeng/inputtext';
import { ButtonDirective } from 'primeng/button';
import * as bcrypt from 'bcryptjs';
import { UserService } from '../../core/services/user.service';
import { UserDto } from '../../core/models';
import { generateGuid, generateTempPassword } from '../../shared/utils/business-logic';
@Component({
selector: 'app-register',
standalone: true,
imports: [FormsModule, InputTextModule, ButtonDirective, RouterLink],
template: `
<div class="auth-layout">
<aside class="auth-layout__brand">
<div class="auth-layout__brand-logo">
<img src="logo.svg" width="40" height="40" alt="" />
<h1 class="auth-layout__brand-title">FA Krosno Manager</h1>
</div>
<p class="auth-layout__brand-tagline">
System zarządzania zamówieniami DELFOR, magazynem i procesami EDI dla operacji produkcyjnych.
</p>
<div class="auth-layout__features">
<div class="auth-layout__feature"><i class="pi pi-building"></i> Zamówienia DELFOR i harmonogramy</div>
<div class="auth-layout__feature"><i class="pi pi-warehouse"></i> Magazyn i packing listy</div>
<div class="auth-layout__feature"><i class="pi pi-database"></i> Integracja EDI / Syteline</div>
</div>
</aside>
<main class="auth-layout__form">
<div class="auth-card">
<h2 class="auth-card__title">Rejestracja</h2>
<p class="auth-card__subtitle">Utwórz nowe konto w systemie FA Krosno Manager</p>
<form (ngSubmit)="handleRegister()">
<div class="field-group">
<label class="field-label" for="login">Login</label>
<input pInputText id="login" class="w-full" [(ngModel)]="model.login" name="login" required placeholder="Wprowadź login" />
</div>
<div class="field-group">
<label class="field-label" for="email">Email</label>
<input pInputText id="email" class="w-full" [(ngModel)]="model.email" name="email" required placeholder="Wprowadź email" />
</div>
<div class="field-group">
<label class="field-label" for="firstName">Imię</label>
<input pInputText id="firstName" class="w-full" [(ngModel)]="model.firstName" name="firstName" required placeholder="Wprowadź imię" />
</div>
<div class="field-group">
<label class="field-label" for="lastName">Nazwisko</label>
<input pInputText id="lastName" class="w-full" [(ngModel)]="model.lastName" name="lastName" required placeholder="Wprowadź nazwisko" />
</div>
<button pButton type="submit" [fluid]="true"><i class="pi pi-user-plus"></i> Zarejestruj</button>
</form>
<p class="text-secondary" style="margin-top: var(--spacing-4); font-size: var(--font-size-sm); text-align: center">
Masz już konto? <a routerLink="/login">Zaloguj się</a>
</p>
</div>
</main>
</div>
`,
})
export class RegisterComponent {
private readonly userService = inject(UserService);
private readonly router = inject(Router);
model = { login: '', email: '', firstName: '', lastName: '' };
async handleRegister(): Promise<void> {
const temporaryPassword = generateTempPassword();
const user: UserDto = {
id: 0,
login: this.model.login,
passwordHash: bcrypt.hashSync(temporaryPassword, 10),
isTemporaryPassword: true,
isActive: true,
activeFrom: new Date().toISOString(),
email: this.model.email,
firstName: this.model.firstName,
lastName: this.model.lastName,
createdDate: new Date().toISOString(),
failedLoginAttempts: 0,
isLocked: false,
rowPointer: generateGuid(),
userRoles: [],
};
await this.userService.create(user);
this.router.navigate(['/login'], { queryParams: { tempPassword: temporaryPassword } });
}
}

View File

@@ -0,0 +1,12 @@
import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) {
return true;
}
return router.createUrlTree(['/login']);
};

View File

@@ -0,0 +1,12 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const token = auth.getToken();
if (token) {
req = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
}
return next(req);
};

View File

@@ -0,0 +1,30 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, finalize, throwError } from 'rxjs';
import { MessageService } from 'primeng/api';
import { Router } from '@angular/router';
import { LoadingService } from '../services/loading.service';
import { AuthService } from '../services/auth.service';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const messageService = inject(MessageService);
const router = inject(Router);
const auth = inject(AuthService);
const loading = inject(LoadingService);
loading.show();
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
auth.logout();
router.navigate(['/login']);
messageService.add({ severity: 'error', summary: 'Sesja wygasła', detail: 'Zaloguj się ponownie.' });
} else {
const detail = error.error?.message ?? error.message ?? 'Wystąpił błąd podczas komunikacji z serwerem.';
messageService.add({ severity: 'error', summary: 'Błąd', detail });
}
return throwError(() => error);
}),
finalize(() => loading.hide()),
);
};

View File

@@ -0,0 +1,414 @@
export interface LoginResponse {
token: string;
expires: string;
}
export interface ResponseModel {
status: number;
identifier: string;
message: string;
externalIdentifier?: string;
}
export interface TransactionModel {
partNumber?: string;
itemNumber?: string;
quantity?: number;
}
export interface UserDto {
id: number;
login: string;
passwordHash: string;
isTemporaryPassword: boolean;
isActive: boolean;
activeFrom?: string;
activeTo?: string;
email: string;
firstName: string;
lastName: string;
createdDate: string;
lastLoginDate?: string;
failedLoginAttempts: number;
isLocked: boolean;
lockoutEndDate?: string;
rowPointer: string;
userRoles: UserRoleDto[];
}
export interface UserRoleDto {
userId: number;
roleId: number;
rowPointer: string;
}
export interface RoleDto {
id: number;
name: string;
rowPointer: string;
}
export interface FunctionDto {
id: number;
roleId: number;
name: string;
rowPointer: string;
}
export interface TaskSchedulerDto {
id: number;
rowPointer: string;
name: string;
path: string;
cronOptions: string;
createDate: string;
activeFrom: string;
activeUntil?: string;
lastExecution?: string;
nextExecution?: string;
taskSchedulerDetails: TaskSchedulerDetailDto[];
}
export interface TaskSchedulerDetailDto {
id: number;
taskSchedulerId: number;
name: string;
value: string;
}
export interface ProductDto {
id: number;
recipientID: number;
recipientIdx: string;
faIdx: string;
recipientName: string;
}
export interface ScheduleOrderDto {
id: number;
poNum: string;
orderID: number;
recipientID: number;
lastUpdateDate: string;
docNumber: number;
docType?: string;
recipientCode: string;
purchaserCode?: string;
recipientName: string;
purchaserName?: string;
scheduleOrderDetails: ScheduleOrderDetailDto[];
scheduleOrderMiscs: ScheduleOrderMiscDto[];
}
export interface ScheduleOrderDetailDto {
id: number;
scheduleOrderID: number;
sc_productCode: string;
sh_productCode: string;
price?: number;
orderNumber?: string;
recipientName?: string;
recipientCode?: string;
purchaserName?: string;
scheduleOrderDetailDetails: ScheduleOrderDetailDetailDto[];
}
export interface ScheduleOrderDetailDetailDto {
id: number;
scheduleOrderDetailID: number;
qty: number;
dateFrom: string;
dateTo: string;
sccType: string;
sccDesc?: string;
qtyType?: string;
qtyDesc?: string;
status: string;
shipDate?: string;
lastUpdateDate: string;
}
export interface ScheduleOrderMiscDto {
id: number;
scheduleOrderID: number;
type: string;
value: string;
label?: string;
display: boolean;
text: string;
}
export interface CustomerOrderDto {
rowPointer: string;
coNum: string;
custPo: string;
custNum: string;
custSeq: number;
createDate: string;
contact?: string;
phone?: string;
orderDate?: string;
termsCode?: string;
price?: number;
translatedStatus?: string;
whse?: string;
frtTaxCode1?: string;
endUserType?: string;
exchRate?: number;
uf_FKR_EDI_IdentificationCode?: string;
uf_FKR_EDI_RSSBussFolder?: string;
uf_DocType?: string;
uf_FKR_EDI_Gate?: string;
uf_FKR_EDI_RecipientCode?: string;
uf_FKR_EDI_SellerCode?: string;
uf_FKR_EDI_SenderCode?: string;
uf_FKR_EDI_BuyerCode?: string;
ediCustomerOrderTranslates?: EdiCustomerOrderTranslateDto[];
customerOrderLines: CustomerOrderLineDto[];
}
export interface CustomerOrderLineDto {
coLine: number;
coNum?: string;
item: string;
custItem: string;
description: string;
blanketQty?: number;
um: string;
contPrice?: number;
translatedStatus?: string;
effDate?: string;
uf_FKR_EDI_BLN_BoxType?: string;
uf_FKR_EDI_BLN_Address?: string;
uf_FKR_EDI_BLN_FinalDestination?: string;
uf_FKR_EDI_BLN_QtyPerBox?: number;
customerOrderLineItems: CustomerOrderLineItemDto[];
}
export interface CustomerOrderLineItemDto {
coLine: number;
coRelease: number;
coNum?: string;
item: string;
custItem: string;
qtyOrdered?: number;
dueDate?: string;
translatedStatus?: string;
price?: number;
releaseDate?: string;
whse?: string;
uf_FKR_EDI_ITEM_DocumentType?: string;
taxCode1?: string;
um?: string;
coCustNum?: string;
description?: string;
uf_FKR_EDI_ITEM_RoutingCode?: string;
uf_FKR_EDI_ITEM_DeliveryCallNum?: string;
uf_LOC_11_UnloadingPoint?: string;
uf_LOC_159_DestinationPoint?: string;
uf_FKR_EDI_ITEM_PalletCode?: string;
}
export interface EdiCustomerOrderDto {
rowPointer: string;
customerOrderNumber: string;
customerPoNumber: string;
customerNumber: string;
customerSequence?: number;
customerName?: string;
createDate: string;
recivedDate?: string;
posted?: number;
postedDate?: string;
orderDate?: string;
price?: number;
weight?: number;
warehouse?: string;
gate?: string;
recipientCode?: string;
senderCode?: string;
sellerCode?: string;
buyerCode?: string;
docType?: string;
slOrderNumber?: string;
sentToSl?: string;
translatedStatus?: string;
ediCustomerOrderLines: EdiCustomerOrderLineDto[];
ediCustomerOrderTranslates: EdiCustomerOrderTranslateDto[];
}
export interface EdiCustomerOrderLineDto {
customerOrderLine: number;
customerOrderNumber?: string;
item: string;
customerItemNumber: string;
description: string;
blanketQty?: number;
uom: string;
contPrice?: number;
translatedStatus?: string;
effectiveDate?: string;
boxType?: string;
address?: string;
finalDestination?: string;
qtyPerBox?: number;
ediCustomerOrderLineItems: EdiCustomerOrderLineItemDto[];
}
export interface EdiCustomerOrderLineItemDto {
customerOrderLine: number;
customerOrderRelease: number;
customerOrderNumber?: string;
item: string;
customerItem: string;
qtyOrdered?: number;
dueDate?: string;
translatedStatus?: string;
price?: number;
releaseDate?: string;
warehouse?: string;
documentType?: string;
taxCodeOne?: string;
uom?: string;
customerOrderCustomerNumber?: string;
description?: string;
routingCode?: string;
deliveryCallNumber?: string;
unloadingPoint?: string;
destinationPoint?: string;
palletCode?: string;
palletNumber?: string;
}
export interface EdiCustomerOrderTranslateDto {
id: number;
coEdiOrder: number;
coRowPointer: string;
coCoNum?: string;
coType?: string;
coTakenBy?: string;
ediCoCoNum?: string;
ordersCount: number;
orderFound: boolean;
createdDate: string;
foundNumbers?: string;
scheduleOrderId: number;
}
export interface ErrorLogDto {
trxNum: string;
seq: number;
lineNum: number;
releaseNum: number;
errDate?: string;
errNum?: number;
errMsg: string;
trxCode: string;
noteExistsFlag: boolean;
recordDate: string;
rowPointer: string;
createdBy: string;
updatedBy: string;
createDate: string;
inWorkflow: boolean;
}
export interface WzHeaderDto {
id: string;
fk_Client?: string;
createdDate: string;
emailAddresses?: string;
wzNumbers?: string;
wzRowsMeyle?: WzRowMeyleDto[];
wzRowsMarelli?: WzRowMarelliDto[];
}
export interface WzRowMeyleDto {
id: string;
fk_Header?: string;
orderNumber: string;
itemNumber: string;
quantity?: number;
palletNumber?: number;
wzNumber: string;
partNumber?: string;
transactionNumber?: number;
faIndex?: string;
partNumberSl?: string;
}
export interface WzRowMarelliDto {
id: string;
fkHeader?: string;
type: string;
palletNumber?: number;
itemNumber: string;
engineerNumber: string;
quantity?: number;
orderNumber: string;
wzNumber: string;
faIndex: string;
transactionNumber?: number;
}
export interface WzClientDto {
id: string;
customerNumber: string;
customerSequence?: number;
createdDate: string;
name: string;
shortName: string;
logoBase64?: string;
}
export interface MaterialTransactionDto {
mtGroup?: string;
mtGroupNum?: string;
transNum?: number;
item?: string;
transDate?: string;
qty?: number;
cost?: number;
whse?: string;
loc?: string;
refNum?: string;
refLineSuf?: number;
refRelease?: number;
reasonCode?: string;
transType?: string;
refType?: string;
mtReasonType?: string;
prefixId?: number;
sequenceId?: number;
whseSequenceId?: number;
whseSplit?: boolean;
variableId?: string;
formName?: string;
inWorkflow?: boolean;
noteExistsFlag?: boolean;
recordDate?: string;
rowPointer?: string;
createdBy?: string;
updatedBy?: string;
createDate?: string;
custNum?: string;
vendNum?: string;
recipNum?: number;
uf_FKR_internal_num_matltran_zn?: string;
session_Id?: string;
uf_MobileAppUser?: string;
nr_KARTY_KONTROLNEJ?: string;
}
export interface ItemCustDto {
item: string;
custNum: string;
custItemSeq: number;
custItem: string;
um: string;
uf_FKR_CustItem2?: string;
}
export interface GridFilterState {
columns: Record<string, unknown>;
}

View File

@@ -0,0 +1,58 @@
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { LoginResponse, UserDto } from '../models';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly tokenKey = 'authToken';
private readonly tokenSignal = signal<string | null>(this.readToken());
readonly token = this.tokenSignal.asReadonly();
readonly isAuthenticated = computed(() => !!this.tokenSignal());
readonly userName = signal<string>('');
private readToken(): string | null {
return sessionStorage.getItem(this.tokenKey);
}
async login(login: string, password: string): Promise<UserDto | null> {
try {
const result = await firstValueFrom(
this.http.post<LoginResponse>(`${environment.apiBaseUrl}/api/Users/login`, { login, password }),
);
if (!result?.token) return null;
sessionStorage.setItem(this.tokenKey, result.token);
this.tokenSignal.set(result.token);
const user = await this.getUserByUsername(login);
if (user) {
this.userName.set(user.login);
}
return user;
} catch {
return null;
}
}
async getUserByUsername(username: string): Promise<UserDto | null> {
try {
return await firstValueFrom(
this.http.get<UserDto>(`${environment.apiBaseUrl}/api/Users/by-username`, { params: { username } }),
);
} catch {
return null;
}
}
logout(): void {
sessionStorage.removeItem(this.tokenKey);
this.tokenSignal.set(null);
this.userName.set('');
}
getToken(): string | null {
return this.tokenSignal();
}
}

View File

@@ -0,0 +1,33 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { CustomerOrderDto } from '../models';
@Injectable({ providedIn: 'root' })
export class CustomerOrderService {
private readonly http = inject(HttpClient);
private readonly base = `${environment.apiBaseUrl}/api/CustomerOrders`;
getAll(): Promise<CustomerOrderDto[]> {
return firstValueFrom(this.http.get<CustomerOrderDto[]>(this.base));
}
getByOrderNumber(customerOrderNumber: string): Promise<CustomerOrderDto> {
return firstValueFrom(
this.http.get<CustomerOrderDto>(`${this.base}/by-order-number`, { params: { customerOrderNumber } }),
);
}
getByCoNumber(customerOrderNumber: string): Promise<CustomerOrderDto> {
return firstValueFrom(
this.http.get<CustomerOrderDto>(`${this.base}/by-co-number`, { params: { customerOrderNumber } }),
);
}
getByRowPointer(rowPointer: string): Promise<CustomerOrderDto> {
return firstValueFrom(
this.http.get<CustomerOrderDto>(`${this.base}/by-order-number`, { params: { customerOrderNumber: rowPointer } }),
);
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { EdiCustomerOrderTranslateDto } from '../models';
@Injectable({ providedIn: 'root' })
export class EdiCustomerOrderTranslateService {
private readonly http = inject(HttpClient);
private readonly base = `${environment.apiBaseUrl}/api/EdiCustomerOrdersTranslations`;
getAll(): Promise<EdiCustomerOrderTranslateDto[]> {
return firstValueFrom(this.http.get<EdiCustomerOrderTranslateDto[]>(this.base));
}
delete(id: number): Promise<void> {
return firstValueFrom(this.http.delete<void>(this.base, { params: { id: id.toString() } }));
}
}

View File

@@ -0,0 +1,44 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { EdiCustomerOrderDto, ErrorLogDto, ResponseModel } from '../models';
@Injectable({ providedIn: 'root' })
export class EdiCustomerOrderService {
private readonly http = inject(HttpClient);
private readonly base = `${environment.apiBaseUrl}/api/EdiCustomerOrders`;
getAll(): Promise<EdiCustomerOrderDto[]> {
return firstValueFrom(this.http.get<EdiCustomerOrderDto[]>(this.base));
}
getByOrderNumber(customerOrderNumber: string): Promise<EdiCustomerOrderDto> {
return firstValueFrom(
this.http.get<EdiCustomerOrderDto>(`${this.base}/by-order-number`, { params: { customerOrderNumber } }),
);
}
getByRowPointer(rowPointer: string): Promise<EdiCustomerOrderDto> {
return firstValueFrom(
this.http.get<EdiCustomerOrderDto>(`${this.base}/by-order-number`, { params: { customerOrderNumber: rowPointer } }),
);
}
async sendToSyteline(rowPointer: string, identifier: string, externalIdentifier?: string): Promise<ResponseModel> {
try {
await firstValueFrom(
this.http.post(`${this.base}/send-to-syteline`, null, { params: { customerOrderNumber: rowPointer } }),
);
return { status: 1, identifier, message: '', externalIdentifier: externalIdentifier ?? '' };
} catch {
const errors = await firstValueFrom(
this.http.get<ErrorLogDto[]>(`${environment.apiBaseUrl}/api/ErrorLog/by-order-number`, {
params: { customerOrderNumber: rowPointer },
}),
).catch(() => [] as ErrorLogDto[]);
const message = errors.map((e) => e.errMsg).join('\n');
return { status: 0, identifier, message, externalIdentifier: '' };
}
}
}

View File

@@ -0,0 +1,27 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { FunctionDto } from '../models';
@Injectable({ providedIn: 'root' })
export class FunctionService {
private readonly http = inject(HttpClient);
private readonly base = `${environment.apiBaseUrl}/api/Functions`;
getAll(): Promise<FunctionDto[]> {
return firstValueFrom(this.http.get<FunctionDto[]>(this.base));
}
create(fn: FunctionDto): Promise<FunctionDto> {
return firstValueFrom(this.http.post<FunctionDto>(this.base, fn));
}
update(fn: FunctionDto): Promise<FunctionDto> {
return firstValueFrom(this.http.put<FunctionDto>(this.base, fn));
}
delete(id: string): Promise<void> {
return firstValueFrom(this.http.delete<void>(this.base, { params: { id } }));
}
}

View File

@@ -0,0 +1,31 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { TaskSchedulerDto } from '../models';
@Injectable({ providedIn: 'root' })
export class HangfireService {
private readonly http = inject(HttpClient);
private readonly base = `${environment.apiBaseUrl}/api/HangfireJobs`;
getAll(): Promise<TaskSchedulerDto[]> {
return firstValueFrom(this.http.get<TaskSchedulerDto[]>(`${this.base}/`));
}
getById(id: string): Promise<TaskSchedulerDto> {
return firstValueFrom(this.http.get<TaskSchedulerDto>(`${this.base}/${id}`));
}
add(job: TaskSchedulerDto): Promise<void> {
return firstValueFrom(this.http.post<void>(`${this.base}/add`, job));
}
update(job: TaskSchedulerDto): Promise<void> {
return firstValueFrom(this.http.post<void>(`${this.base}/update`, job));
}
delete(job: TaskSchedulerDto): Promise<void> {
return firstValueFrom(this.http.post<void>(`${this.base}/delete`, job));
}
}

View File

@@ -0,0 +1,15 @@
import { Injectable, signal, computed } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class LoadingService {
private readonly pendingCount = signal(0);
readonly isLoading = computed(() => this.pendingCount() > 0);
show(): void {
this.pendingCount.update((c) => c + 1);
}
hide(): void {
this.pendingCount.update((c) => Math.max(0, c - 1));
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { ProductDto } from '../models';
@Injectable({ providedIn: 'root' })
export class ProductService {
private readonly http = inject(HttpClient);
private readonly base = `${environment.apiBaseUrl}/api/Product`;
getByIndex(indexName: string): Promise<ProductDto[]> {
return firstValueFrom(this.http.get<ProductDto[]>(`${this.base}/by-index`, { params: { indexName } }));
}
update(product: ProductDto): Promise<void> {
return firstValueFrom(this.http.put<void>(this.base, product));
}
}

View File

@@ -0,0 +1,27 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { RoleDto } from '../models';
@Injectable({ providedIn: 'root' })
export class RoleService {
private readonly http = inject(HttpClient);
private readonly base = `${environment.apiBaseUrl}/api/Roles`;
getAll(): Promise<RoleDto[]> {
return firstValueFrom(this.http.get<RoleDto[]>(this.base));
}
create(role: RoleDto): Promise<RoleDto> {
return firstValueFrom(this.http.post<RoleDto>(this.base, role));
}
update(role: RoleDto): Promise<RoleDto> {
return firstValueFrom(this.http.put<RoleDto>(this.base, role));
}
delete(id: string): Promise<void> {
return firstValueFrom(this.http.delete<void>(this.base, { params: { id } }));
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { ScheduleOrderDto } from '../models';
@Injectable({ providedIn: 'root' })
export class ScheduleOrderService {
private readonly http = inject(HttpClient);
private readonly base = `${environment.apiBaseUrl}/api/ScheduleOrders`;
getAll(): Promise<ScheduleOrderDto[]> {
return firstValueFrom(this.http.get<ScheduleOrderDto[]>(this.base));
}
getById(id: number): Promise<ScheduleOrderDto> {
return firstValueFrom(this.http.get<ScheduleOrderDto>(`${this.base}/${id}`));
}
}

View File

@@ -0,0 +1,31 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { UserDto } from '../models';
@Injectable({ providedIn: 'root' })
export class UserService {
private readonly http = inject(HttpClient);
private readonly base = `${environment.apiBaseUrl}/api/Users`;
getAll(): Promise<UserDto[]> {
return firstValueFrom(this.http.get<UserDto[]>(this.base));
}
getById(id: string): Promise<UserDto> {
return firstValueFrom(this.http.get<UserDto>(`${this.base}/by-id`, { params: { id } }));
}
create(user: UserDto): Promise<UserDto> {
return firstValueFrom(this.http.post<UserDto>(this.base, user));
}
update(user: UserDto): Promise<UserDto> {
return firstValueFrom(this.http.put<UserDto>(this.base, user));
}
delete(id: string): Promise<void> {
return firstValueFrom(this.http.delete<void>(this.base, { params: { id } }));
}
}

View File

@@ -0,0 +1,131 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import {
CustomerOrderDto,
ItemCustDto,
MaterialTransactionDto,
TransactionModel,
WzClientDto,
WzHeaderDto,
WzRowMarelliDto,
WzRowMeyleDto,
} from '../models';
@Injectable({ providedIn: 'root' })
export class WarehouseService {
private readonly http = inject(HttpClient);
private readonly base = environment.apiBaseUrl;
getWzHeaderById(id: string): Promise<WzHeaderDto> {
return firstValueFrom(this.http.get<WzHeaderDto>(`${this.base}/api/WzHeader/by-id`, { params: { id } }));
}
getAllClients(): Promise<WzClientDto[]> {
return firstValueFrom(this.http.get<WzClientDto[]>(`${this.base}/api/WzClient`));
}
getAllClientWzs(customerNumber: string, customerSequence: number): Promise<MaterialTransactionDto[]> {
return firstValueFrom(
this.http.get<MaterialTransactionDto[]>(`${this.base}/api/WzHeader/by-customer-number`, {
params: { customerNumber, customerSequence: customerSequence.toString() },
}),
);
}
getAllClientWzHeaders(customerNumber: string, customerSequence: number): Promise<WzHeaderDto[]> {
return firstValueFrom(
this.http.get<WzHeaderDto[]>(`${this.base}/api/WzHeader/all-wz-headers`, {
params: { customerNumber, customerSequence: customerSequence.toString() },
}),
);
}
createWzHeader(header: WzHeaderDto): Promise<void> {
return firstValueFrom(this.http.post<void>(`${this.base}/api/WzHeader`, header));
}
createWzRowsMeyle(rows: WzRowMeyleDto[]): Promise<void> {
return firstValueFrom(this.http.post<void>(`${this.base}/api/WzRowMeyle`, rows));
}
createWzRowsMarelli(rows: WzRowMarelliDto[]): Promise<void> {
return firstValueFrom(this.http.post<void>(`${this.base}/api/WzRowMarelli`, rows));
}
getCustomerOrder(customerOrderNumber: string): Promise<CustomerOrderDto> {
return firstValueFrom(
this.http.get<CustomerOrderDto>(`${this.base}/api/CustomerOrders/by-co-number`, {
params: { customerOrderNumber },
}),
);
}
getItem(itemNumber: string, customerNumber: string): Promise<ItemCustDto> {
return firstValueFrom(
this.http.get<ItemCustDto>(`${this.base}/api/ItemCust`, { params: { itemNumber, customerNumber } }),
);
}
getWzRowsMeyleByWzHeaderId(wzHeaderId: string): Promise<WzRowMeyleDto[]> {
return firstValueFrom(
this.http.get<WzRowMeyleDto[]>(`${this.base}/api/WzRowMeyle/by-wz-header-id`, {
params: { wzHeaderId },
}),
);
}
getWzRowsMarelliByWzHeaderId(wzHeaderId: string): Promise<WzRowMarelliDto[]> {
return firstValueFrom(
this.http.get<WzRowMarelliDto[]>(`${this.base}/api/WzRowMarelli/by-wz-header-id`, {
params: { wzHeaderId },
}),
);
}
async getTransactionsModels(): Promise<Record<string, TransactionModel[]>> {
const materialTransactions = await firstValueFrom(
this.http.get<MaterialTransactionDto[]>(`${this.base}/api/WzRowMeyle/transactions-with-part-number`),
);
const result: Record<string, TransactionModel[]> = {};
for (const mt of materialTransactions) {
const key = mt.nr_KARTY_KONTROLNEJ ?? '';
if (!result[key]) result[key] = [];
result[key].push({
itemNumber: mt.item,
partNumber: mt.nr_KARTY_KONTROLNEJ,
quantity: mt.qty,
});
}
return result;
}
updateWzRowsMeyle(rows: WzRowMeyleDto[]): Promise<void> {
return firstValueFrom(this.http.put<void>(`${this.base}/api/WzRowMeyle`, rows));
}
updateWzRowsMarelli(rows: WzRowMarelliDto[]): Promise<void> {
return firstValueFrom(this.http.put<void>(`${this.base}/api/WzRowMarelli`, rows));
}
generateXlsForMeyle(packListId: string): Promise<void> {
return firstValueFrom(
this.http.get<void>(`${this.base}/api/ExcelGenerator/generate-meyle`, { params: { packListId } }),
);
}
generateXlsForMarelli(packListId: string): Promise<void> {
return firstValueFrom(
this.http.get<void>(`${this.base}/api/ExcelGenerator/generate-marelli`, { params: { packListId } }),
);
}
addEmailsToWzHeader(wzHeaderId: string, emailAddresses: string): Promise<void> {
return firstValueFrom(
this.http.post<void>(`${this.base}/api/WzHeader/add-emails`, emailAddresses, {
params: { id: wzHeaderId },
}),
);
}
}

View File

@@ -0,0 +1,162 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { DatePipe } from '@angular/common';
import { RowToggler, SelectableRow, TableModule, TableRowSelectEvent } from 'primeng/table';
import { ButtonDirective } from 'primeng/button';
import { CustomerOrderDto, CustomerOrderLineDto, CustomerOrderLineItemDto, ScheduleOrderDto } from '../core/models';
import { CustomerOrderService } from '../core/services/customer-order.service';
import { ScheduleOrderService } from '../core/services/schedule-order.service';
import { ScheduleOrdersGridComponent } from '../shared/components/schedule-orders-grid/schedule-orders-grid.component';
import { PageShellComponent } from '../shared/components/page-shell/page-shell.component';
import { ContentSectionComponent } from '../shared/components/content-section/content-section.component';
@Component({
selector: 'app-customer-order-detail',
standalone: true,
imports: [TableModule, RowToggler, SelectableRow, ButtonDirective, DatePipe, ScheduleOrdersGridComponent, PageShellComponent, ContentSectionComponent],
template: `
<app-page-shell
[title]="'Zamówienie klienta nr ' + (order()?.coNum ?? 'Brak numeru')"
subtitle="Szczegóły zamówienia klienta w systemie Syteline"
icon="pi pi-shopping-cart"
>
<app-content-section title="Informacje o zamówieniu" icon="pi pi-info-circle">
<div class="form-grid">
<div>
<u>Numer Zamówienia:</u> <b>{{ order()?.coNum }}</b><br />
<u>Numer Zamówienia Klienta:</u> <b>{{ order()?.custPo }}</b><br />
<u>Klient:</u> <b>{{ order()?.custNum }}</b><br />
<u>Numer Odbiorcy:</u> <b>{{ order()?.custSeq }}</b><br />
<u>Kontakt:</u> <b>{{ order()?.contact }}</b><br />
<u>Telefon:</u> <b>{{ order()?.phone }}</b><br />
<u>Data Zamówienia:</u> <b>{{ order()?.orderDate | date: 'yyyy-MM-dd HH:mm:ss' }}</b><br />
<u>Warunki:</u> <b>{{ order()?.termsCode }}</b><br />
<u>Wartość Brutto:</u> <b>{{ order()?.price?.toFixed(2) ?? 'N/A' }}</b><br />
<u>Status:</u> <b>{{ order()?.translatedStatus }}</b>
</div>
<div>
<u>Magazyn:</u> <b>{{ order()?.whse }}</b><br />
<u>VAT:</u> <b>{{ order()?.frtTaxCode1 }}</b><br />
<u>Typ Odbiorcy:</u> <b>{{ order()?.endUserType }}</b><br />
<u>Kurs Wymiany:</u> <b>{{ order()?.exchRate?.toFixed(4) ?? 'N/A' }}</b><br />
<u>Gate:</u> <b>{{ order()?.uf_FKR_EDI_Gate }}</b><br />
<u>RecipientCode:</u> <b>{{ order()?.uf_FKR_EDI_RecipientCode }}</b><br />
<u>SellerCode:</u> <b>{{ order()?.uf_FKR_EDI_SellerCode }}</b><br />
<u>SenderCode:</u> <b>{{ order()?.uf_FKR_EDI_SenderCode }}</b><br />
<u>BuyerCode:</u> <b>{{ order()?.uf_FKR_EDI_BuyerCode }}</b><br />
<u>Typ Dokumentu:</u> <b>{{ order()?.uf_DocType }}</b>
</div>
</div>
</app-content-section>
<app-content-section title="Powiązane zamówienia DELFOR" icon="pi pi-building">
<button pButton type="button" (click)="toggleDelfors()">{{ delforButtonText() }}</button>
@if (showDelfors()) {
<div class="mt-3">
<app-schedule-orders-grid [gridData]="scheduleOrders()" [pageSize]="5" />
</div>
}
</app-content-section>
<app-content-section title="Indeksy" icon="pi pi-list" [flush]="true">
<p-table [value]="lines()" dataKey="coLine" [paginator]="true" [rows]="10" (onRowSelect)="onLineSelect($event)" selectionMode="single" [(selection)]="selectedLine" styleClass="p-datatable-sm" [showGridlines]="true" [stripedRows]="true">
<ng-template #header>
<tr><th style="width:3rem"></th><th>Linia</th><th>Pozycja</th><th>Pozycja Klienta</th><th>Opis</th><th>Ilość</th><th>J/M</th><th>Cena</th><th>Status</th></tr>
</ng-template>
<ng-template #body let-line let-expanded="expanded">
<tr [pSelectableRow]="line">
<td><button pButton type="button" [text]="true" [rounded]="true" [iconOnly]="true" [pRowToggler]="line"><i [class]="expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"></i></button></td>
<td>{{ line.coLine }}</td><td>{{ line.item }}</td><td>{{ line.custItem }}</td><td>{{ line.description }}</td>
<td>{{ line.blanketQty?.toFixed(2) }}</td><td>{{ line.um }}</td><td>{{ line.contPrice }}</td><td>{{ line.translatedStatus }}</td>
</tr>
</ng-template>
<ng-template #expandedrow let-line>
<tr><td colspan="9">
<div class="form-grid">
<div>
<h6>Szczegóły</h6>
<u>Numer zamówienia:</u> <b>{{ line.coNum }}</b><br />
<u>Linia:</u> <b>{{ line.coLine }}</b><br />
<u>Pozycja:</u> <b>{{ line.item }}</b><br />
<u>Pozycja Klienta:</u> <b>{{ line.custItem }}</b><br />
<u>Opis:</u> <b>{{ line.description }}</b><br />
<u>Łączna Ilość:</u> <b>{{ line.blanketQty?.toFixed(2) }}</b><br />
<u>Status:</u> <b>{{ line.translatedStatus }}</b>
</div>
<div>
<u>Cena:</u> <b>{{ line.contPrice?.toFixed(2) ?? 'N/A' }}</b><br />
<u>Ważne Od:</u> <b>{{ line.effDate | date: 'dd.MM.yyyy' }}</b><br />
<u>J/M:</u> <b>{{ line.um }}</b><br />
<u>BoxType:</u> <b>{{ line.uf_FKR_EDI_BLN_BoxType }}</b><br />
<u>Address:</u> <b>{{ line.uf_FKR_EDI_BLN_Address }}</b><br />
<u>FinalDestination:</u> <b>{{ line.uf_FKR_EDI_BLN_FinalDestination }}</b><br />
<u>QtyPerBox:</u> <b>{{ line.uf_FKR_EDI_BLN_QtyPerBox }}</b>
</div>
</div>
</td></tr>
</ng-template>
</p-table>
</app-content-section>
@if (lineItems().length) {
<app-content-section title="Harmonogramy" icon="pi pi-calendar" [flush]="true">
<p-table [value]="lineItems()" [paginator]="true" [rows]="10" styleClass="p-datatable-sm" [showGridlines]="true" [stripedRows]="true">
<ng-template #header>
<tr><th>Linia</th><th>Zwolnienie</th><th>Pozycja</th><th>Pozycja Klienta</th><th>Łączna Ilość</th><th>Data Wykonania</th><th>Status</th></tr>
</ng-template>
<ng-template #body let-item>
<tr>
<td>{{ item.coLine }}</td><td>{{ item.coRelease }}</td><td>{{ item.item }}</td><td>{{ item.custItem }}</td>
<td>{{ item.qtyOrdered?.toFixed(2) }}</td><td>{{ item.dueDate | date: 'dd.MM.yyyy' }}</td><td>{{ item.translatedStatus }}</td>
</tr>
</ng-template>
</p-table>
</app-content-section>
}
</app-page-shell>
`,
})
export class CustomerOrderDetailComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly customerOrderService = inject(CustomerOrderService);
private readonly scheduleOrderService = inject(ScheduleOrderService);
readonly order = signal<CustomerOrderDto | null>(null);
readonly lines = signal<CustomerOrderLineDto[]>([]);
readonly lineItems = signal<CustomerOrderLineItemDto[]>([]);
readonly scheduleOrders = signal<ScheduleOrderDto[]>([]);
readonly showDelfors = signal(false);
readonly delforButtonText = signal('Pokaż powiązane zamówienia DELFOR');
selectedLine: CustomerOrderLineDto | null = null;
async ngOnInit(): Promise<void> {
const id = this.route.snapshot.paramMap.get('customerOrderId') ?? '';
const data = await this.customerOrderService.getByRowPointer(id);
this.order.set(data);
this.lines.set(data.customerOrderLines ?? []);
}
onLineSelect(event: TableRowSelectEvent<CustomerOrderLineDto>): void {
const data = event.data;
if (data && !Array.isArray(data)) {
this.lineItems.set(data.customerOrderLineItems ?? []);
}
}
async toggleDelfors(): Promise<void> {
if (this.showDelfors()) {
this.showDelfors.set(false);
this.delforButtonText.set('Pokaż powiązane zamówienia DELFOR');
this.scheduleOrders.set([]);
return;
}
const order = this.order();
const ids = [...new Set((order?.ediCustomerOrderTranslates ?? []).map((x) => x.scheduleOrderId))];
if (ids.length) {
const all = await this.scheduleOrderService.getAll();
this.scheduleOrders.set(all.filter((x) => ids.includes(x.id)).sort((a, b) => new Date(b.lastUpdateDate).getTime() - new Date(a.lastUpdateDate).getTime()));
}
this.showDelfors.set(true);
this.delforButtonText.set('Ukryj');
}
}

View File

@@ -0,0 +1,74 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { ConfirmationService } from 'primeng/api';
import { TableModule } from 'primeng/table';
import { ButtonDirective } from 'primeng/button';
import { DatePipe } from '@angular/common';
import { EdiCustomerOrderTranslateDto } from '../core/models';
import { EdiCustomerOrderTranslateService } from '../core/services/edi-customer-order-translate.service';
import { PageShellComponent } from '../shared/components/page-shell/page-shell.component';
import { ContentSectionComponent } from '../shared/components/content-section/content-section.component';
@Component({
selector: 'app-customer-orders-translations',
standalone: true,
imports: [TableModule, ButtonDirective, DatePipe, PageShellComponent, ContentSectionComponent],
template: `
<app-page-shell
title="Zarządzanie powiązaniami zamówień z DELFORami"
subtitle="Powiązania między zamówieniami EDI, SL i harmonogramami DELFOR"
icon="pi pi-link"
>
<app-content-section title="Powiązania" icon="pi pi-sitemap" [flush]="true">
<p-table [value]="translations()" [paginator]="true" [rows]="10" dataKey="id" styleClass="p-datatable-sm" [showGridlines]="true" [stripedRows]="true">
<ng-template #header>
<tr>
<th>DELFOR Id</th>
<th>Numer zamówienia EDI</th>
<th>Numer zamówienia SL</th>
<th>Data utworzenia</th>
<th style="width: 6rem"></th>
</tr>
</ng-template>
<ng-template #body let-row>
<tr>
<td>{{ row.scheduleOrderId }}</td>
<td>{{ row.ediCoCoNum }}</td>
<td>{{ row.coCoNum }}</td>
<td>{{ row.createdDate | date: 'dd.MM.yyyy HH:mm' }}</td>
<td><button pButton type="button" severity="danger" [text]="true" [iconOnly]="true" (click)="confirmDelete(row)"><i class="pi pi-trash"></i></button></td>
</tr>
</ng-template>
</p-table>
</app-content-section>
</app-page-shell>
`,
})
export class CustomerOrdersTranslationsComponent implements OnInit {
private readonly translateService = inject(EdiCustomerOrderTranslateService);
private readonly confirmation = inject(ConfirmationService);
readonly translations = signal<EdiCustomerOrderTranslateDto[]>([]);
async ngOnInit(): Promise<void> { await this.load(); }
confirmDelete(row: EdiCustomerOrderTranslateDto): void {
this.confirmation.confirm({
message: 'Czy na pewno usunąć to powiązanie?',
header: 'Potwierdzenie',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Tak',
rejectLabel: 'Anuluj',
accept: () => this.delete(row),
});
}
private async delete(row: EdiCustomerOrderTranslateDto): Promise<void> {
await this.translateService.delete(row.id);
await this.load();
}
private async load(): Promise<void> {
const data = await this.translateService.getAll();
this.translations.set([...data].sort((a, b) => new Date(b.createdDate).getTime() - new Date(a.createdDate).getTime()));
}
}

View File

@@ -0,0 +1,93 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { Router } from '@angular/router';
import { DatePipe } from '@angular/common';
import { RowToggler, TableModule } from 'primeng/table';
import { ButtonDirective } from 'primeng/button';
import { CustomerOrderDto } from '../core/models';
import { CustomerOrderService } from '../core/services/customer-order.service';
import { PageShellComponent } from '../shared/components/page-shell/page-shell.component';
import { ContentSectionComponent } from '../shared/components/content-section/content-section.component';
@Component({
selector: 'app-customer-orders-list',
standalone: true,
imports: [TableModule, RowToggler, ButtonDirective, DatePipe, PageShellComponent, ContentSectionComponent],
template: `
<app-page-shell
title="Zamówienia Klienta"
subtitle="Przegląd zamówień klientów w systemie Syteline"
icon="pi pi-shopping-cart"
>
<app-content-section title="Lista zamówień" icon="pi pi-list" [flush]="true">
<p-table [value]="orders()" [paginator]="true" [rows]="10" dataKey="rowPointer" styleClass="p-datatable-sm" [showGridlines]="true" [stripedRows]="true">
<ng-template #header>
<tr>
<th style="width: 3rem"></th>
<th pSortableColumn="coNum">Numer Zamówienia</th>
<th pSortableColumn="custPo">Zamówienie Klienta</th>
<th pSortableColumn="custNum">Numer Klienta</th>
<th pSortableColumn="custSeq">Odbiorca</th>
<th pSortableColumn="createDate">Data zamówienia</th>
<th pSortableColumn="translatedStatus">Status</th>
</tr>
</ng-template>
<ng-template #body let-order let-expanded="expanded">
<tr (dblclick)="openDetail(order.rowPointer)">
<td><button pButton type="button" [text]="true" [rounded]="true" [iconOnly]="true" [pRowToggler]="order"><i [class]="expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"></i></button></td>
<td>{{ order.coNum }}</td>
<td>{{ order.custPo }}</td>
<td>{{ order.custNum }}</td>
<td>{{ order.custSeq }}</td>
<td>{{ order.createDate | date: 'dd.MM.yyyy HH:mm:ss' }}</td>
<td>{{ order.translatedStatus }}</td>
</tr>
</ng-template>
<ng-template #expandedrow let-order>
<tr><td colspan="7">
<div class="form-grid">
<div>
<u>Numer Zamówienia:</u> <b>{{ order.coNum }}</b><br />
<u>Numer Zamówienia Klienta:</u> <b>{{ order.custPo }}</b><br />
<u>Klient:</u> <b>{{ order.custNum }}</b><br />
<u>Numer Odbiorcy:</u> <b>{{ order.custSeq }}</b><br />
<u>Kontakt:</u> <b>{{ order.contact }}</b><br />
<u>Telefon:</u> <b>{{ order.phone }}</b><br />
<u>Data Zamówienia:</u> <b>{{ order.orderDate | date: 'yyyy-MM-dd HH:mm:ss' }}</b><br />
<u>Warunki:</u> <b>{{ order.termsCode }}</b><br />
<u>Wartość Brutto:</u> <b>{{ order.price?.toFixed(2) ?? 'N/A' }}</b><br />
<u>Status:</u> <b>{{ order.translatedStatus }}</b>
</div>
<div>
<u>Magazyn:</u> <b>{{ order.whse }}</b><br />
<u>VAT:</u> <b>{{ order.frtTaxCode1 }}</b><br />
<u>Typ Odbiorcy:</u> <b>{{ order.endUserType }}</b><br />
<u>Kurs Wymiany:</u> <b>{{ order.exchRate?.toFixed(4) ?? 'N/A' }}</b><br />
<u>Gate:</u> <b>{{ order.uf_FKR_EDI_Gate }}</b><br />
<u>RecipientCode:</u> <b>{{ order.uf_FKR_EDI_RecipientCode }}</b><br />
<u>SellerCode:</u> <b>{{ order.uf_FKR_EDI_SellerCode }}</b><br />
<u>SenderCode:</u> <b>{{ order.uf_FKR_EDI_SenderCode }}</b><br />
<u>BuyerCode:</u> <b>{{ order.uf_FKR_EDI_BuyerCode }}</b><br />
<u>Typ Dokumentu:</u> <b>{{ order.uf_DocType }}</b>
</div>
</div>
</td></tr>
</ng-template>
</p-table>
</app-content-section>
</app-page-shell>
`,
})
export class CustomerOrdersListComponent implements OnInit {
private readonly customerOrderService = inject(CustomerOrderService);
private readonly router = inject(Router);
readonly orders = signal<CustomerOrderDto[]>([]);
async ngOnInit(): Promise<void> {
const data = await this.customerOrderService.getAll();
this.orders.set([...data].sort((a, b) => new Date(b.createDate).getTime() - new Date(a.createDate).getTime()));
}
openDetail(id: string): void {
this.router.navigate(['/Admin/PK/CustomerOrder', id]);
}
}

View File

@@ -0,0 +1,128 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { DatePipe } from '@angular/common';
import { RowToggler, SelectableRow, TableModule, TableRowSelectEvent } from 'primeng/table';
import { ButtonDirective } from 'primeng/button';
import { EdiCustomerOrderDto, EdiCustomerOrderLineDto, EdiCustomerOrderLineItemDto } from '../core/models';
import { EdiCustomerOrderService } from '../core/services/edi-customer-order.service';
import { PageShellComponent } from '../shared/components/page-shell/page-shell.component';
import { ContentSectionComponent } from '../shared/components/content-section/content-section.component';
@Component({
selector: 'app-edi-customer-order-detail',
standalone: true,
imports: [TableModule, RowToggler, SelectableRow, ButtonDirective, DatePipe, PageShellComponent, ContentSectionComponent],
template: `
<app-page-shell
[title]="'Zamówienie klienta EDI nr ' + (order()?.customerOrderNumber ?? 'Brak numeru')"
subtitle="Szczegóły zamówienia otrzymanego przez EDI"
icon="pi pi-database"
>
<app-content-section title="Informacje o zamówieniu" icon="pi pi-info-circle">
<div class="form-grid">
<div>
<u>Numer zamówienia EDI:</u> <b>{{ order()?.customerOrderNumber }}</b><br />
<u>Numer zamówienia Klienta:</u> <b>{{ order()?.customerPoNumber }}</b><br />
<u>Numer klienta:</u> <b>{{ order()?.customerNumber }}</b><br />
<u>Klient:</u> <b>{{ order()?.customerName }}</b><br />
<u>Numer odbiorcy:</u> <b>{{ order()?.customerSequence ?? 'N/A' }}</b><br />
<u>Data otrzymania:</u> <b>{{ order()?.recivedDate | date: 'dd.MM.yyyy' }}</b><br />
<u>Wysłano do Syteline?:</u> <b>{{ (order()?.posted ?? 0) === 0 ? 'NIE' : 'TAK' }}</b><br />
<u>Data wysyłki do Syteline:</u> <b>{{ order()?.postedDate | date: 'dd.MM.yyyy' }}</b><br />
<u>Data zamówienia:</u> <b>{{ order()?.orderDate | date: 'dd.MM.yyyy' }}</b>
</div>
<div>
<u>Cena:</u> <b>{{ order()?.price?.toFixed(2) ?? 'N/A' }}</b><br />
<u>Waga:</u> <b>{{ order()?.weight?.toFixed(2) ?? 'N/A' }}</b><br />
<u>Magazyn:</u> <b>{{ order()?.warehouse }}</b><br />
<u>Gate:</u> <b>{{ order()?.gate }}</b><br />
<u>Kod odbiorcy:</u> <b>{{ order()?.recipientCode }}</b><br />
<u>Kod wysyłającego:</u> <b>{{ order()?.senderCode }}</b><br />
<u>Kod sprzedawcy:</u> <b>{{ order()?.sellerCode }}</b><br />
<u>Kod kupującego:</u> <b>{{ order()?.buyerCode }}</b><br />
<u>Typ dokumentu:</u> <b>{{ order()?.docType }}</b>
</div>
</div>
</app-content-section>
<app-content-section title="Indeksy" icon="pi pi-list" [flush]="true">
<p-table [value]="lines()" dataKey="customerOrderLine" [paginator]="true" [rows]="10" selectionMode="single" [(selection)]="selectedLine" (onRowSelect)="onLineSelect($event)" styleClass="p-datatable-sm" [showGridlines]="true" [stripedRows]="true">
<ng-template #header>
<tr><th style="width:3rem"></th><th>Linia</th><th>Pozycja</th><th>Pozycja Klienta</th><th>Opis</th><th>Ilość</th><th>J/M</th><th>Cena</th><th>Status</th></tr>
</ng-template>
<ng-template #body let-line let-expanded="expanded">
<tr [pSelectableRow]="line">
<td><button pButton type="button" [text]="true" [rounded]="true" [iconOnly]="true" [pRowToggler]="line"><i [class]="expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"></i></button></td>
<td>{{ line.customerOrderLine }}</td><td>{{ line.item }}</td><td>{{ line.customerItemNumber }}</td><td>{{ line.description }}</td>
<td>{{ line.blanketQty?.toFixed(2) }}</td><td>{{ line.uom }}</td><td>{{ line.contPrice }}</td><td>{{ line.translatedStatus }}</td>
</tr>
</ng-template>
<ng-template #expandedrow let-line>
<tr><td colspan="9">
<div class="form-grid">
<div>
<h6>Szczegóły</h6>
<u>Numer zamówienia EDI:</u> <b>{{ line.customerOrderNumber }}</b><br />
<u>Linia:</u> <b>{{ line.customerOrderLine }}</b><br />
<u>Pozycja:</u> <b>{{ line.item }}</b><br />
<u>Pozycja Klienta:</u> <b>{{ line.customerItemNumber }}</b><br />
<u>Opis:</u> <b>{{ line.description }}</b><br />
<u>Łączna Ilość:</u> <b>{{ line.blanketQty?.toFixed(2) ?? 'N/A' }}</b><br />
<u>Status:</u> <b>{{ line.translatedStatus }}</b>
</div>
<div>
<u>Cena:</u> <b>{{ line.contPrice?.toFixed(2) ?? 'N/A' }}</b><br />
<u>Ważne Od:</u> <b>{{ line.effectiveDate | date: 'dd.MM.yyyy' }}</b><br />
<u>J/M:</u> <b>{{ line.uom }}</b><br />
<u>BoxType:</u> <b>{{ line.boxType }}</b><br />
<u>Address:</u> <b>{{ line.address }}</b><br />
<u>FinalDestination:</u> <b>{{ line.finalDestination }}</b><br />
<u>QtyPerBox:</u> <b>{{ line.qtyPerBox }}</b>
</div>
</div>
</td></tr>
</ng-template>
</p-table>
</app-content-section>
@if (lineItems().length) {
<app-content-section title="Harmonogramy" icon="pi pi-calendar" [flush]="true">
<p-table [value]="lineItems()" [paginator]="true" [rows]="10" styleClass="p-datatable-sm" [showGridlines]="true" [stripedRows]="true">
<ng-template #header>
<tr><th>Linia</th><th>Zwolnienie</th><th>Pozycja</th><th>Pozycja Klienta</th><th>Łączna Ilość</th><th>Data Wykonania</th><th>Status</th></tr>
</ng-template>
<ng-template #body let-item>
<tr>
<td>{{ item.customerOrderLine }}</td><td>{{ item.customerOrderRelease }}</td><td>{{ item.item }}</td><td>{{ item.customerItem }}</td>
<td>{{ item.qtyOrdered?.toFixed(2) }}</td><td>{{ item.dueDate | date: 'dd.MM.yyyy' }}</td><td>{{ item.translatedStatus }}</td>
</tr>
</ng-template>
</p-table>
</app-content-section>
}
</app-page-shell>
`,
})
export class EdiCustomerOrderDetailComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly ediService = inject(EdiCustomerOrderService);
readonly order = signal<EdiCustomerOrderDto | null>(null);
readonly lines = signal<EdiCustomerOrderLineDto[]>([]);
readonly lineItems = signal<EdiCustomerOrderLineItemDto[]>([]);
selectedLine: EdiCustomerOrderLineDto | null = null;
async ngOnInit(): Promise<void> {
const id = this.route.snapshot.paramMap.get('customerOrderId') ?? '';
const data = await this.ediService.getByRowPointer(id);
this.order.set(data);
this.lines.set(data.ediCustomerOrderLines ?? []);
}
onLineSelect(event: TableRowSelectEvent<EdiCustomerOrderLineDto>): void {
const data = event.data;
if (data && !Array.isArray(data)) {
this.lineItems.set(data.ediCustomerOrderLineItems ?? []);
}
}
}

View File

@@ -0,0 +1,109 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { Router } from '@angular/router';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TableModule, TableCheckbox, TableHeaderCheckbox } from 'primeng/table';
import { ButtonDirective } from 'primeng/button';
import { ToggleSwitchModule } from 'primeng/toggleswitch';
import { DialogModule } from 'primeng/dialog';
import { EdiCustomerOrderDto, ResponseModel } from '../core/models';
import { EdiCustomerOrderService } from '../core/services/edi-customer-order.service';
import { PageShellComponent } from '../shared/components/page-shell/page-shell.component';
import { ContentSectionComponent } from '../shared/components/content-section/content-section.component';
@Component({
selector: 'app-edi-customer-orders-list',
standalone: true,
imports: [TableModule, TableCheckbox, TableHeaderCheckbox, ButtonDirective, ToggleSwitchModule, DialogModule, FormsModule, DatePipe, PageShellComponent, ContentSectionComponent],
template: `
<app-page-shell
title="Zamówienia Klienta EDI"
subtitle="Zamówienia otrzymane przez EDI i księgowanie do Syteline"
icon="pi pi-database"
>
<app-content-section title="Lista zamówień EDI" icon="pi pi-list" [flush]="true">
<div class="data-toolbar mb-3">
<div class="data-toolbar__left">
<label class="font-medium">Pokaż wszystkie</label>
<p-toggleswitch [(ngModel)]="showAll" (ngModelChange)="loadData()" />
<span class="text-secondary">{{ showAll ? 'Pokaż wszystkie' : 'Pokaż tylko Wysłane do SL' }}</span>
</div>
<div class="data-toolbar__right">
@if (showPostButton()) {
<button pButton type="button" (click)="sendToSyteline()">{{ postButtonText() }}</button>
}
</div>
</div>
<p-table [value]="orders()" [(selection)]="selectedOrders" dataKey="rowPointer" [paginator]="true" [rows]="10" selectionMode="multiple" (selectionChange)="onSelectionChange()" styleClass="p-datatable-sm" [showGridlines]="true" [stripedRows]="true">
<ng-template #header>
<tr><th style="width:3rem"><p-table-header-checkbox /></th><th>Numer Zamówienia</th><th>Zamówienie Klienta</th><th>Numer Klienta</th><th>Odbiorca</th><th>Data Otrzymania</th><th>Zamówienie SL</th><th>Wysłane do SL</th></tr>
</ng-template>
<ng-template #body let-order>
<tr (dblclick)="openDetail(order.rowPointer)">
<td><p-table-checkbox [value]="order" /></td>
<td>{{ order.customerOrderNumber }}</td><td>{{ order.customerPoNumber }}</td><td>{{ order.customerNumber }}</td>
<td>{{ order.customerSequence }}</td><td>{{ order.createDate | date: 'dd.MM.yyyy' }}</td>
<td>{{ order.slOrderNumber }}</td><td>{{ order.sentToSl }}</td>
</tr>
</ng-template>
</p-table>
</app-content-section>
</app-page-shell>
<p-dialog [header]="postButtonText()" [(visible)]="showResultDialog" [modal]="true" [style]="{ width: '500px' }">
@for (r of responses(); track r.identifier) {
@if (r.status === 1) {
<p>Zamówienie EDI {{ r.identifier }} zostało poprawnie zaksięgowane w Zamówieniach klienta pod numerem '{{ r.externalIdentifier }}'</p>
} @else {
<p>Błąd: Zamówienie EDI {{ r.identifier }} nie zostało poprawnie zaksięgowane w Zamówieniach klienta.<br />Lista błędów:<br />{{ r.message }}</p>
}
}
<ng-template #footer><button pButton type="button" (click)="showResultDialog = false">OK</button></ng-template>
</p-dialog>
`,
})
export class EdiCustomerOrdersListComponent implements OnInit {
private readonly ediService = inject(EdiCustomerOrderService);
private readonly router = inject(Router);
readonly orders = signal<EdiCustomerOrderDto[]>([]);
readonly responses = signal<ResponseModel[]>([]);
readonly showPostButton = signal(false);
readonly postButtonText = signal('Księguj bieżący');
showAll = false;
selectedOrders: EdiCustomerOrderDto[] = [];
showResultDialog = false;
async ngOnInit(): Promise<void> { await this.loadData(); }
async loadData(): Promise<void> {
let data = await this.ediService.getAll();
if (!this.showAll) data = data.filter((x) => x.posted === 0);
this.orders.set([...data].sort((a, b) => new Date(b.createDate).getTime() - new Date(a.createDate).getTime()));
}
onSelectionChange(): void {
this.showPostButton.set(this.selectedOrders.some((x) => x.posted === 0));
this.postButtonText.set(this.selectedOrders.length > 1 ? 'Księguj zaznaczone' : 'Księguj bieżący');
}
async sendToSyteline(): Promise<void> {
if (!this.selectedOrders.length) return;
const results: ResponseModel[] = [];
for (const order of this.selectedOrders) {
const response = await this.ediService.sendToSyteline(order.rowPointer, order.customerOrderNumber);
if (response.status === 1) {
response.externalIdentifier = order.ediCustomerOrderTranslates?.[0]?.coCoNum ?? '';
}
results.push(response);
}
this.responses.set(results);
this.showResultDialog = true;
this.showPostButton.set(false);
await this.loadData();
}
openDetail(id: string): void { this.router.navigate(['/Admin/PK/EdiCustomerOrder', id]); }
}

View File

@@ -0,0 +1,33 @@
import { Component } from '@angular/core';
import { PageShellComponent } from '../shared/components/page-shell/page-shell.component';
import { ContentSectionComponent } from '../shared/components/content-section/content-section.component';
@Component({
selector: 'app-error',
standalone: true,
imports: [PageShellComponent, ContentSectionComponent],
template: `
<app-page-shell title="Error" subtitle="An error occurred while processing your request" icon="pi pi-exclamation-triangle">
<app-content-section>
<h1 class="error-title">Error.</h1>
<h2 class="error-subtitle">An error occurred while processing your request.</h2>
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the
<strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
</app-content-section>
</app-page-shell>
`,
styles: `
.error-title { color: var(--color-error); margin: 0 0 var(--spacing-2); }
.error-subtitle { color: var(--color-error); font-size: var(--font-size-lg); margin: 0 0 var(--spacing-4); }
`,
})
export class ErrorComponent {}

View File

@@ -0,0 +1,104 @@
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ProgressSpinner } from 'primeng/progressspinner';
import { ToastModule } from 'primeng/toast';
import { ConfirmDialogModule } from 'primeng/confirmdialog';
import { SidebarComponent } from '../sidebar/sidebar.component';
import { TopbarComponent } from '../topbar/topbar.component';
import { LoadingService } from '../../core/services/loading.service';
@Component({
selector: 'app-main-layout',
standalone: true,
imports: [RouterOutlet, SidebarComponent, TopbarComponent, ProgressSpinner, ToastModule, ConfirmDialogModule],
template: `
<p-toast position="top-right" />
<p-confirm-dialog />
@if (loading.isLoading()) {
<div class="loading-overlay" role="status" aria-live="polite" aria-label="Ładowanie">
<div class="loading-overlay__card">
<p-progress-spinner ariaLabel="Ładowanie" strokeWidth="3" styleClass="loading-spinner" />
<span>Ładowanie danych…</span>
</div>
</div>
}
<div class="app-shell">
<app-sidebar />
<div class="app-shell__main">
<app-topbar />
<main class="app-shell__content">
<router-outlet />
</main>
<footer class="app-shell__footer">
FA Krosno Manager © {{ year }}
</footer>
</div>
</div>
`,
styles: `
.app-shell {
display: flex;
height: 100dvh;
overflow: hidden;
background: var(--color-bg);
}
.app-shell__main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.app-shell__content {
flex: 1;
overflow: auto;
padding: var(--content-padding);
}
.app-shell__footer {
flex-shrink: 0;
padding: var(--spacing-2) var(--content-padding);
text-align: center;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
border-top: 1px solid var(--color-border);
background: var(--color-surface);
}
.loading-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.35);
backdrop-filter: blur(2px);
}
.loading-overlay__card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-5) var(--spacing-6);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
:host ::ng-deep .loading-spinner {
width: 36px;
height: 36px;
}
`,
})
export class MainLayoutComponent {
readonly loading = inject(LoadingService);
readonly year = new Date().getFullYear();
}

View File

@@ -0,0 +1,254 @@
import { Component, computed, inject, signal } from '@angular/core';
import { Router, RouterLink, RouterLinkActive, NavigationEnd } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { filter, map, startWith } from 'rxjs';
import { TooltipModule } from 'primeng/tooltip';
interface NavItem {
label: string;
icon: string;
route: string;
exact?: boolean;
}
interface NavGroup {
label?: string;
items: NavItem[];
}
@Component({
selector: 'app-sidebar',
standalone: true,
imports: [RouterLink, RouterLinkActive, TooltipModule],
template: `
<aside class="sidebar" [class.sidebar--collapsed]="collapsed()">
<div class="sidebar__brand">
@if (!collapsed()) {
<img src="logo.svg" width="28" height="28" alt="" class="sidebar__logo" />
<span class="sidebar__brand-text">FA Manager</span>
} @else {
<img src="logo.svg" width="24" height="24" alt="" class="sidebar__logo" />
}
</div>
<nav class="sidebar__nav">
@for (group of navGroups(); track group.label ?? 'main') {
@if (group.label && !collapsed()) {
<div class="sidebar__group-label">{{ group.label }}</div>
}
@for (item of group.items; track item.route) {
<a
class="sidebar__link"
[routerLink]="item.route"
routerLinkActive="sidebar__link--active"
[routerLinkActiveOptions]="{ exact: item.exact ?? false }"
[pTooltip]="collapsed() ? item.label : undefined"
tooltipPosition="right"
>
<i [class]="item.icon" class="sidebar__link-icon"></i>
@if (!collapsed()) {
<span class="sidebar__link-label">{{ item.label }}</span>
}
</a>
}
}
</nav>
<div class="sidebar__footer">
<button type="button" class="sidebar__collapse-btn" (click)="toggleCollapsed()" [attr.aria-label]="collapsed() ? 'Rozwiń menu' : 'Zwiń menu'">
<i [class]="collapsed() ? 'pi pi-angle-double-right' : 'pi pi-angle-double-left'"></i>
@if (!collapsed()) {
<span>Zwiń menu</span>
}
</button>
</div>
</aside>
`,
styles: `
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
height: 100%;
display: flex;
flex-direction: column;
background: var(--color-sidebar-bg);
border-right: 1px solid var(--color-sidebar-border);
transition: width 0.2s ease, min-width 0.2s ease;
overflow: hidden;
}
.sidebar--collapsed {
width: var(--sidebar-collapsed-width);
min-width: var(--sidebar-collapsed-width);
}
.sidebar__brand {
display: flex;
align-items: center;
gap: var(--spacing-3);
height: var(--topbar-height);
padding: 0 var(--spacing-4);
border-bottom: 1px solid var(--color-sidebar-border);
flex-shrink: 0;
}
.sidebar--collapsed .sidebar__brand {
justify-content: center;
padding: 0;
}
.sidebar__logo {
filter: brightness(0) invert(1);
opacity: 0.9;
}
.sidebar__brand-text {
font-size: var(--font-size-md);
font-weight: 700;
color: var(--color-sidebar-text-active);
letter-spacing: -0.01em;
white-space: nowrap;
}
.sidebar__nav {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: var(--spacing-3) var(--spacing-2);
}
.sidebar__group-label {
padding: var(--spacing-3) var(--spacing-3) var(--spacing-1);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-sidebar-text-muted);
}
.sidebar__link {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: 9px var(--spacing-3);
margin-bottom: 2px;
border-radius: var(--radius-sm);
color: var(--color-sidebar-text);
text-decoration: none;
font-size: var(--font-size-sm);
font-weight: 500;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.sidebar--collapsed .sidebar__link {
justify-content: center;
padding: 10px;
}
.sidebar__link:hover {
background: var(--color-sidebar-surface);
color: var(--color-sidebar-text-active);
}
.sidebar__link--active {
background: var(--color-sidebar-accent-soft) !important;
color: var(--color-sidebar-text-active) !important;
}
.sidebar__link--active .sidebar__link-icon {
color: var(--color-sidebar-accent);
}
.sidebar__link-icon {
font-size: 15px;
width: 18px;
text-align: center;
flex-shrink: 0;
color: var(--color-sidebar-text-muted);
}
.sidebar__link-label {
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__footer {
padding: var(--spacing-2);
border-top: 1px solid var(--color-sidebar-border);
flex-shrink: 0;
}
.sidebar__collapse-btn {
display: flex;
align-items: center;
gap: var(--spacing-2);
width: 100%;
padding: var(--spacing-2) var(--spacing-3);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-sidebar-text-muted);
font-size: var(--font-size-xs);
font-family: inherit;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.sidebar--collapsed .sidebar__collapse-btn {
justify-content: center;
padding: var(--spacing-2);
}
.sidebar__collapse-btn:hover {
background: var(--color-sidebar-surface);
color: var(--color-sidebar-text);
}
`,
})
export class SidebarComponent {
private readonly router = inject(Router);
readonly collapsed = signal(false);
private readonly url = toSignal(
this.router.events.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
map((e) => e.urlAfterRedirects),
startWith(this.router.url),
),
{ initialValue: this.router.url },
);
readonly isAdminRoute = computed(() => this.url().split('?')[0].toLowerCase().startsWith('/admin'));
readonly navGroups = computed<NavGroup[]>(() => {
const main: NavGroup = {
items: [
{ label: 'Zamówienia DELFOR', icon: 'pi pi-building', route: '/', exact: true },
{ label: 'Zarządzanie Indeksami', icon: 'pi pi-box', route: '/Products' },
{ label: 'Magazyn', icon: 'pi pi-warehouse', route: '/Warehouse' },
],
};
const groups = [main];
if (this.isAdminRoute()) {
groups.push({
label: 'Administracja',
items: [
{ label: 'Użytkownicy', icon: 'pi pi-users', route: '/Admin/PK/UsersManager' },
{ label: 'Scheduler', icon: 'pi pi-calendar', route: '/Admin/PK/Scheduler' },
{ label: 'Zamówienia klienta EDI', icon: 'pi pi-list-check', route: '/Admin/PK/EdiCustomerOrders' },
{ label: 'Zamówienia klienta', icon: 'pi pi-database', route: '/Admin/PK/CustomerOrders' },
],
});
}
return groups;
});
toggleCollapsed(): void {
this.collapsed.update((v) => !v);
}
}

View File

@@ -0,0 +1,203 @@
import { Component, computed, inject, signal } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { filter, map, startWith } from 'rxjs';
import { ButtonDirective } from 'primeng/button';
import { BreadcrumbModule } from 'primeng/breadcrumb';
import { MenuModule } from 'primeng/menu';
import { MenuItem } from 'primeng/api';
import { AuthService } from '../../core/services/auth.service';
@Component({
selector: 'app-topbar',
standalone: true,
imports: [ButtonDirective, BreadcrumbModule, MenuModule],
template: `
<header class="topbar">
<div class="topbar__breadcrumbs">
<p-breadcrumb [model]="breadcrumbItems()" [home]="homeItem" />
</div>
<div class="topbar__actions">
<button pButton type="button" severity="secondary" [text]="true" [rounded]="true" [iconOnly]="true" aria-label="Przełącz tryb ciemny" (click)="toggleTheme()"><i [class]="isDark() ? 'pi pi-sun' : 'pi pi-moon'"></i></button>
<button type="button" class="topbar__user" (click)="userMenu.toggle($event)">
<span class="topbar__avatar">{{ userInitials() }}</span>
<span class="topbar__username">{{ userLabel() }}</span>
<i class="pi pi-chevron-down topbar__chevron"></i>
</button>
<p-menu #userMenu [model]="userMenuItems" [popup]="true" />
</div>
</header>
`,
styles: `
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--topbar-height);
padding: 0 var(--content-padding);
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
gap: var(--spacing-4);
}
.topbar__breadcrumbs {
flex: 1;
min-width: 0;
overflow: hidden;
}
.topbar__actions {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex-shrink: 0;
}
.topbar__user {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: 4px 8px 4px 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
cursor: pointer;
font-family: inherit;
transition: background 0.15s, border-color 0.15s;
}
.topbar__user:hover {
background: var(--color-surface-hover);
border-color: var(--color-border-strong);
}
.topbar__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
background: var(--color-accent);
color: var(--color-text-inverse);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
}
.topbar__username {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text-primary);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar__chevron {
font-size: 10px;
color: var(--color-text-muted);
}
:host ::ng-deep .p-breadcrumb {
background: transparent;
border: none;
padding: 0;
}
:host ::ng-deep .p-breadcrumb-list {
flex-wrap: nowrap;
}
:host ::ng-deep .p-breadcrumb-item-label {
font-size: var(--font-size-sm);
}
@media (max-width: 640px) {
.topbar__username {
display: none;
}
}
`,
})
export class TopbarComponent {
private readonly router = inject(Router);
private readonly auth = inject(AuthService);
readonly isDark = signal(document.documentElement.getAttribute('data-theme') === 'dark');
readonly homeItem: MenuItem = { icon: 'pi pi-home', routerLink: '/' };
private readonly url = toSignal(
this.router.events.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
map((e) => e.urlAfterRedirects),
startWith(this.router.url),
),
{ initialValue: this.router.url },
);
readonly breadcrumbItems = computed<MenuItem[]>(() => {
const segments = this.url()
.split('?')[0]
.split('/')
.filter(Boolean);
const labels: Record<string, string> = {
Products: 'Zarządzanie Indeksami',
Warehouse: 'Magazyn',
ScheduleOrder: 'Zamówienie DELFOR',
Admin: 'Administracja',
PK: 'PK',
UsersManager: 'Użytkownicy',
Scheduler: 'Scheduler',
EdiCustomerOrders: 'Zamówienia klienta EDI',
EdiCustomerOrder: 'Zamówienie EDI',
CustomerOrders: 'Zamówienia klienta',
CustomerOrder: 'Zamówienie klienta',
Meyle: 'Meyle',
Marelli: 'Marelli',
PackList: 'Packing List',
Simple: 'Widok prosty',
};
let path = '';
return segments.map((seg) => {
path += `/${seg}`;
return { label: labels[seg] ?? seg, routerLink: path };
});
});
readonly userLabel = computed(() => this.auth.userName() || 'Użytkownik');
readonly userInitials = computed(() => {
const name = this.userLabel();
return name.slice(0, 2).toUpperCase();
});
readonly userMenuItems: MenuItem[] = [
{
label: 'Wyloguj',
icon: 'pi pi-sign-out',
command: () => this.logout(),
},
];
toggleTheme(): void {
const next = this.isDark() ? 'light' : 'dark';
if (next === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
this.isDark.set(next === 'dark');
}
logout(): void {
this.auth.logout();
this.router.navigate(['/login']);
}
}

View File

@@ -0,0 +1,18 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../core/services/auth.service';
@Component({
selector: 'app-main',
standalone: true,
template: '',
})
export class MainComponent implements OnInit {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
ngOnInit(): void {
this.auth.logout();
this.router.navigate(['/login']);
}
}

View File

@@ -0,0 +1,165 @@
import { Component, ElementRef, inject, OnInit, signal, viewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { ButtonDirective } from 'primeng/button';
import { InputTextModule } from 'primeng/inputtext';
import { DialogModule } from 'primeng/dialog';
import { TransactionModel, WzRowMarelliDto } from '../../core/models';
import { WarehouseService } from '../../core/services/warehouse.service';
import { findMarelliCombinations } from '../../shared/utils/business-logic';
import { PageShellComponent } from '../../shared/components/page-shell/page-shell.component';
import { ContentSectionComponent } from '../../shared/components/content-section/content-section.component';
@Component({
selector: 'app-marelli-pack-list-simple',
standalone: true,
imports: [ButtonDirective, InputTextModule, DialogModule, FormsModule, PageShellComponent, ContentSectionComponent],
template: `
<app-page-shell
title="Packing List"
subtitle="Packing list Marelli — widok uproszczony ze skanowaniem"
icon="pi pi-box"
>
<div actions>
<button pButton type="button" (click)="changeView()"><i class="pi pi-eye"></i> Zmień widok</button>
<button pButton type="button" (click)="exportXls()"><i class="pi pi-file-excel"></i> Generuj XLS i Wyślij</button>
</div>
<app-content-section title="Dane wysyłki" icon="pi pi-envelope">
<div class="form-grid form-grid--2">
<div class="field-group">
<label class="field-label">Adresy Email do Wysyłki raportu:</label>
<input pInputText class="w-full" placeholder="Wprowadź adresy..." [(ngModel)]="emailAddresses" />
</div>
<div class="field-group">
<label class="field-label">Numer WZ:</label>
<input pInputText class="w-full" [(ngModel)]="wzNumber" />
</div>
</div>
</app-content-section>
<app-content-section title="Skanowanie" icon="pi pi-barcode">
<div class="form-grid form-grid--2">
<div class="field-group">
<label class="field-label">Wprowadź numer palety:</label>
<input pInputText type="number" class="w-full" [(ngModel)]="palletNumber" />
</div>
<div class="field-group">
<label class="field-label">Zeskanowana wartość:</label>
<input #scanner pInputText class="w-full" [(ngModel)]="scannedValue" (change)="scanValue()" />
</div>
</div>
</app-content-section>
<app-content-section title="Ostatnio zeskanowana pozycja" icon="pi pi-info-circle">
<div class="form-grid form-grid--3">
<div class="field-group"><label class="field-label">Numer Indeksu FA:</label><input pInputText readonly class="w-full" [(ngModel)]="itemNumber" /></div>
<div class="field-group"><label class="field-label">Ilość w Dostawie:</label><input pInputText readonly class="w-full" [(ngModel)]="qty" /></div>
<div class="field-group"><label class="field-label">Numer Palety:</label><input pInputText readonly class="w-full" [(ngModel)]="palletNumberOutput" /></div>
</div>
</app-content-section>
</app-page-shell>
<p-dialog header="Informacja" [(visible)]="showInfoDialog" [modal]="true" [style]="{ width: '500px' }">
@if (exportValid()) { <p>Packing List został wygenerowany i wysłany!</p> }
@else if (!emailAddresses?.trim()) { <p>Błąd: Proszę wprowadzić przynajmniej jeden <b>ADRES EMAIL</b> do wysyłki raportu!</p> }
@else { <p>Błąd: Nie Wszystkie linie mają wypełniony <b>NUMER PALETY</b>.<br />Packing List nie zostanie wygenerowany!</p> }
<ng-template #footer><button pButton type="button" (click)="hideModal()">OK</button></ng-template>
</p-dialog>
<p-dialog header="Błąd" [(visible)]="showPalletDialog" [modal]="true" [style]="{ width: '500px' }">
<p>Błąd skanowania! <b>Wybierz NUMER PALETY większy niż 0</b> (Aktualnie '{{ palletNumber }}'):</p>
<ng-template #footer><button pButton type="button" (click)="hideModal()">OK</button></ng-template>
</p-dialog>
`,
})
export class MarelliPackListSimpleComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly warehouseService = inject(WarehouseService);
readonly scanner = viewChild<ElementRef<HTMLInputElement>>('scanner');
wzHeaderId = '';
rows: WzRowMarelliDto[] = [];
transactionModels: Record<string, TransactionModel[]> = {};
changedRecords: WzRowMarelliDto[] = [];
selectedRow: WzRowMarelliDto | null = null;
selectedRows: WzRowMarelliDto[] = [];
emailAddresses = '';
wzNumber = '';
palletNumber = '0';
scannedValue = '';
itemNumber = '';
qty = '0';
palletNumberOutput = '0';
showInfoDialog = false;
showPalletDialog = false;
readonly exportValid = signal(false);
async ngOnInit(): Promise<void> {
this.wzHeaderId = this.route.snapshot.paramMap.get('WzHeader') ?? '';
const header = await this.warehouseService.getWzHeaderById(this.wzHeaderId);
this.rows = await this.warehouseService.getWzRowsMarelliByWzHeaderId(this.wzHeaderId);
this.transactionModels = await this.warehouseService.getTransactionsModels();
this.emailAddresses = header.emailAddresses ?? '';
this.wzNumber = header.wzNumbers ?? '';
setTimeout(() => this.scanner()?.nativeElement.focus());
}
changeView(): void { this.router.navigate(['/Warehouse/Marelli/PackList', this.wzHeaderId]); }
hideModal(): void { this.showInfoDialog = false; this.showPalletDialog = false; this.scannedValue = ''; setTimeout(() => this.scanner()?.nativeElement.focus()); }
async saveChanges(): Promise<void> {
if (this.emailAddresses?.trim()) await this.warehouseService.addEmailsToWzHeader(this.wzHeaderId, this.emailAddresses);
if (this.changedRecords.length) {
await this.warehouseService.updateWzRowsMarelli(this.changedRecords);
this.rows = await this.warehouseService.getWzRowsMarelliByWzHeaderId(this.wzHeaderId);
}
}
async exportXls(): Promise<void> {
const valid = !this.rows.some((x) => x.palletNumber == null) && !!this.emailAddresses?.trim();
this.exportValid.set(valid);
if (valid) {
await this.warehouseService.addEmailsToWzHeader(this.wzHeaderId, this.emailAddresses);
await this.warehouseService.generateXlsForMarelli(this.wzHeaderId);
}
this.showInfoDialog = true;
}
async scanValue(): Promise<void> {
const value = this.scannedValue.trim();
if (!value || parseInt(this.palletNumber, 10) <= 0) { this.showPalletDialog = true; return; }
const material = this.transactionModels[value]?.[0];
if (material) await this.fillPalletNumber(material);
this.scannedValue = '';
setTimeout(() => this.scanner()?.nativeElement.focus());
}
private async fillPalletNumber(material: TransactionModel): Promise<void> {
const palletNum = parseInt(this.palletNumber, 10);
let rowIndex = this.rows.findIndex((x) => x.faIndex === material.itemNumber && x.quantity === material.quantity);
if (rowIndex === -1) {
this.selectedRows = this.rows.filter((x) => x.faIndex === material.itemNumber);
const validCombinations = findMarelliCombinations(this.selectedRows, material.quantity ?? 0);
for (const combination of validCombinations) {
for (const record of combination) {
record.palletNumber = palletNum;
this.changedRecords.push(record);
}
}
this.selectedRows = [...this.changedRecords];
this.selectedRow = this.selectedRows[0] ?? null;
} else {
this.selectedRow = this.rows[rowIndex];
this.selectedRow.palletNumber = palletNum;
if (!this.changedRecords.some((x) => x.transactionNumber === this.selectedRow!.transactionNumber)) this.changedRecords.push(this.selectedRow);
}
this.itemNumber = this.selectedRow?.faIndex ?? '';
this.qty = String(this.selectedRow?.quantity ?? 0);
this.palletNumberOutput = String(this.selectedRow?.palletNumber ?? 0);
await this.saveChanges();
this.changedRecords = [];
}
}

View File

@@ -0,0 +1,223 @@
import { Component, ElementRef, inject, OnInit, signal, viewChild } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { SelectableRow, TableModule, TableRowSelectEvent } from 'primeng/table';
import { ButtonDirective } from 'primeng/button';
import { InputTextModule } from 'primeng/inputtext';
import { DialogModule } from 'primeng/dialog';
import { TransactionModel, WzRowMarelliDto } from '../../core/models';
import { WarehouseService } from '../../core/services/warehouse.service';
import { findMarelliCombinations } from '../../shared/utils/business-logic';
import { PageShellComponent } from '../../shared/components/page-shell/page-shell.component';
import { ContentSectionComponent } from '../../shared/components/content-section/content-section.component';
@Component({
selector: 'app-marelli-pack-list',
standalone: true,
imports: [TableModule, SelectableRow, ButtonDirective, InputTextModule, DialogModule, FormsModule, PageShellComponent, ContentSectionComponent],
template: `
<app-page-shell
title="Packing List"
subtitle="Packing list Marelli — edycja pozycji i skanowanie"
icon="pi pi-box"
>
<app-content-section title="Dane wysyłki" icon="pi pi-envelope">
<div class="form-grid form-grid--2">
<div class="field-group">
<label class="field-label">Adresy Email do Wysyłki raportu:</label>
<input pInputText class="w-full" placeholder="Wprowadź adresy..." [(ngModel)]="emailAddresses" />
</div>
<div class="field-group">
<label class="field-label">Numer WZ:</label>
<input pInputText class="w-full" [(ngModel)]="wzNumber" />
</div>
</div>
</app-content-section>
<app-content-section title="Skanowanie" icon="pi pi-barcode">
<div class="form-grid form-grid--2">
<div class="field-group">
<label class="field-label">Wprowadź numer palety:</label>
<input pInputText type="number" class="w-full" [(ngModel)]="palletNumber" />
</div>
<div class="field-group">
<label class="field-label">Zeskanowana wartość:</label>
<input #scanner pInputText class="w-full" [(ngModel)]="scannedValue" (change)="scanValue()" />
</div>
</div>
</app-content-section>
<app-content-section title="Pozycje packing list" icon="pi pi-list" [flush]="true">
<div headerActions>
<button pButton type="button" (click)="saveChanges()"><i class="pi pi-save"></i> Zapisz zmiany</button>
<button pButton type="button" (click)="exportXls()"><i class="pi pi-file-excel"></i> Generuj XLS i Wyślij</button>
</div>
<p-table [value]="rows()" [(selection)]="selectedRowsList" dataKey="id" [paginator]="true" [rows]="10" selectionMode="single" (onRowSelect)="onRowSelect($event)" styleClass="p-datatable-sm" [showGridlines]="true" [stripedRows]="true">
<ng-template #header>
<tr>
<th>Nr Palety</th><th>Numer Indeksu Marelli</th><th>Numer Inżynieryjny</th>
<th>Ilość w Dostawie</th><th>Nr Zamówienia</th><th>Nr WZ</th>
</tr>
</ng-template>
<ng-template #body let-row>
<tr [pSelectableRow]="row">
<td><input pInputText type="number" [(ngModel)]="row.palletNumber" (ngModelChange)="markChanged(row)" /></td>
<td>{{ row.itemNumber }}</td>
<td>{{ row.engineerNumber }}</td>
<td>{{ row.quantity }}</td>
<td><input pInputText [(ngModel)]="row.orderNumber" (ngModelChange)="markChanged(row)" /></td>
<td><input pInputText [(ngModel)]="row.wzNumber" (ngModelChange)="markChanged(row)" /></td>
</tr>
</ng-template>
</p-table>
</app-content-section>
</app-page-shell>
<p-dialog header="Informacja" [(visible)]="showInfoDialog" [modal]="true" [style]="{ width: '500px' }">
@if (exportValid()) { <p>Packing List został wygenerowany i wysłany!</p> }
@else if (!emailAddresses?.trim()) { <p>Błąd: Proszę wprowadzić przynajmniej jeden <b>ADRES EMAIL</b> do wysyłki raportu!</p> }
@else { <p>Błąd: Nie Wszystkie linie mają wypełniony <b>NUMER PALETY</b>.<br />Packing List nie zostanie wygenerowany!</p> }
<ng-template #footer><button pButton type="button" (click)="hideModal()">OK</button></ng-template>
</p-dialog>
<p-dialog header="Błąd" [(visible)]="showPalletDialog" [modal]="true" [style]="{ width: '500px' }">
<p>Błąd skanowania! <b>Wybierz NUMER PALETY większy niż 0</b> (Aktualnie '{{ palletNumber }}'):</p>
<ng-template #footer><button pButton type="button" (click)="hideModal()">OK</button></ng-template>
</p-dialog>
<p-dialog header="Błąd" [(visible)]="showIndexQtyDialog" [modal]="true" [style]="{ width: '500px' }">
<p>Nie znaleziono indeksu z ilością sztuk ('<b>{{ indexWithQty }}</b>') odpowiadającemu skanowanym wartościom!</p>
<p>Znaleziono indeks, który ma ilość sztuk: '<b>{{ indexQty }}</b>'!</p>
<p><b>Uzupełnij numer palety ręcznie i kliknij 'Zapisz'!</b></p>
<ng-template #footer><button pButton type="button" (click)="hideModal()">OK</button></ng-template>
</p-dialog>
<p-dialog header="Błąd" [(visible)]="showNotFoundDialog" [modal]="true" [style]="{ width: '500px' }">
<p>Na liście nie znaleziono skanowanego numeru partii '<b>{{ notFoundItem }}</b>'!</p>
<ng-template #footer><button pButton type="button" (click)="hideModal()">OK</button></ng-template>
</p-dialog>
`,
})
export class MarelliPackListComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly warehouseService = inject(WarehouseService);
readonly scanner = viewChild<ElementRef<HTMLInputElement>>('scanner');
wzHeaderId = '';
emailAddresses = '';
wzNumber = '';
palletNumber = '0';
scannedValue = '';
indexWithQty = '';
indexQty = '';
notFoundItem = '';
readonly rows = signal<WzRowMarelliDto[]>([]);
readonly selectedRow = signal<WzRowMarelliDto | null>(null);
selectedRowsList: WzRowMarelliDto[] = [];
changedRecords: WzRowMarelliDto[] = [];
transactionModels: Record<string, TransactionModel[]> = {};
showInfoDialog = false;
showPalletDialog = false;
showIndexQtyDialog = false;
showNotFoundDialog = false;
readonly exportValid = signal(false);
async ngOnInit(): Promise<void> {
this.wzHeaderId = this.route.snapshot.paramMap.get('WzHeader') ?? '';
const header = await this.warehouseService.getWzHeaderById(this.wzHeaderId);
this.rows.set(await this.warehouseService.getWzRowsMarelliByWzHeaderId(this.wzHeaderId));
this.transactionModels = await this.warehouseService.getTransactionsModels();
this.emailAddresses = header.emailAddresses ?? '';
this.wzNumber = header.wzNumbers ?? '';
setTimeout(() => this.scanner()?.nativeElement.focus());
}
onRowSelect(event: TableRowSelectEvent<WzRowMarelliDto>): void {
const data = event.data;
if (data && !Array.isArray(data)) this.selectedRow.set(data);
}
markChanged(row: WzRowMarelliDto): void { if (!this.changedRecords.includes(row)) this.changedRecords.push(row); }
hideModal(): void {
this.showInfoDialog = false;
this.showPalletDialog = false;
this.showIndexQtyDialog = false;
this.showNotFoundDialog = false;
this.scannedValue = '';
this.indexQty = '';
this.indexWithQty = '';
this.notFoundItem = '';
setTimeout(() => this.scanner()?.nativeElement.focus());
}
async saveChanges(): Promise<void> {
if (this.emailAddresses?.trim()) await this.warehouseService.addEmailsToWzHeader(this.wzHeaderId, this.emailAddresses);
if (this.changedRecords.length) {
await this.updateRows(this.changedRecords);
this.changedRecords = [];
}
this.selectedRowsList = [];
}
async exportXls(): Promise<void> {
const valid = !this.rows().some((x) => x.palletNumber == null) && !!this.emailAddresses?.trim();
this.exportValid.set(valid);
if (valid) {
await this.warehouseService.addEmailsToWzHeader(this.wzHeaderId, this.emailAddresses);
await this.warehouseService.generateXlsForMarelli(this.wzHeaderId);
}
this.showInfoDialog = true;
}
async scanValue(): Promise<void> {
const value = this.scannedValue.trim();
if (!value || parseInt(this.palletNumber, 10) <= 0) { this.showPalletDialog = true; return; }
const material = this.transactionModels[value]?.[0];
if (material) {
this.selectedRowsList = [];
await this.fillPalletNumber(material, value);
} else {
this.notFoundItem = value;
this.showNotFoundDialog = true;
}
this.scannedValue = '';
setTimeout(() => this.scanner()?.nativeElement.focus());
}
private async fillPalletNumber(material: TransactionModel, scanned: string): Promise<void> {
const palletNum = parseInt(this.palletNumber, 10);
const currentRows = this.rows();
let rowIndex = currentRows.findIndex((x) => x.faIndex === material.itemNumber && x.quantity === material.quantity);
if (rowIndex === -1) {
const candidates = currentRows.filter((x) => x.faIndex === material.itemNumber);
if (!candidates.length) return;
rowIndex = currentRows.findIndex((x) => x.faIndex === candidates[0].faIndex && x.quantity === candidates[0].quantity);
const validCombinations = findMarelliCombinations(candidates, material.quantity ?? 0);
if (!validCombinations.length && candidates.length > 0) {
this.indexWithQty = `${material.itemNumber}, Qty = ${material.quantity}`;
this.indexQty = String(candidates[0].quantity ?? 0);
this.showIndexQtyDialog = true;
return;
}
for (const combination of validCombinations) {
for (const record of combination) {
record.palletNumber = palletNum;
this.changedRecords.push(record);
}
}
this.selectedRowsList = [...this.changedRecords];
this.selectedRow.set(this.selectedRowsList[0] ?? null);
} else {
const row = currentRows[rowIndex];
this.selectedRow.set(row);
row.palletNumber = palletNum;
if (!this.changedRecords.some((x) => x.transactionNumber === row.transactionNumber)) this.changedRecords.push(row);
}
await this.saveChanges();
}
private async updateRows(changed: WzRowMarelliDto[]): Promise<void> {
await this.warehouseService.updateWzRowsMarelli(changed);
this.rows.set(await this.warehouseService.getWzRowsMarelliByWzHeaderId(this.wzHeaderId));
}
}

View File

@@ -0,0 +1,217 @@
import { Component, ElementRef, inject, OnInit, signal, viewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { ButtonDirective } from 'primeng/button';
import { InputTextModule } from 'primeng/inputtext';
import { DialogModule } from 'primeng/dialog';
import { TransactionModel, WzRowMeyleDto } from '../../core/models';
import { WarehouseService } from '../../core/services/warehouse.service';
import { findCombinations, isValidMeyleScannedValue } from '../../shared/utils/business-logic';
import { PageShellComponent } from '../../shared/components/page-shell/page-shell.component';
import { ContentSectionComponent } from '../../shared/components/content-section/content-section.component';
@Component({
selector: 'app-meyle-pack-list-simple',
standalone: true,
imports: [ButtonDirective, InputTextModule, DialogModule, FormsModule, PageShellComponent, ContentSectionComponent],
template: `
<app-page-shell
title="Packing List"
subtitle="Packing list Meyle — widok uproszczony ze skanowaniem"
icon="pi pi-box"
>
<div actions>
<button pButton type="button" (click)="changeView()"><i class="pi pi-eye"></i> Zmień widok</button>
<button pButton type="button" (click)="exportXls()"><i class="pi pi-file-excel"></i> Generuj XLS i Wyślij</button>
</div>
<app-content-section title="Dane wysyłki" icon="pi pi-envelope">
<div class="form-grid form-grid--2">
<div class="field-group">
<label class="field-label">Adresy Email do Wysyłki raportu:</label>
<input pInputText class="w-full" placeholder="Wprowadź adresy..." [(ngModel)]="emailAddresses" />
</div>
<div class="field-group">
<label class="field-label">Numer WZ:</label>
<input pInputText class="w-full" [(ngModel)]="wzNumber" />
</div>
</div>
</app-content-section>
<app-content-section title="Skanowanie" icon="pi pi-barcode">
<div class="form-grid form-grid--2">
<div class="field-group">
<label class="field-label">Wprowadź numer palety:</label>
<input pInputText type="number" class="w-full" [(ngModel)]="palletNumber" />
</div>
<div class="field-group">
<label class="field-label">Zeskanowana wartość:</label>
<input #scanner pInputText class="w-full" [(ngModel)]="scannedValue" (change)="scanValue()" />
</div>
</div>
</app-content-section>
<app-content-section title="Ostatnio zeskanowana pozycja" icon="pi pi-info-circle">
<div class="form-grid form-grid--3">
<div class="field-group"><label class="field-label">Numer Indeksu FA:</label><input pInputText readonly class="w-full" [(ngModel)]="itemNumber" /></div>
<div class="field-group"><label class="field-label">Ilość w Dostawie:</label><input pInputText readonly class="w-full" [(ngModel)]="qty" /></div>
<div class="field-group"><label class="field-label">Numer Palety:</label><input pInputText readonly class="w-full" [(ngModel)]="palletNumberOutput" /></div>
<div class="field-group"><label class="field-label">Nr Partii SL:</label><input pInputText readonly class="w-full" [(ngModel)]="partNumberSl" /></div>
<div class="field-group"><label class="field-label">Numer Partii Meyle:</label><input pInputText readonly class="w-full" [(ngModel)]="partNumberMeyle" /></div>
</div>
</app-content-section>
</app-page-shell>
<p-dialog header="Informacja" [(visible)]="showInfoDialog" [modal]="true" [style]="{ width: '500px' }">
@if (exportValid()) { <p>Packing List został wygenerowany i wysłany!</p> }
@else if (!emailAddresses?.trim()) { <p>Błąd: Proszę wprowadzić przynajmniej jeden <b>ADRES EMAIL</b> do wysyłki raportu!</p> }
@else { <p>Błąd: Nie Wszystkie linie mają wypełniony <b>NUMER PALETY</b>.<br />Packing List nie zostanie wygenerowany!</p> }
<ng-template #footer><button pButton type="button" (click)="hideModal()">OK</button></ng-template>
</p-dialog>
<p-dialog header="Błąd" [(visible)]="showValidationDialog" [modal]="true" [style]="{ width: '500px' }">
<p>Błąd skanowania! Wystąpił jeden z wyjątków (Zeskanowana wartość '<b>{{ lastScannedValue }}</b>'):</p>
<ul>
<li>Zeskanowano niepoprawny Numer Partii SL (nieistniejący w tabeli)</li>
<li>Zeskanowano niepoprawny numer Partii Meyle (niezaczynający się od <b>{{ yearPrefix }}X</b>)</li>
<li>Numer Palety nie jest większy niż 0 (aktualnie wybrany numer palety: '<b>{{ palletNumber }}</b>')</li>
</ul>
<ng-template #footer><button pButton type="button" (click)="hideModal()">OK</button></ng-template>
</p-dialog>
<p-dialog header="Błąd" [(visible)]="showPalletDialog" [modal]="true" [style]="{ width: '500px' }">
<p>Błąd skanowania! <b>Wybierz NUMER PALETY większy niż 0</b> (Aktualnie '{{ palletNumber }}'):</p>
<ng-template #footer><button pButton type="button" (click)="hideModal()">OK</button></ng-template>
</p-dialog>
`,
})
export class MeylePackListSimpleComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly warehouseService = inject(WarehouseService);
readonly scanner = viewChild<ElementRef<HTMLInputElement>>('scanner');
wzHeaderId = '';
rows: WzRowMeyleDto[] = [];
transactionModels: Record<string, TransactionModel[]> = {};
changedRecords: WzRowMeyleDto[] = [];
selectedRow: WzRowMeyleDto | null = null;
selectedRows: WzRowMeyleDto[] = [];
emailAddresses = '';
wzNumber = '';
palletNumber = '0';
scannedValue = '';
lastScannedValue = '';
itemNumber = '';
qty = '0';
palletNumberOutput = '0';
partNumberSl = '';
partNumberMeyle = '';
showInfoDialog = false;
showValidationDialog = false;
showPalletDialog = false;
readonly exportValid = signal(false);
get yearPrefix(): number { return new Date().getFullYear() - 2000; }
async ngOnInit(): Promise<void> {
this.wzHeaderId = this.route.snapshot.paramMap.get('WzHeader') ?? '';
const header = await this.warehouseService.getWzHeaderById(this.wzHeaderId);
this.rows = await this.warehouseService.getWzRowsMeyleByWzHeaderId(this.wzHeaderId);
this.transactionModels = await this.warehouseService.getTransactionsModels();
this.emailAddresses = header.emailAddresses ?? '';
this.wzNumber = header.wzNumbers ?? '';
setTimeout(() => this.scanner()?.nativeElement.focus());
}
changeView(): void { this.router.navigate(['/Warehouse/Meyle/PackList', this.wzHeaderId]); }
hideModal(): void {
this.showInfoDialog = false;
this.showValidationDialog = false;
this.showPalletDialog = false;
this.lastScannedValue = this.scannedValue;
this.scannedValue = '';
setTimeout(() => this.scanner()?.nativeElement.focus());
}
async saveChanges(): Promise<void> {
if (this.emailAddresses?.trim()) await this.warehouseService.addEmailsToWzHeader(this.wzHeaderId, this.emailAddresses);
if (this.changedRecords.length) {
await this.warehouseService.updateWzRowsMeyle(this.changedRecords);
this.rows = await this.warehouseService.getWzRowsMeyleByWzHeaderId(this.wzHeaderId);
}
}
async exportXls(): Promise<void> {
const valid = !this.rows.some((x) => x.palletNumber == null) && !!this.emailAddresses?.trim();
this.exportValid.set(valid);
if (valid) {
await this.warehouseService.addEmailsToWzHeader(this.wzHeaderId, this.emailAddresses);
await this.warehouseService.generateXlsForMeyle(this.wzHeaderId);
}
this.showInfoDialog = true;
}
async scanValue(): Promise<void> {
const value = this.scannedValue.trim();
if (!value || parseInt(this.palletNumber, 10) <= 0) { this.showPalletDialog = true; return; }
const material = this.transactionModels[value]?.[0];
if (!material && isValidMeyleScannedValue(value)) {
await this.fillMeylePartNumber(value);
this.scannedValue = '';
return;
}
if (material) await this.fillFaPartNumberAndPalletNumber(material, value);
else { this.showValidationDialog = true; this.changedRecords = []; return; }
this.lastScannedValue = value;
this.scannedValue = '';
setTimeout(() => this.scanner()?.nativeElement.focus());
}
private async fillMeylePartNumber(scanned: string): Promise<void> {
if (this.selectedRow && !this.selectedRows.length) {
this.selectedRow.partNumber = scanned;
this.changedRecords.push(this.selectedRow);
this.partNumberMeyle = scanned;
}
for (const row of this.selectedRows) { row.partNumber = scanned; this.changedRecords.push(row); }
await this.saveChanges();
this.changedRecords = [];
this.selectedRows = [];
setTimeout(() => this.scanner()?.nativeElement.focus());
}
private async fillFaPartNumberAndPalletNumber(material: TransactionModel, scanned: string): Promise<void> {
const palletNum = parseInt(this.palletNumber, 10);
let rowIndex = this.rows.findIndex((x) => x.faIndex === material.itemNumber && x.quantity === material.quantity);
if (rowIndex === -1) {
this.selectedRows = this.rows.filter((x) => x.faIndex === material.itemNumber);
if (!this.selectedRows.length) { this.showValidationDialog = true; return; }
const validCombinations = findCombinations(this.selectedRows, material.quantity ?? 0);
for (const combination of validCombinations) {
for (const record of combination) {
record.partNumberSl = scanned;
record.palletNumber = palletNum;
this.changedRecords.push(record);
}
}
this.selectedRows = [...this.changedRecords];
this.selectedRow = this.selectedRows[0] ?? null;
} else {
this.selectedRow = this.rows[rowIndex];
this.selectedRow.partNumberSl = scanned;
this.selectedRow.palletNumber = palletNum;
if (!this.changedRecords.some((x) => x.transactionNumber === this.selectedRow!.transactionNumber)) {
this.changedRecords.push(this.selectedRow);
}
}
this.partNumberSl = this.selectedRow?.partNumberSl ?? '';
this.palletNumberOutput = String(this.selectedRow?.palletNumber ?? 0);
this.itemNumber = this.selectedRow?.faIndex ?? '';
this.qty = String(this.selectedRow?.quantity ?? 0);
await this.saveChanges();
this.changedRecords = [];
setTimeout(() => this.scanner()?.nativeElement.focus());
}
}

View File

@@ -0,0 +1,336 @@
import { Component, ElementRef, inject, OnInit, signal, viewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { SelectableRow, TableModule, TableRowSelectEvent } from 'primeng/table';
import { ButtonDirective } from 'primeng/button';
import { InputTextModule } from 'primeng/inputtext';
import { DialogModule } from 'primeng/dialog';
import { TransactionModel, WzHeaderDto, WzRowMeyleDto } from '../../core/models';
import { WarehouseService } from '../../core/services/warehouse.service';
import {
findCombinations,
isValidMeyleScannedValue,
} from '../../shared/utils/business-logic';
import { PageShellComponent } from '../../shared/components/page-shell/page-shell.component';
import { ContentSectionComponent } from '../../shared/components/content-section/content-section.component';
@Component({
selector: 'app-meyle-pack-list',
standalone: true,
imports: [TableModule, SelectableRow, ButtonDirective, InputTextModule, DialogModule, FormsModule, PageShellComponent, ContentSectionComponent],
template: `
<app-page-shell
title="Packing List"
subtitle="Packing list Meyle — widok szczegółowy z edycją pozycji"
icon="pi pi-box"
>
<div actions>
<button pButton type="button" (click)="changeView()"><i class="pi pi-eye"></i> Zmień widok</button>
</div>
<app-content-section title="Dane wysyłki" icon="pi pi-envelope">
<div class="form-grid form-grid--2">
<div class="field-group">
<label class="field-label">Adresy Email do Wysyłki raportu:</label>
<input pInputText class="w-full" placeholder="Wprowadź adresy..." [(ngModel)]="emailAddresses" />
</div>
<div class="field-group">
<label class="field-label">Numer WZ:</label>
<input pInputText class="w-full" [(ngModel)]="wzNumber" />
</div>
</div>
</app-content-section>
<app-content-section title="Skanowanie" icon="pi pi-barcode">
<div class="form-grid form-grid--2">
<div class="field-group">
<label class="field-label">Wprowadź numer palety:</label>
<input pInputText type="number" class="w-full" [(ngModel)]="palletNumber" />
</div>
<div class="field-group">
<label class="field-label">Zeskanowana wartość:</label>
<input #scanner pInputText class="w-full" [(ngModel)]="scannedValue" (change)="scanValue()" />
</div>
</div>
</app-content-section>
<app-content-section title="Pozycje packing list" icon="pi pi-list" [flush]="true">
<div headerActions>
<button pButton type="button" (click)="saveChanges()"><i class="pi pi-save"></i> Zapisz zmiany</button>
<button pButton type="button" [disabled]="!selectedRow()" (click)="showSplitDialog.set(true)"><i class="pi pi-arrows-h"></i> Podziel Linię</button>
<button pButton type="button" (click)="exportXls()"><i class="pi pi-file-excel"></i> Generuj XLS i Wyślij</button>
</div>
<p-table
[value]="rows()"
[(selection)]="selectedRowsList"
dataKey="id"
[paginator]="true"
[rows]="10"
selectionMode="single"
(onRowSelect)="onRowSelect($event)"
styleClass="p-datatable-sm"
[showGridlines]="true"
[stripedRows]="true"
>
<ng-template #header>
<tr>
<th>Numer Zamówienia Meyle</th>
<th>Numer Indeksu FA</th>
<th>Numer Indeksu Meyle</th>
<th>Ilość w Dostawie</th>
<th>Nr Palety</th>
<th>Nr Partii SL</th>
<th>Nr Partii Meyle</th>
</tr>
</ng-template>
<ng-template #body let-row>
<tr [pSelectableRow]="row">
<td>{{ row.orderNumber }}</td>
<td>{{ row.faIndex }}</td>
<td>{{ row.itemNumber }}</td>
<td>{{ row.quantity }}</td>
<td><input pInputText type="number" [(ngModel)]="row.palletNumber" (ngModelChange)="markChanged(row)" /></td>
<td><input pInputText [(ngModel)]="row.partNumberSl" (ngModelChange)="markChanged(row)" /></td>
<td><input pInputText [(ngModel)]="row.partNumber" (ngModelChange)="markChanged(row)" /></td>
</tr>
</ng-template>
</p-table>
</app-content-section>
</app-page-shell>
<p-dialog header="Informacja" [(visible)]="showInfoDialog" [modal]="true" [style]="{ width: '500px' }">
@if (exportValid()) {
<p>Packing List został wygenerowany i wysłany!</p>
} @else if (!emailAddresses?.trim()) {
<p>Błąd: Proszę wprowadzić przynajmniej jeden <b>ADRES EMAIL</b> do wysyłki raportu!</p>
} @else {
<p>Błąd: Nie Wszystkie linie mają wypełniony <b>NUMER PALETY</b>.<br />Packing List nie zostanie wygenerowany!</p>
}
<ng-template #footer><button pButton type="button" (click)="hideModal()">OK</button></ng-template>
</p-dialog>
<p-dialog header="Błąd" [(visible)]="showValidationDialog" [modal]="true" [style]="{ width: '500px' }">
<p>Błąd skanowania! Wystąpił jeden z wyjątków (Zeskanowana wartość '<b>{{ lastScannedValue }}</b>'):</p>
<ul>
<li>Zeskanowano niepoprawny Numer Partii SL (nieistniejący w tabeli)</li>
<li>Zeskanowano niepoprawny numer Partii Meyle (niezaczynający się od <b>{{ yearPrefix }}X</b>)</li>
<li>Numer Palety nie jest większy niż 0 (aktualnie wybrany numer palety: '<b>{{ palletNumber }}</b>')</li>
</ul>
<ng-template #footer><button pButton type="button" (click)="hideModal()">OK</button></ng-template>
</p-dialog>
<p-dialog header="Błąd" [(visible)]="showPalletDialog" [modal]="true" [style]="{ width: '500px' }">
<p>Błąd skanowania! <b>Wybierz NUMER PALETY większy niż 0</b> (Aktualnie '{{ palletNumber }}'):</p>
<ng-template #footer><button pButton type="button" (click)="hideModal()">OK</button></ng-template>
</p-dialog>
<p-dialog header="Podziel Linię" [(visible)]="showSplitDialog" [modal]="true" [style]="{ width: '500px' }">
<label class="field-label">Podziel linię <b>{{ selectedRow()?.faIndex }}</b> podając ilość sztuk dla nowej linii:</label>
<input pInputText type="number" class="w-full" [(ngModel)]="newQuantity" />
<ng-template #footer>
<button pButton type="button" (click)="splitLine()">Zapisz</button>
<button pButton type="button" severity="secondary" (click)="showSplitDialog.set(false)">Anuluj</button>
</ng-template>
</p-dialog>
`,
})
export class MeylePackListComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly warehouseService = inject(WarehouseService);
readonly scanner = viewChild<ElementRef<HTMLInputElement>>('scanner');
wzHeaderId = '';
emailAddresses = '';
wzNumber = '';
palletNumber = '0';
scannedValue = '';
lastScannedValue = '';
newQuantity = '0';
readonly rows = signal<WzRowMeyleDto[]>([]);
readonly selectedRow = signal<WzRowMeyleDto | null>(null);
selectedRowsList: WzRowMeyleDto[] = [];
changedRecords: WzRowMeyleDto[] = [];
transactionModels: Record<string, TransactionModel[]> = {};
showInfoDialog = false;
showValidationDialog = false;
showPalletDialog = false;
readonly showSplitDialog = signal(false);
readonly exportValid = signal(false);
get yearPrefix(): number {
return new Date().getFullYear() - 2000;
}
async ngOnInit(): Promise<void> {
this.wzHeaderId = this.route.snapshot.paramMap.get('WzHeader') ?? '';
const header = await this.warehouseService.getWzHeaderById(this.wzHeaderId);
this.rows.set(await this.warehouseService.getWzRowsMeyleByWzHeaderId(this.wzHeaderId));
this.transactionModels = await this.warehouseService.getTransactionsModels();
this.emailAddresses = header.emailAddresses ?? '';
this.wzNumber = header.wzNumbers ?? '';
setTimeout(() => this.scanner()?.nativeElement.focus());
}
changeView(): void {
this.router.navigate(['/Warehouse/Meyle/PackList', this.wzHeaderId, 'Simple']);
}
onRowSelect(event: TableRowSelectEvent<WzRowMeyleDto>): void {
const data = event.data;
if (data && !Array.isArray(data)) this.selectedRow.set(data);
}
markChanged(row: WzRowMeyleDto): void {
if (!this.changedRecords.includes(row)) this.changedRecords.push(row);
}
hideModal(): void {
this.showInfoDialog = false;
this.showValidationDialog = false;
this.showPalletDialog = false;
this.showSplitDialog.set(false);
this.lastScannedValue = this.scannedValue;
this.scannedValue = '';
setTimeout(() => this.scanner()?.nativeElement.focus());
}
async saveChanges(): Promise<void> {
if (this.emailAddresses?.trim()) {
await this.warehouseService.addEmailsToWzHeader(this.wzHeaderId, this.emailAddresses);
}
if (this.changedRecords.length) {
await this.updateRows(this.changedRecords);
this.changedRecords = [];
}
}
async exportXls(): Promise<void> {
const missingPallet = this.rows().some((x) => x.palletNumber == null);
const valid = !missingPallet && !!this.emailAddresses?.trim();
this.exportValid.set(valid);
if (valid) {
await this.warehouseService.addEmailsToWzHeader(this.wzHeaderId, this.emailAddresses);
await this.warehouseService.generateXlsForMeyle(this.wzHeaderId);
}
this.showInfoDialog = true;
}
async scanValue(): Promise<void> {
const value = this.scannedValue.trim();
if (!value) return;
if (parseInt(this.palletNumber, 10) <= 0) {
this.showPalletDialog = true;
return;
}
this.transactionModels[value]?.[0];
const materialTransaction = this.transactionModels[value]?.[0];
if (!materialTransaction && isValidMeyleScannedValue(value)) {
await this.fillMeylePartNumber(value);
this.lastScannedValue = value;
this.scannedValue = '';
return;
}
if (materialTransaction) {
this.selectedRowsList = [];
await this.fillFaPartNumberAndPalletNumber(materialTransaction, value);
} else {
this.showValidationDialog = true;
this.changedRecords = [];
return;
}
this.lastScannedValue = value;
this.scannedValue = '';
setTimeout(() => this.scanner()?.nativeElement.focus());
}
private async fillMeylePartNumber(scanned: string): Promise<void> {
const selected = this.selectedRow();
if (selected && !this.selectedRowsList.length) {
selected.partNumber = scanned;
this.changedRecords.push(selected);
}
for (const row of this.selectedRowsList) {
row.partNumber = scanned;
this.changedRecords.push(row);
}
await this.saveChanges();
this.changedRecords = [];
this.selectedRowsList = [];
setTimeout(() => this.scanner()?.nativeElement.focus());
}
private async fillFaPartNumberAndPalletNumber(material: TransactionModel, scanned: string): Promise<void> {
const palletNum = parseInt(this.palletNumber, 10);
const currentRows = this.rows();
let rowIndex = currentRows.findIndex((x) => x.faIndex === material.itemNumber && x.quantity === material.quantity);
if (rowIndex === -1) {
const candidates = currentRows.filter((x) => x.faIndex === material.itemNumber);
if (!candidates.length) {
this.showValidationDialog = true;
return;
}
rowIndex = currentRows.findIndex((x) => x.faIndex === candidates[0].faIndex && x.quantity === candidates[0].quantity);
const validCombinations = findCombinations(candidates, material.quantity ?? 0);
for (const combination of validCombinations) {
for (const record of combination) {
record.partNumberSl = scanned;
record.palletNumber = palletNum;
this.changedRecords.push(record);
}
}
this.selectedRowsList = [...this.changedRecords];
this.selectedRow.set(this.selectedRowsList[0] ?? null);
} else {
const row = currentRows[rowIndex];
this.selectedRow.set(row);
row.partNumberSl = scanned;
row.palletNumber = palletNum;
if (!this.changedRecords.some((x) => x.transactionNumber === row.transactionNumber)) {
this.changedRecords.push(row);
}
}
await this.saveChanges();
this.changedRecords = [];
setTimeout(() => this.scanner()?.nativeElement.focus());
}
async splitLine(): Promise<void> {
const qty = parseInt(this.newQuantity, 10);
const selected = this.selectedRow();
if (qty <= 0 || !selected) return;
const splitRow: WzRowMeyleDto = {
id: crypto.randomUUID(),
fk_Header: selected.fk_Header,
quantity: qty,
faIndex: selected.faIndex,
itemNumber: selected.itemNumber,
orderNumber: selected.orderNumber,
palletNumber: selected.palletNumber,
wzNumber: selected.wzNumber,
transactionNumber: (selected.transactionNumber ?? 0) + 10000,
partNumberSl: selected.partNumberSl,
partNumber: selected.partNumber,
};
selected.quantity = (selected.quantity ?? 0) - qty;
await this.warehouseService.createWzRowsMeyle([splitRow]);
await this.updateRows([selected]);
this.showSplitDialog.set(false);
}
private async updateRows(changed: WzRowMeyleDto[]): Promise<void> {
await this.warehouseService.updateWzRowsMeyle(changed);
this.rows.set(await this.warehouseService.getWzRowsMeyleByWzHeaderId(this.wzHeaderId));
}
}

View File

@@ -0,0 +1,83 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
CancelEditableRow,
CellEditor,
EditableRow,
InitEditableRow,
SaveEditableRow,
TableModule,
} from 'primeng/table';
import { InputTextModule } from 'primeng/inputtext';
import { ButtonDirective } from 'primeng/button';
import { ProductDto } from '../core/models';
import { ProductService } from '../core/services/product.service';
import { PageShellComponent } from '../shared/components/page-shell/page-shell.component';
import { ContentSectionComponent } from '../shared/components/content-section/content-section.component';
@Component({
selector: 'app-products-list',
standalone: true,
imports: [TableModule, CellEditor, EditableRow, InitEditableRow, SaveEditableRow, CancelEditableRow, InputTextModule, ButtonDirective, FormsModule, PageShellComponent, ContentSectionComponent],
template: `
<app-page-shell
title="Zarządzanie Indeksami"
subtitle="Mapowanie indeksów odbiorców na kody FA"
icon="pi pi-box"
>
<app-content-section title="Indeksy produktów" icon="pi pi-list" [flush]="true">
<p-table [value]="products()" [paginator]="true" [rows]="10" editMode="row" dataKey="id" styleClass="p-datatable-sm" [showGridlines]="true" [stripedRows]="true">
<ng-template #header>
<tr>
<th>ID</th>
<th>Odbiorca</th>
<th>Indeks odbiorcy</th>
<th>Kod FA</th>
<th style="width: 8rem"></th>
</tr>
</ng-template>
<ng-template #body let-product let-editing="editing" let-ri="rowIndex">
<tr [pEditableRow]="product">
<td>{{ product.id }}</td>
<td>{{ product.recipientName }}</td>
<td>{{ product.recipientIdx }}</td>
<td>
<p-cell-editor>
<ng-template #input>
<input pInputText [(ngModel)]="product.faIdx" />
</ng-template>
<ng-template #output>{{ product.faIdx }}</ng-template>
</p-cell-editor>
</td>
<td>
@if (!editing) {
<button pButton type="button" [text]="true" [iconOnly]="true" pInitEditableRow><i class="pi pi-pencil"></i></button>
} @else {
<button pButton type="button" [text]="true" [iconOnly]="true" (click)="saveProduct(product)" pSaveEditableRow><i class="pi pi-check"></i></button>
<button pButton type="button" severity="secondary" [text]="true" [iconOnly]="true" pCancelEditableRow><i class="pi pi-times"></i></button>
}
</td>
</tr>
</ng-template>
</p-table>
</app-content-section>
</app-page-shell>
`,
})
export class ProductsListComponent implements OnInit {
private readonly productService = inject(ProductService);
readonly products = signal<ProductDto[]>([]);
async ngOnInit(): Promise<void> {
await this.loadProducts();
}
async saveProduct(product: ProductDto): Promise<void> {
await this.productService.update(product);
await this.loadProducts();
}
private async loadProducts(): Promise<void> {
this.products.set(await this.productService.getByIndex('Uzupelnij'));
}
}

View File

@@ -0,0 +1,121 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { DatePipe } from '@angular/common';
import { RowToggler, TableModule } from 'primeng/table';
import { ButtonDirective } from 'primeng/button';
import { ScheduleOrderDto, ScheduleOrderDetailDto } from '../core/models';
import { ScheduleOrderService } from '../core/services/schedule-order.service';
import { isQtyTypeHighlighted } from '../shared/utils/business-logic';
import { PageShellComponent } from '../shared/components/page-shell/page-shell.component';
import { ContentSectionComponent } from '../shared/components/content-section/content-section.component';
@Component({
selector: 'app-schedule-order-detail',
standalone: true,
imports: [TableModule, RowToggler, ButtonDirective, DatePipe, PageShellComponent, ContentSectionComponent],
template: `
<app-page-shell
[title]="'Zamówienie DELFOR nr ' + (order()?.poNum ?? 'Brak numeru') + ' (ID: ' + (order()?.id ?? '') + ')'"
subtitle="Szczegóły harmonogramu dostaw DELFOR"
icon="pi pi-building"
>
@if (order()?.scheduleOrderMiscs?.length) {
<app-content-section title="Informacje dodatkowe" icon="pi pi-info-circle">
<ul class="misc-list">
@for (misc of order()!.scheduleOrderMiscs; track misc.id) {
@if (misc.display !== false) {
<li><b>{{ misc.label }}:</b> {{ misc.value }}</li>
}
}
</ul>
</app-content-section>
}
<app-content-section title="Indeksy" icon="pi pi-list" [flush]="true">
<p-table [value]="details()" dataKey="id" [paginator]="true" [rows]="10" styleClass="p-datatable-sm" [showGridlines]="true" [stripedRows]="true">
<ng-template #header>
<tr>
<th style="width: 3rem"></th>
<th>Numer Zamówienia</th>
<th>Klient</th>
<th>Odbiorca</th>
<th>Kod odbiorcy</th>
<th>Pozycja</th>
<th>Pozycja Klienta</th>
</tr>
</ng-template>
<ng-template #body let-detail let-expanded="expanded">
<tr>
<td>
<button pButton type="button" [text]="true" [rounded]="true" [iconOnly]="true" [pRowToggler]="detail"><i [class]="expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"></i></button>
</td>
<td>{{ detail.orderNumber }}</td>
<td>{{ detail.purchaserName }}</td>
<td>{{ detail.recipientName }}</td>
<td>{{ detail.recipientCode }}</td>
<td>{{ detail.sh_productCode }}</td>
<td>{{ detail.sc_productCode }}</td>
</tr>
</ng-template>
<ng-template #expandedrow let-detail>
<tr>
<td colspan="7">
<app-content-section title="Harmonogramy" icon="pi pi-calendar" [flush]="true">
<p-table [value]="detail.scheduleOrderDetailDetails ?? []" [paginator]="true" [rows]="10" styleClass="p-datatable-sm" [showGridlines]="true" [stripedRows]="true">
<ng-template #header>
<tr>
<th>Data Od</th>
<th>Data Do</th>
<th>Ilość Sztuk</th>
<th>Typ Qty</th>
<th>Opis Typu</th>
</tr>
</ng-template>
<ng-template #body let-row>
<tr [class.qty-type-highlight]="isHighlighted(row.qtyType)">
<td>{{ row.dateFrom | date: 'dd.MM.yyyy' }}</td>
<td>{{ row.dateTo | date: 'dd.MM.yyyy' }}</td>
<td>{{ row.qty }}</td>
<td>{{ row.qtyType }}</td>
<td>{{ row.qtyDesc }}</td>
</tr>
</ng-template>
</p-table>
</app-content-section>
</td>
</tr>
</ng-template>
</p-table>
</app-content-section>
</app-page-shell>
`,
styles: `
.misc-list { list-style: none; padding: 0; margin: 0; }
.misc-list li { margin-bottom: var(--spacing-1); }
`,
})
export class ScheduleOrderDetailComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly scheduleOrderService = inject(ScheduleOrderService);
readonly order = signal<ScheduleOrderDto | null>(null);
readonly details = signal<ScheduleOrderDetailDto[]>([]);
async ngOnInit(): Promise<void> {
const id = Number(this.route.snapshot.paramMap.get('scheduleOrderId'));
const data = await this.scheduleOrderService.getById(id);
this.order.set(data);
const details = [...(data.scheduleOrderDetails ?? [])];
for (const d of details) {
d.orderNumber = data.poNum;
d.recipientCode = data.recipientCode;
d.recipientName = data.recipientName;
d.purchaserName = data.purchaserCode;
}
this.details.set(details);
}
isHighlighted(qtyType?: string): boolean {
return qtyType === '83' || qtyType === '84' || isQtyTypeHighlighted(qtyType);
}
}

View File

@@ -0,0 +1,29 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { ScheduleOrderDto } from '../core/models';
import { ScheduleOrderService } from '../core/services/schedule-order.service';
import { ScheduleOrdersGridComponent } from '../shared/components/schedule-orders-grid/schedule-orders-grid.component';
import { PageShellComponent } from '../shared/components/page-shell/page-shell.component';
@Component({
selector: 'app-schedule-orders-list',
standalone: true,
imports: [ScheduleOrdersGridComponent, PageShellComponent],
template: `
<app-page-shell
title="Zamówienia DELFOR"
subtitle="Przegląd harmonogramów dostaw i zamówień klientów"
icon="pi pi-building"
>
<app-schedule-orders-grid [gridData]="orders()" [pageSize]="20" />
</app-page-shell>
`,
})
export class ScheduleOrdersListComponent implements OnInit {
private readonly scheduleOrderService = inject(ScheduleOrderService);
readonly orders = signal<ScheduleOrderDto[]>([]);
async ngOnInit(): Promise<void> {
const data = await this.scheduleOrderService.getAll();
this.orders.set([...data].sort((a, b) => new Date(b.lastUpdateDate).getTime() - new Date(a.lastUpdateDate).getTime()));
}
}

View File

@@ -0,0 +1,44 @@
import { Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Select } from 'primeng/select';
@Component({
selector: 'app-column-values-filter',
standalone: true,
imports: [Select, FormsModule],
template: `
<p-select
[ngModel]="value()"
[options]="options()"
[optionLabel]="optionLabel()"
[optionValue]="optionValue()"
[multiple]="true"
placeholder="Wybierz wartości"
emptyMessage="Brak wartości"
emptyFilterMessage="Brak wyników"
[showClear]="true"
[filter]="true"
appendTo="body"
styleClass="column-values-filter w-full"
(onChange)="onChange($event.value)"
/>
`,
styles: `
:host {
display: block;
min-width: 14rem;
max-width: 20rem;
}
`,
})
export class ColumnValuesFilterComponent {
value = input<unknown>();
options = input<unknown[]>([]);
optionLabel = input<string | undefined>(undefined);
optionValue = input<string | undefined>(undefined);
valueChange = output<unknown>();
onChange(value: unknown): void {
this.valueChange.emit(value);
}
}

View File

@@ -0,0 +1,31 @@
import { Component, input } from '@angular/core';
@Component({
selector: 'app-content-section',
standalone: true,
template: `
<section class="content-section" [class.content-section--flush]="flush()">
@if (title()) {
<header class="content-section__header">
<div class="content-section__heading">
@if (icon()) {
<i [class]="icon()" class="content-section__icon" aria-hidden="true"></i>
}
<h2 class="content-section__title">{{ title() }}</h2>
</div>
<div class="content-section__actions">
<ng-content select="[headerActions]" />
</div>
</header>
}
<div class="content-section__body">
<ng-content />
</div>
</section>
`,
})
export class ContentSectionComponent {
readonly title = input<string>('');
readonly icon = input<string>('');
readonly flush = input<boolean>(false);
}

View File

@@ -0,0 +1,36 @@
import { Component, input } from '@angular/core';
@Component({
selector: 'app-page-shell',
standalone: true,
template: `
<div class="page-shell">
<header class="page-shell__header">
<div class="page-shell__heading">
@if (icon()) {
<div class="page-shell__icon" aria-hidden="true">
<i [class]="icon()"></i>
</div>
}
<div class="page-shell__titles">
<h1 class="page-shell__title">{{ title() }}</h1>
@if (subtitle()) {
<p class="page-shell__subtitle">{{ subtitle() }}</p>
}
</div>
</div>
<div class="page-shell__actions">
<ng-content select="[actions]" />
</div>
</header>
<div class="page-shell__content">
<ng-content />
</div>
</div>
`,
})
export class PageShellComponent {
readonly title = input.required<string>();
readonly subtitle = input<string>('');
readonly icon = input<string>('');
}

View File

@@ -0,0 +1,517 @@
import {
AfterViewInit,
Component,
effect,
inject,
input,
OnInit,
untracked,
ViewChild,
} from '@angular/core';
import { Router } from '@angular/router';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
ColumnFilter,
RowToggler,
SelectableRow,
SortableColumn,
SortIcon,
Table,
TableModule,
} from 'primeng/table';
import { ButtonDirective } from 'primeng/button';
import { InputTextModule } from 'primeng/inputtext';
import { IconFieldModule } from 'primeng/iconfield';
import { InputIconModule } from 'primeng/inputicon';
import { MessageService } from 'primeng/api';
import { ScheduleOrderDto, ScheduleOrderDetailDetailDto } from '../../../core/models';
import { ScheduleOrderService } from '../../../core/services/schedule-order.service';
import { ContentSectionComponent } from '../content-section/content-section.component';
import { ColumnValuesFilterComponent } from '../column-values-filter/column-values-filter.component';
import { isQtyTypeHighlighted } from '../../utils/business-logic';
import {
applySavedFiltersToTable,
clearTableFilters,
deleteGridFilters,
loadSavedGridFilters,
saveGridFilters,
serializeTableFilters,
} from '../../utils/grid-filter-storage';
import {
distinctDateValues,
distinctNumericValues,
distinctStringValues,
LabeledFilterValue,
} from '../../utils/grid-filter-values';
const GRID_FILTER_KEY = 'gridFilter';
@Component({
selector: 'app-schedule-orders-grid',
standalone: true,
imports: [
TableModule,
Table,
RowToggler,
SelectableRow,
SortableColumn,
SortIcon,
ColumnFilter,
ButtonDirective,
InputTextModule,
IconFieldModule,
InputIconModule,
FormsModule,
ContentSectionComponent,
ColumnValuesFilterComponent,
DatePipe,
],
template: `
<app-content-section title="Lista zamówień" icon="pi pi-table" [flush]="true">
<div headerActions>
<button pButton type="button" size="small" (click)="saveFilters()"><i class="pi pi-save"></i> Zapisz filtry</button>
<button pButton type="button" severity="secondary" size="small" (click)="deleteFilters()"><i class="pi pi-trash"></i> Usuń zapisane filtry</button>
</div>
<p-table
#mainTable
[value]="gridData()"
[paginator]="true"
[rows]="pageSize()"
[rowsPerPageOptions]="[10, 20, 50]"
[globalFilterFields]="globalFilterFields"
[filterDelay]="300"
dataKey="id"
[expandedRowKeys]="expandedKeys"
(onRowExpand)="onRowExpand($event)"
styleClass="p-datatable-sm erp-table"
[showGridlines]="true"
[stripedRows]="true"
>
<ng-template #caption>
<div class="data-toolbar">
<div class="data-toolbar__left">
<span class="text-secondary" style="font-size: var(--font-size-sm)">
{{ filteredCount() }} / {{ gridData().length }} rekordów · dwuklik otwiera szczegóły
</span>
</div>
<div class="data-toolbar__right">
<p-iconfield iconPosition="left" class="data-toolbar__search">
<p-inputicon styleClass="pi pi-search" />
<input
pInputText
type="text"
[(ngModel)]="globalFilterValue"
placeholder="Szukaj w tabeli…"
(ngModelChange)="onGlobalFilterChange($event)"
/>
</p-iconfield>
</div>
</div>
</ng-template>
<ng-template #header>
<tr>
<th style="width: 3rem"></th>
<th pSortableColumn="poNum">
<div class="column-header">
<span>Zamówienie Klienta</span>
<p-sort-icon field="poNum" />
<p-column-filter field="poNum" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctMainColumn('poNum')" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div>
</th>
<th pSortableColumn="purchaserCode">
<div class="column-header">
<span>Klient</span>
<p-sort-icon field="purchaserCode" />
<p-column-filter field="purchaserCode" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctMainColumn('purchaserCode')" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div>
</th>
<th pSortableColumn="recipientName">
<div class="column-header">
<span>Odbiorca</span>
<p-sort-icon field="recipientName" />
<p-column-filter field="recipientName" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctMainColumn('recipientName')" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div>
</th>
<th pSortableColumn="recipientCode">
<div class="column-header">
<span>Kod odbiorcy</span>
<p-sort-icon field="recipientCode" />
<p-column-filter field="recipientCode" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctMainColumn('recipientCode')" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div>
</th>
<th pSortableColumn="lastUpdateDate">
<div class="column-header">
<span>Data</span>
<p-sort-icon field="lastUpdateDate" />
<p-column-filter field="lastUpdateDate" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctMainDates()" optionLabel="label" optionValue="value" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div>
</th>
<th pSortableColumn="docType">
<div class="column-header">
<span>Typ Dokumentu</span>
<p-sort-icon field="docType" />
<p-column-filter field="docType" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctMainColumn('docType')" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div>
</th>
</tr>
</ng-template>
<ng-template #body let-order let-expanded="expanded">
<tr (dblclick)="navigateToOrder(order.id)" [pSelectableRow]="order" class="erp-table__row">
<td>
<button pButton type="button" size="small" [text]="true" [rounded]="true" [iconOnly]="true" [pRowToggler]="order"><i [class]="expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"></i></button>
</td>
<td><span class="font-medium">{{ order.poNum }}</span></td>
<td>{{ order.purchaserCode }}</td>
<td>{{ order.recipientName }}</td>
<td>{{ order.recipientCode }}</td>
<td>{{ order.lastUpdateDate | date: 'dd.MM.yyyy' }}</td>
<td>{{ order.docType }}</td>
</tr>
</ng-template>
<ng-template #expandedrow let-order>
<tr>
<td colspan="7" class="erp-table__nested">
<p-table
[value]="order.scheduleOrderDetails ?? []"
dataKey="id"
[paginator]="true"
[rows]="10"
[filterDelay]="300"
styleClass="p-datatable-sm"
[showGridlines]="true"
>
<ng-template #header>
<tr>
<th style="width: 3rem"></th>
<th>
<div class="column-header"><span>Numer Zamówienia</span>
<p-column-filter field="orderNumber" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctFromRows(order.scheduleOrderDetails, 'orderNumber')" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div>
</th>
<th>
<div class="column-header"><span>Klient</span>
<p-column-filter field="purchaserName" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctFromRows(order.scheduleOrderDetails, 'purchaserName')" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div>
</th>
<th>
<div class="column-header"><span>Odbiorca</span>
<p-column-filter field="recipientName" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctFromRows(order.scheduleOrderDetails, 'recipientName')" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div>
</th>
<th>
<div class="column-header"><span>Kod odbiorcy</span>
<p-column-filter field="recipientCode" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctFromRows(order.scheduleOrderDetails, 'recipientCode')" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div>
</th>
<th>
<div class="column-header"><span>Pozycja Klienta</span>
<p-column-filter field="sc_productCode" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctFromRows(order.scheduleOrderDetails, 'sc_productCode')" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div>
</th>
<th>
<div class="column-header"><span>Pozycja</span>
<p-column-filter field="sh_productCode" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctFromRows(order.scheduleOrderDetails, 'sh_productCode')" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div>
</th>
</tr>
</ng-template>
<ng-template #body let-detail let-detailExpanded="expanded">
<tr (dblclick)="navigateToOrder(order.id)">
<td>
<button pButton type="button" size="small" [text]="true" [rounded]="true" [iconOnly]="true" [pRowToggler]="detail"><i [class]="detailExpanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"></i></button>
</td>
<td>{{ detail.orderNumber }}</td>
<td>{{ detail.purchaserName }}</td>
<td>{{ detail.recipientName }}</td>
<td>{{ detail.recipientCode }}</td>
<td>{{ detail.sc_productCode }}</td>
<td>{{ detail.sh_productCode }}</td>
</tr>
</ng-template>
<ng-template #expandedrow let-detail>
<tr>
<td colspan="7" class="erp-table__nested">
<p-table
[value]="detail.scheduleOrderDetailDetails ?? []"
[paginator]="true"
[rows]="10"
[filterDelay]="300"
styleClass="p-datatable-sm"
[showGridlines]="true"
>
<ng-template #header>
<tr>
<th><div class="column-header"><span>Data Od</span>
<p-column-filter field="dateFrom" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctDatesFromRows(detail.scheduleOrderDetailDetails, 'dateFrom')" optionLabel="label" optionValue="value" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div></th>
<th><div class="column-header"><span>Data Do</span>
<p-column-filter field="dateTo" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctDatesFromRows(detail.scheduleOrderDetailDetails, 'dateTo')" optionLabel="label" optionValue="value" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div></th>
<th><div class="column-header"><span>Ilość Sztuk</span>
<p-column-filter field="qty" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctNumbersFromRows(detail.scheduleOrderDetailDetails, 'qty')" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div></th>
<th><div class="column-header"><span>Typ Qty</span>
<p-column-filter field="qtyType" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctFromRows(detail.scheduleOrderDetailDetails, 'qtyType')" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div></th>
<th><div class="column-header"><span>Opis Typu</span>
<p-column-filter field="qtyDesc" matchMode="in" display="menu" [showMatchModes]="false" [showOperator]="false" [showApplyButton]="false" [showAddButton]="false">
<ng-template #filter let-value let-filter="filterCallback">
<app-column-values-filter [value]="value" [options]="distinctFromRows(detail.scheduleOrderDetailDetails, 'qtyDesc')" (valueChange)="filter($event)" />
</ng-template>
</p-column-filter>
</div></th>
</tr>
</ng-template>
<ng-template #body let-qtyRow>
<tr [class.qty-type-highlight]="isHighlighted(qtyRow.qtyType)" (dblclick)="navigateToOrder(order.id)">
<td>{{ qtyRow.dateFrom | date: 'dd.MM.yyyy' }}</td>
<td>{{ qtyRow.dateTo | date: 'dd.MM.yyyy' }}</td>
<td>{{ qtyRow.qty }}</td>
<td>{{ qtyRow.qtyType }}</td>
<td>{{ qtyRow.qtyDesc }}</td>
</tr>
</ng-template>
</p-table>
</td>
</tr>
</ng-template>
</p-table>
</td>
</tr>
</ng-template>
</p-table>
</app-content-section>
`,
styles: `
.column-header {
display: flex;
align-items: center;
gap: var(--spacing-1);
white-space: nowrap;
}
:host ::ng-deep .erp-table__nested {
padding: var(--spacing-3) !important;
background: var(--color-bg-subtle) !important;
}
:host ::ng-deep .p-datatable-column-filter-button {
width: 1.75rem;
height: 1.75rem;
}
`,
})
export class ScheduleOrdersGridComponent implements OnInit, AfterViewInit {
private readonly scheduleOrderService = inject(ScheduleOrderService);
private readonly router = inject(Router);
private readonly messageService = inject(MessageService);
readonly gridData = input.required<ScheduleOrderDto[]>();
readonly pageSize = input.required<number>();
@ViewChild('mainTable') mainTable?: Table;
readonly globalFilterFields = ['poNum', 'purchaserCode', 'recipientName', 'recipientCode', 'docType'];
expandedKeys: Record<string, boolean> = {};
globalFilterValue = '';
private viewReady = false;
private lastDataLength = -1;
constructor() {
effect(() => {
const data = this.gridData();
const length = data.length;
untracked(() => {
if (!this.viewReady || length === this.lastDataLength) return;
this.lastDataLength = length;
queueMicrotask(() => this.restoreFilters());
});
});
}
ngOnInit(): void {
const saved = loadSavedGridFilters(GRID_FILTER_KEY);
if (saved?.global) {
this.globalFilterValue = saved.global;
}
}
ngAfterViewInit(): void {
this.viewReady = true;
this.lastDataLength = this.gridData().length;
queueMicrotask(() => this.restoreFilters());
}
filteredCount(): number {
if (!this.mainTable?.filteredValue) {
return this.gridData().length;
}
return this.mainTable.filteredValue.length;
}
isHighlighted(qtyType?: string): boolean {
return isQtyTypeHighlighted(qtyType);
}
distinctMainColumn(field: keyof ScheduleOrderDto): string[] {
return distinctStringValues(this.gridData(), field);
}
distinctMainDates(): LabeledFilterValue[] {
return distinctDateValues(this.gridData(), 'lastUpdateDate');
}
distinctFromRows<T>(rows: T[] | null | undefined, field: keyof T): string[] {
return distinctStringValues(rows, field);
}
distinctDatesFromRows(
rows: ScheduleOrderDetailDetailDto[] | null | undefined,
field: keyof ScheduleOrderDetailDetailDto,
): LabeledFilterValue[] {
return distinctDateValues(rows, field);
}
distinctNumbersFromRows(
rows: ScheduleOrderDetailDetailDto[] | null | undefined,
field: keyof ScheduleOrderDetailDetailDto,
): number[] {
return distinctNumericValues(rows, field);
}
navigateToOrder(id: number): void {
this.router.navigate(['/ScheduleOrder', id]);
}
onGlobalFilterChange(value: string): void {
this.globalFilterValue = value;
this.mainTable?.filterGlobal(value, 'contains');
}
async onRowExpand(event: { data: ScheduleOrderDto }): Promise<void> {
const order = event.data;
if (order.scheduleOrderDetails?.length) return;
const full = await this.scheduleOrderService.getById(order.id);
order.scheduleOrderDetails = full.scheduleOrderDetails ?? [];
for (const detail of order.scheduleOrderDetails) {
detail.orderNumber = full.poNum;
detail.recipientCode = full.recipientCode;
detail.recipientName = full.recipientName;
detail.purchaserName = full.purchaserCode;
}
}
saveFilters(): void {
if (!this.mainTable) return;
const state = serializeTableFilters(this.mainTable);
if (!state) {
this.messageService.add({
severity: 'warn',
summary: 'Brak filtrów',
detail: 'Ustaw filtry kolumn lub wyszukiwanie globalne przed zapisaniem.',
});
return;
}
saveGridFilters(GRID_FILTER_KEY, state);
this.messageService.add({
severity: 'success',
summary: 'Filtry zapisane',
detail: 'Filtry zostaną przywrócone przy następnym wejściu na listę.',
});
}
deleteFilters(): void {
deleteGridFilters(GRID_FILTER_KEY);
this.globalFilterValue = '';
if (this.mainTable) {
clearTableFilters(this.mainTable);
}
this.messageService.add({
severity: 'info',
summary: 'Filtry usunięte',
detail: 'Zapisane filtry zostały usunięte z pamięci przeglądarki.',
});
}
private restoreFilters(): void {
if (!this.mainTable) return;
const saved = loadSavedGridFilters(GRID_FILTER_KEY);
if (!saved) return;
this.globalFilterValue = applySavedFiltersToTable(this.mainTable, saved, this.globalFilterValue);
}
}

View File

@@ -0,0 +1,52 @@
import { WzRowMarelliDto, WzRowMeyleDto } from '../../core/models';
export function isValidMeyleScannedValue(scannedValue: string): boolean {
const year = new Date().getFullYear() - 2000;
return scannedValue.startsWith(`${year}X`);
}
export function findCombinationsByQuantity<T extends { quantity?: number | null }>(
records: T[],
targetSum: number,
): T[][] {
const result: T[][] = [];
const currentCombination: T[] = [];
function backtrack(start: number, currentSum: number): void {
if (currentSum === targetSum) {
result.push([...currentCombination]);
return;
}
for (let i = start; i < records.length; i++) {
const qty = records[i].quantity ?? 0;
if (currentSum + qty <= targetSum) {
currentCombination.push(records[i]);
backtrack(i + 1, currentSum + qty);
currentCombination.pop();
}
}
}
backtrack(0, 0);
return result;
}
export function findCombinations(records: WzRowMeyleDto[], targetSum: number): WzRowMeyleDto[][] {
return findCombinationsByQuantity(records, targetSum);
}
export function findMarelliCombinations(records: WzRowMarelliDto[], targetSum: number): WzRowMarelliDto[][] {
return findCombinationsByQuantity(records, targetSum);
}
export function isQtyTypeHighlighted(qtyType?: string): boolean {
return qtyType === '54' || qtyType === '83' || qtyType === '84';
}
export function generateTempPassword(): string {
return crypto.randomUUID().substring(0, 8);
}
export function generateGuid(): string {
return crypto.randomUUID();
}

View File

@@ -0,0 +1,105 @@
import { FilterMetadata } from 'primeng/api';
import { Table } from 'primeng/table';
export interface SavedGridFilterState {
global?: string;
columns?: Record<string, FilterMetadata | FilterMetadata[]>;
}
export function serializeTableFilters(table: Table): SavedGridFilterState | null {
const columns: Record<string, FilterMetadata | FilterMetadata[]> = {};
for (const [key, meta] of Object.entries(table.filters ?? {})) {
if (key === 'global') continue;
if (hasFilterValue(meta)) {
columns[key] = JSON.parse(JSON.stringify(meta));
}
}
const globalMeta = table.filters?.['global'] as FilterMetadata | undefined;
const global = typeof globalMeta?.value === 'string' ? globalMeta.value : '';
if (!global && Object.keys(columns).length === 0) {
return null;
}
return { global, columns };
}
type RestorableFilterMetadata = FilterMetadata & { applyFilter?: boolean };
export function applySavedFiltersToTable(table: Table, saved: SavedGridFilterState, globalFilterValue: string): string {
table.clearFilterValues();
if (saved.columns && Object.keys(saved.columns).length > 0) {
table.restoringFilter = true;
const restored: Record<string, RestorableFilterMetadata | RestorableFilterMetadata[]> = {};
for (const [key, meta] of Object.entries(saved.columns)) {
if (Array.isArray(meta)) {
restored[key] = meta.map((m) => ({ ...m, applyFilter: hasFilterValue(m) }));
} else if (meta) {
restored[key] = { ...meta, applyFilter: hasFilterValue(meta) };
}
}
table.filters = restored as Record<string, FilterMetadata | FilterMetadata[]>;
}
const global = saved.global ?? globalFilterValue ?? '';
if (global) {
table.filterGlobal(global, 'contains');
return global;
}
table._filter();
return '';
}
export function clearTableFilters(table: Table): void {
table.clearFilterValues();
table.filteredValue = null;
table._filter();
table.tableService.onValueChange(table.value);
}
function hasFilterValue(meta: FilterMetadata | FilterMetadata[] | undefined): boolean {
if (!meta) return false;
if (Array.isArray(meta)) {
return meta.some((m) => hasSingleFilterValue(m));
}
return hasSingleFilterValue(meta);
}
function hasSingleFilterValue(meta: FilterMetadata): boolean {
if (meta.value == null) return false;
if (Array.isArray(meta.value)) return meta.value.length > 0;
return meta.value !== '';
}
export function loadSavedGridFilters(storageKey: string): SavedGridFilterState | null {
const raw = localStorage.getItem(storageKey);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as SavedGridFilterState | { global?: string };
// Legacy format from earlier Angular migration (global only)
if (parsed && !('columns' in parsed) && parsed.global) {
return { global: parsed.global, columns: {} };
}
return parsed as SavedGridFilterState;
} catch {
localStorage.removeItem(storageKey);
return null;
}
}
export function saveGridFilters(storageKey: string, state: SavedGridFilterState): void {
localStorage.setItem(storageKey, JSON.stringify(state));
}
export function deleteGridFilters(storageKey: string): void {
localStorage.removeItem(storageKey);
}

View File

@@ -0,0 +1,58 @@
export interface LabeledFilterValue {
label: string;
value: string;
}
export function formatGridDate(iso: string): string {
if (!iso) return '';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleDateString('pl-PL', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
export function distinctStringValues<T>(rows: T[] | null | undefined, field: keyof T): string[] {
if (!rows?.length) return [];
const values = new Set<string>();
for (const row of rows) {
const raw = row[field];
if (raw == null || raw === '') continue;
values.add(String(raw));
}
return [...values].sort((a, b) => a.localeCompare(b, 'pl'));
}
export function distinctNumericValues<T>(rows: T[] | null | undefined, field: keyof T): number[] {
if (!rows?.length) return [];
const values = new Set<number>();
for (const row of rows) {
const raw = row[field];
if (typeof raw === 'number' && !Number.isNaN(raw)) {
values.add(raw);
}
}
return [...values].sort((a, b) => a - b);
}
export function distinctDateValues<T>(
rows: T[] | null | undefined,
field: keyof T,
format: (iso: string) => string = formatGridDate,
): LabeledFilterValue[] {
if (!rows?.length) return [];
const byValue = new Map<string, LabeledFilterValue>();
for (const row of rows) {
const raw = row[field];
if (raw == null || raw === '') continue;
const value = String(raw);
if (!byValue.has(value)) {
byValue.set(value, { value, label: format(value) });
}
}
return [...byValue.values()].sort((a, b) => a.label.localeCompare(b.label, 'pl'));
}

View File

@@ -0,0 +1,42 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { ButtonDirective } from 'primeng/button';
@Component({
selector: 'app-unauthorized',
standalone: true,
imports: [ButtonDirective],
template: `
<div class="auth-layout">
<aside class="auth-layout__brand">
<div class="auth-layout__brand-logo">
<img src="logo.svg" width="40" height="40" alt="" />
<h1 class="auth-layout__brand-title">FA Krosno Manager</h1>
</div>
<p class="auth-layout__brand-tagline">
System zarządzania zamówieniami DELFOR, magazynem i procesami EDI dla operacji produkcyjnych.
</p>
<div class="auth-layout__features">
<div class="auth-layout__feature"><i class="pi pi-building"></i> Zamówienia DELFOR i harmonogramy</div>
<div class="auth-layout__feature"><i class="pi pi-warehouse"></i> Magazyn i packing listy</div>
<div class="auth-layout__feature"><i class="pi pi-database"></i> Integracja EDI / Syteline</div>
</div>
</aside>
<main class="auth-layout__form">
<div class="auth-card">
<h2 class="auth-card__title">Brak autoryzacji</h2>
<p class="auth-card__subtitle">Ups! Wygląda na to, że nie masz dostępu do tej strony.</p>
<p class="text-secondary mb-3">
Aby kontynuować, zaloguj się do swojego konta.
</p>
<button pButton type="button" [fluid]="true" (click)="goLogin()"><i class="pi pi-sign-in"></i> Przejdź do logowania</button>
</div>
</main>
</div>
`,
})
export class UnauthorizedComponent {
private readonly router = inject(Router);
goLogin(): void { this.router.navigate(['/login']); }
}

View File

@@ -0,0 +1,250 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { DatePipe } from '@angular/common';
import { Table, TableCheckbox, TableHeaderCheckbox, TableModule } from 'primeng/table';
import { SelectModule } from 'primeng/select';
import { ButtonDirective } from 'primeng/button';
import { DialogModule } from 'primeng/dialog';
import { MaterialTransactionDto, WzClientDto, WzHeaderDto, WzRowMarelliDto, WzRowMeyleDto } from '../core/models';
import { WarehouseService } from '../core/services/warehouse.service';
import { generateGuid } from '../shared/utils/business-logic';
import { PageShellComponent } from '../shared/components/page-shell/page-shell.component';
import { ContentSectionComponent } from '../shared/components/content-section/content-section.component';
const SELECTED_CLIENT_KEY = 'SelectedClientId';
@Component({
selector: 'app-warehouse',
standalone: true,
imports: [TableModule, Table, TableCheckbox, TableHeaderCheckbox, SelectModule, ButtonDirective, DialogModule, FormsModule, DatePipe, PageShellComponent, ContentSectionComponent],
template: `
<app-page-shell
title="Dokumenty WZ na Magazynie"
subtitle="Zarządzanie dokumentami WZ i packing listami dla klientów Meyle i Marelli"
icon="pi pi-warehouse"
>
<app-content-section title="Klient" icon="pi pi-users">
<div class="field-group">
<label class="field-label">Wybierz klienta</label>
<p-select
[options]="clients()"
optionLabel="name"
optionValue="id"
placeholder="Wybierz Klienta"
[(ngModel)]="selectedClientId"
(onChange)="onClientChange()"
styleClass="w-full"
/>
</div>
</app-content-section>
@if (isVisible()) {
<app-content-section title="Dokumenty WZ" icon="pi pi-file" [flush]="true">
<div headerActions>
<button pButton type="button" (click)="createPackingList()"><i class="pi pi-save"></i> Utwórz Packing List</button>
</div>
<p-table
#wzGrid
[value]="wzDataSource()"
[(selection)]="selectedWzRows"
dataKey="mtGroupNum"
[paginator]="true"
[rows]="5"
selectionMode="multiple"
styleClass="p-datatable-sm"
[showGridlines]="true"
[stripedRows]="true"
>
<ng-template #header>
<tr>
<th style="width: 3rem"><p-table-header-checkbox /></th>
<th pSortableColumn="mtGroupNum">Numer WZ</th>
<th pSortableColumn="createDate">Data utworzenia</th>
<th pSortableColumn="refNum">Numer zamówienia</th>
</tr>
</ng-template>
<ng-template #body let-row>
<tr>
<td><p-table-checkbox [value]="row" /></td>
<td>{{ row.mtGroupNum }}</td>
<td>{{ row.createDate | date: 'dd.MM.yyyy' }}</td>
<td>{{ row.refNum }}</td>
</tr>
</ng-template>
</p-table>
</app-content-section>
<app-content-section title="Packling Listy" icon="pi pi-list" [flush]="true">
<p-table [value]="wzHeaders()" [paginator]="true" [rows]="5" selectionMode="single" [(selection)]="selectedHeader" dataKey="id" styleClass="p-datatable-sm" [showGridlines]="true" [stripedRows]="true">
<ng-template #body let-header>
<tr (dblclick)="openPackList(header)">
<td>{{ header.id }}</td>
<td>{{ header.wzNumbers }}</td>
<td>{{ header.createdDate | date: 'dd.MM.yyyy' }}</td>
</tr>
</ng-template>
<ng-template #header>
<tr>
<th>ID</th>
<th>Numery WZ</th>
<th>Data utworzenia</th>
</tr>
</ng-template>
</p-table>
</app-content-section>
}
</app-page-shell>
<p-dialog header="Informacja" [(visible)]="showNoSelectionDialog" [modal]="true" [style]="{ width: '500px' }">
<p>Błąd: Zaznacz przynajmniej jeden rekord, żeby wygenerowac Pack List!</p>
<ng-template #footer>
<button pButton type="button" (click)="showNoSelectionDialog = false">OK</button>
</ng-template>
</p-dialog>
<p-dialog header="Błąd" [(visible)]="showDuplicateDialog" [modal]="true" [style]="{ width: '500px' }">
<p>Błąd: Dla zaznaczonego rekordu istnieje już PackingList!</p>
<ng-template #footer>
<button pButton type="button" (click)="showDuplicateDialog = false">OK</button>
</ng-template>
</p-dialog>
`,
styles: `:host ::ng-deep .w-full { width: 100%; max-width: 400px; }`,
})
export class WarehouseComponent implements OnInit {
private readonly warehouseService = inject(WarehouseService);
private readonly router = inject(Router);
readonly clients = signal<WzClientDto[]>([]);
readonly wzDataSource = signal<MaterialTransactionDto[]>([]);
readonly wzHeaders = signal<WzHeaderDto[]>([]);
readonly materialTransactions = signal<MaterialTransactionDto[]>([]);
readonly isVisible = signal(false);
selectedClientId: string | null = null;
selectedClient: WzClientDto | null = null;
selectedWzRows: MaterialTransactionDto[] = [];
selectedHeader: WzHeaderDto | null = null;
showNoSelectionDialog = false;
showDuplicateDialog = false;
async ngOnInit(): Promise<void> {
const all = await this.warehouseService.getAllClients();
const filtered = all.filter((c) => ['MAGNETI MARELLI', 'MEYLE'].some((n) => n.toLowerCase() === c.name.toLowerCase()));
this.clients.set(filtered);
const saved = localStorage.getItem(SELECTED_CLIENT_KEY);
if (saved && filtered.some((c) => c.id === saved)) {
this.selectedClientId = saved;
await this.onClientChange();
}
}
async onClientChange(): Promise<void> {
if (!this.selectedClientId) {
this.isVisible.set(false);
this.selectedClient = null;
return;
}
this.selectedClient = this.clients().find((c) => c.id === this.selectedClientId) ?? null;
if (!this.selectedClient) return;
this.isVisible.set(true);
localStorage.setItem(SELECTED_CLIENT_KEY, this.selectedClientId);
const txs = await this.warehouseService.getAllClientWzs(
this.selectedClient.customerNumber,
this.selectedClient.customerSequence ?? 0,
);
this.materialTransactions.set(txs);
const grouped = Object.values(
txs.reduce<Record<string, MaterialTransactionDto>>((acc, t) => {
const key = t.mtGroupNum ?? '';
if (!acc[key]) acc[key] = t;
return acc;
}, {}),
);
this.wzDataSource.set(grouped);
this.wzHeaders.set(
await this.warehouseService.getAllClientWzHeaders(
this.selectedClient.customerNumber,
this.selectedClient.customerSequence ?? 0,
),
);
}
openPackList(header: WzHeaderDto): void {
if (!this.selectedClient) return;
this.router.navigate([`/Warehouse/${this.selectedClient.shortName}/PackList/${header.id}`]);
}
async createPackingList(): Promise<void> {
if (!this.selectedWzRows.length) {
this.showNoSelectionDialog = true;
return;
}
const wzNumbers = [...new Set(this.selectedWzRows.map((x) => x.mtGroupNum))].join(', ');
if (this.wzHeaders().some((x) => x.wzNumbers === wzNumbers)) {
this.showDuplicateDialog = true;
return;
}
const wzHeader: WzHeaderDto = {
id: generateGuid(),
fk_Client: this.selectedClient?.id,
createdDate: new Date().toISOString(),
wzNumbers,
};
await this.warehouseService.createWzHeader(wzHeader);
const shortName = this.selectedClient?.shortName.toUpperCase();
const selectedNums = new Set(this.selectedWzRows.map((x) => x.mtGroupNum));
const related = this.materialTransactions().filter((x) => selectedNums.has(x.mtGroupNum));
if (shortName === 'MEYLE') {
const rows: WzRowMeyleDto[] = [];
for (const mt of related) {
const customerOrder = await this.warehouseService.getCustomerOrder(mt.refNum ?? '');
const item = await this.warehouseService.getItem(mt.item ?? '', customerOrder.custNum);
rows.push({
id: generateGuid(),
quantity: Math.abs(mt.qty ?? 0),
itemNumber: item.custItem,
orderNumber: customerOrder.custPo,
wzNumber: mt.mtGroupNum ?? '',
fk_Header: wzHeader.id,
transactionNumber: mt.transNum ?? 0,
partNumberSl: mt.nr_KARTY_KONTROLNEJ,
faIndex: item.item,
});
}
await this.warehouseService.createWzRowsMeyle(rows);
this.router.navigate(['/Warehouse/Meyle/PackList', wzHeader.id]);
} else if (shortName === 'MARELLI') {
const rows: WzRowMarelliDto[] = [];
for (const mt of related) {
const customerOrder = await this.warehouseService.getCustomerOrder(mt.refNum ?? '');
const item = await this.warehouseService.getItem(mt.item ?? '', customerOrder.custNum);
rows.push({
id: generateGuid(),
quantity: Math.abs(mt.qty ?? 0),
itemNumber: item.custItem,
orderNumber: customerOrder.custPo,
wzNumber: mt.mtGroupNum ?? '',
fkHeader: wzHeader.id,
transactionNumber: mt.transNum ?? 0,
type: 'MIX',
faIndex: item.item,
engineerNumber: item.uf_FKR_CustItem2 ?? '',
});
}
await this.warehouseService.createWzRowsMarelli(rows);
this.router.navigate(['/Warehouse/Marelli/PackList', wzHeader.id]);
}
}
}

View File

@@ -0,0 +1,6 @@
export const environment = {
production: true,
apiBaseUrl: 'http://localhost:5001',
/** Free community key from https://primeui.dev/licenses/community */
primeNgLicense: '',
};

View File

@@ -0,0 +1,6 @@
export const environment = {
production: false,
apiBaseUrl: 'http://localhost:5001',
/** Free community key from https://primeui.dev/licenses/community */
primeNgLicense: 'eyJpZCI6IjZmN2Q2YTA3LWE3NWEtNDdmOC1hYzIwLWI0ZTJlN2JmM2NiYyIsInByb2R1Y3QiOiJwcmltZXVpIiwidGllciI6ImNvbW11bml0eSIsInR5cGUiOiJkZXYiLCJpYXQiOjE3ODQ0MDM4MTUsImV4cCI6MTgxNTkzOTgxNX0.wFfRpKvBUOoX9xV2XahDj_gPlaYRKWtIVS04uFXdDUgDxI9c5UvsagWuhzCc0nFISCdZDVTol2s7Y9WTyoxdDA',
};

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<title>FA Krosno Manager</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

View File

@@ -0,0 +1,131 @@
:root {
/* Surfaces */
--color-bg: #eef1f6;
--color-bg-subtle: #e4e8ef;
--color-surface: #ffffff;
--color-surface-raised: #ffffff;
--color-surface-hover: #f3f5f9;
--color-surface-active: #e8edf5;
--color-border: #d8dee9;
--color-border-strong: #c2cad8;
/* Sidebar */
--color-sidebar-bg: #1e293b;
--color-sidebar-surface: #243044;
--color-sidebar-border: rgba(255, 255, 255, 0.08);
--color-sidebar-text: #cbd5e1;
--color-sidebar-text-muted: #94a3b8;
--color-sidebar-text-active: #ffffff;
--color-sidebar-accent: #3b82f6;
--color-sidebar-accent-soft: rgba(59, 130, 246, 0.16);
/* Text */
--color-text-primary: #0f172a;
--color-text-secondary: #64748b;
--color-text-muted: #94a3b8;
--color-text-disabled: #cbd5e1;
--color-text-inverse: #ffffff;
/* Brand accent */
--color-accent: #1a56db;
--color-accent-hover: #1648b8;
--color-accent-active: #123a94;
--color-accent-soft: rgba(26, 86, 219, 0.08);
--color-accent-border: rgba(26, 86, 219, 0.24);
/* Status */
--color-success: #047857;
--color-success-soft: rgba(4, 120, 87, 0.1);
--color-warning: #b45309;
--color-warning-soft: rgba(180, 83, 9, 0.1);
--color-error: #b91c1c;
--color-error-soft: rgba(185, 28, 28, 0.1);
--color-info: #0369a1;
--color-info-soft: rgba(3, 105, 161, 0.1);
/* Spacing */
--spacing-1: 4px;
--spacing-2: 8px;
--spacing-3: 12px;
--spacing-4: 16px;
--spacing-5: 20px;
--spacing-6: 24px;
--spacing-7: 32px;
/* Layout */
--sidebar-width: 252px;
--sidebar-collapsed-width: 64px;
--topbar-height: 52px;
--page-max-width: 1440px;
--content-padding: 20px;
/* Typography */
--font-family: 'Inter', 'IBM Plex Sans', 'Segoe UI', sans-serif;
--font-size-xs: 11px;
--font-size-sm: 12.5px;
--font-size-base: 13.5px;
--font-size-md: 14px;
--font-size-lg: 16px;
--font-size-xl: 18px;
--font-size-2xl: 22px;
--line-height-tight: 1.25;
--line-height-normal: 1.5;
/* Elevation */
--shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.08);
--shadow-lg: 0 12px 32px rgba(15, 23, 42, 0.12);
/* Radius — ERP: subtle, not bubbly */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
/* Tables */
--table-header-bg: #f8fafc;
--table-row-hover: #f1f5f9;
--table-row-stripe: #fafbfc;
--table-border: #e2e8f0;
}
[data-theme='dark'] {
--color-bg: #0b1220;
--color-bg-subtle: #111827;
--color-surface: #151f32;
--color-surface-raised: #1a2438;
--color-surface-hover: #1e293b;
--color-surface-active: #243044;
--color-border: #2d3a52;
--color-border-strong: #3d4f6f;
--color-sidebar-bg: #0f172a;
--color-sidebar-surface: #151f32;
--color-sidebar-border: rgba(255, 255, 255, 0.06);
--color-sidebar-text: #94a3b8;
--color-sidebar-text-muted: #64748b;
--color-sidebar-text-active: #f8fafc;
--color-sidebar-accent: #60a5fa;
--color-sidebar-accent-soft: rgba(96, 165, 250, 0.14);
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
--color-text-disabled: #475569;
--color-accent: #3b82f6;
--color-accent-hover: #2563eb;
--color-accent-active: #1d4ed8;
--color-accent-soft: rgba(59, 130, 246, 0.12);
--color-accent-border: rgba(59, 130, 246, 0.3);
--table-header-bg: #1a2438;
--table-row-hover: #1e293b;
--table-row-stripe: #151f32;
--table-border: #2d3a52;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.24);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.32);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.4);
}

View File

@@ -0,0 +1,572 @@
@use 'design-tokens';
@import 'primeicons/primeicons.css';
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
font-family: var(--font-family);
font-size: var(--font-size-base);
line-height: var(--line-height-normal);
color: var(--color-text-primary);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: var(--color-accent);
text-decoration: none;
}
/* ─── Page shell ─── */
.page-shell {
max-width: var(--page-max-width);
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.page-shell__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-5);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-xs);
}
.page-shell__heading {
display: flex;
align-items: flex-start;
gap: var(--spacing-3);
min-width: 0;
}
.page-shell__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: var(--radius-md);
background: var(--color-accent-soft);
color: var(--color-accent);
font-size: 18px;
}
.page-shell__title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: 600;
line-height: var(--line-height-tight);
color: var(--color-text-primary);
}
.page-shell__subtitle {
margin: var(--spacing-1) 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.page-shell__actions {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex-shrink: 0;
}
.page-shell__content {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
/* ─── Content sections ─── */
.content-section {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-xs);
overflow: hidden;
}
.content-section--flush .content-section__body {
padding: 0;
}
.content-section__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-4);
background: var(--table-header-bg);
border-bottom: 1px solid var(--color-border);
}
.content-section__heading {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
}
.content-section__icon {
color: var(--color-accent);
font-size: 14px;
}
.content-section__title {
margin: 0;
font-size: var(--font-size-md);
font-weight: 600;
color: var(--color-text-primary);
}
.content-section__actions {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.content-section__body {
padding: var(--spacing-4);
}
/* ─── Data table toolbar ─── */
.data-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-3);
flex-wrap: wrap;
padding: var(--spacing-3) var(--spacing-4);
background: var(--table-header-bg);
border-bottom: 1px solid var(--color-border);
}
.data-toolbar__left,
.data-toolbar__right {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex-wrap: wrap;
}
.data-toolbar__search {
min-width: 220px;
}
/* ─── Form layout ─── */
.field-label {
display: block;
margin-bottom: var(--spacing-1);
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text-secondary);
}
.field-group {
margin-bottom: var(--spacing-4);
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--spacing-4);
}
.form-grid--2 {
grid-template-columns: repeat(2, 1fr);
}
.form-grid--3 {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 1024px) {
.form-grid--2,
.form-grid--3 {
grid-template-columns: 1fr;
}
}
/* ─── Alerts ─── */
.alert-info {
padding: var(--spacing-3) var(--spacing-4);
background: var(--color-info-soft);
border: 1px solid rgba(3, 105, 161, 0.25);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
font-size: var(--font-size-sm);
}
.alert-danger {
padding: var(--spacing-3) var(--spacing-4);
background: var(--color-error-soft);
border: 1px solid rgba(185, 28, 28, 0.25);
border-radius: var(--radius-sm);
color: var(--color-error);
font-size: var(--font-size-sm);
}
.alert-success {
padding: var(--spacing-3) var(--spacing-4);
background: var(--color-success-soft);
border: 1px solid rgba(4, 120, 87, 0.25);
border-radius: var(--radius-sm);
color: var(--color-success);
font-size: var(--font-size-sm);
}
/* ─── Detail metadata grid ─── */
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-3) var(--spacing-6);
font-size: var(--font-size-sm);
}
.detail-grid__item {
display: flex;
flex-direction: column;
gap: 2px;
}
.detail-grid__label {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 500;
}
.detail-grid__value {
color: var(--color-text-primary);
font-weight: 500;
}
@media (max-width: 768px) {
.detail-grid {
grid-template-columns: 1fr;
}
}
/* ─── Row highlight (business rule) ─── */
.qty-type-highlight {
background-color: var(--color-error-soft) !important;
}
.qty-type-highlight > td {
color: var(--color-error) !important;
}
/* ─── Auth pages ─── */
.auth-layout {
min-height: 100dvh;
display: grid;
grid-template-columns: 1fr 480px;
}
.auth-layout__brand {
display: flex;
flex-direction: column;
justify-content: center;
padding: var(--spacing-7);
background: linear-gradient(145deg, #1e293b 0%, #0f172a 55%, #1a3a6b 100%);
color: var(--color-text-inverse);
}
.auth-layout__brand-logo {
display: flex;
align-items: center;
gap: var(--spacing-3);
margin-bottom: var(--spacing-6);
}
.auth-layout__brand-logo img {
filter: brightness(0) invert(1);
}
.auth-layout__brand-title {
margin: 0;
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
}
.auth-layout__brand-tagline {
margin: 0 0 var(--spacing-6);
font-size: var(--font-size-lg);
color: rgba(255, 255, 255, 0.75);
max-width: 420px;
line-height: 1.6;
}
.auth-layout__features {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.auth-layout__feature {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--font-size-sm);
color: rgba(255, 255, 255, 0.85);
}
.auth-layout__feature i {
color: var(--color-sidebar-accent);
}
.auth-layout__form {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-6);
background: var(--color-bg);
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
padding: var(--spacing-6);
}
.auth-card__title {
margin: 0 0 var(--spacing-1);
font-size: var(--font-size-xl);
font-weight: 600;
}
.auth-card__subtitle {
margin: 0 0 var(--spacing-5);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
@media (max-width: 900px) {
.auth-layout {
grid-template-columns: 1fr;
}
.auth-layout__brand {
display: none;
}
}
/* ─── PrimeNG global overrides ─── */
.p-datatable {
font-size: var(--font-size-sm);
}
.p-datatable .p-datatable-thead > tr > th {
padding: 10px 12px;
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
background: var(--table-header-bg) !important;
border-color: var(--table-border) !important;
white-space: nowrap;
}
.p-datatable .p-datatable-tbody > tr > td {
padding: 8px 12px;
border-color: var(--table-border) !important;
vertical-align: middle;
}
.p-datatable .p-datatable-tbody > tr {
background: var(--color-surface);
}
.p-datatable .p-datatable-tbody > tr:nth-child(even) {
background: var(--table-row-stripe);
}
.p-datatable .p-datatable-tbody > tr:hover {
background: var(--table-row-hover) !important;
}
.p-datatable .p-datatable-tbody > tr.erp-table__row,
.p-datatable .p-datatable-tbody > tr:has(+ tr .erp-table__nested),
.p-datatable .p-datatable-tbody > tr:not(.p-datatable-emptymessage-row) {
cursor: default;
}
.p-datatable .p-datatable-tbody > tr:has(td[colspan]) {
cursor: default;
}
.p-datatable .p-datatable-tbody > tr:not(.p-row-expanded):not(.p-datatable-emptymessage-row) {
cursor: pointer;
}
.p-datatable.p-datatable-sm .p-datatable-thead > tr > th,
.p-datatable.p-datatable-sm .p-datatable-tbody > tr > td {
padding: 6px 10px;
}
.p-datatable .p-datatable-header {
background: var(--table-header-bg);
border-color: var(--table-border);
padding: var(--spacing-3) var(--spacing-4);
}
.p-datatable .p-paginator {
background: var(--table-header-bg);
border-color: var(--table-border);
padding: var(--spacing-2) var(--spacing-3);
font-size: var(--font-size-sm);
}
.p-panel {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-xs);
overflow: hidden;
}
.p-panel .p-panel-header {
background: var(--table-header-bg);
border-color: var(--color-border);
padding: var(--spacing-3) var(--spacing-4);
font-size: var(--font-size-md);
font-weight: 600;
}
.p-panel .p-panel-content {
padding: var(--spacing-4);
}
.p-dialog .p-dialog-header {
padding: var(--spacing-3) var(--spacing-4);
font-size: var(--font-size-md);
font-weight: 600;
}
.p-dialog .p-dialog-content {
padding: var(--spacing-4);
font-size: var(--font-size-sm);
}
.p-inputtext,
.p-select,
.p-password input {
font-size: var(--font-size-sm);
}
.p-button {
font-size: var(--font-size-sm);
font-weight: 500;
}
.column-header {
display: flex;
align-items: center;
gap: var(--spacing-1);
white-space: nowrap;
}
.column-header span {
flex: 1;
min-width: 0;
}
.p-breadcrumb {
font-size: var(--font-size-sm);
}
.w-full {
width: 100%;
}
.p-button.w-full,
.p-button-fluid {
width: 100%;
}
.mt-3 {
margin-top: var(--spacing-4);
}
.mb-3 {
margin-bottom: var(--spacing-3);
}
.divider {
margin: var(--spacing-5) 0;
border: none;
border-top: 1px solid var(--color-border);
}
.grid-toolbar {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex-wrap: wrap;
margin-bottom: var(--spacing-3);
}
.flex {
display: flex;
}
.justify-end {
justify-content: flex-end;
}
.justify-between {
justify-content: space-between;
}
.items-center {
align-items: center;
}
.gap-2 {
gap: var(--spacing-2);
}
.gap-3 {
gap: var(--spacing-3);
}
.text-muted {
color: var(--color-text-muted);
}
.text-secondary {
color: var(--color-text-secondary);
}
.font-medium {
font-weight: 500;
}
.font-semibold {
font-weight: 600;
}

View File

@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}

View File

@@ -0,0 +1,31 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}

View File

@@ -918,7 +918,6 @@ namespace SytelineSaAppEfDataModel
.HasMaxLength(500)
.IsRequired(false);
// Relationship
entity.HasOne(e => e.Client)
.WithMany()
.HasForeignKey(e => e.FK_Client)
@@ -1026,7 +1025,6 @@ namespace SytelineSaAppEfDataModel
.HasMaxLength(100)
.IsRequired(false);
// Relationship
entity.HasOne(e => e.Header)
.WithMany()
.HasForeignKey(e => e.FK_Header)