FaKrosnoAngular

* Added project
This commit is contained in:
2026-08-08 13:39:17 +02:00
parent 0a7399f015
commit 89d74428b2
73 changed files with 15267 additions and 0 deletions

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"
]
}