Compare commits

...

186 Commits

Author SHA1 Message Date
admin 690ecc2053 Document purchasing translation deploy 2026-06-11 12:36:29 +02:00
admin 1dbaa66206 Add purchasing translations 2026-06-11 12:22:52 +02:00
admin 41103bbc5a Document finance training deploy 2026-06-11 12:13:25 +02:00
admin f751295449 Update finance training and dashboard UI 2026-06-11 11:32:35 +02:00
admin 2444a01fc7 Clarify processed merge audit CSV filenames 2026-06-11 11:08:54 +02:00
admin 957fdb7dc8 Upload audit CSV with site exports 2026-06-11 10:38:15 +02:00
admin ca6196234e Translate settings and purchasing UI text 2026-06-11 10:08:37 +02:00
admin 2e7aeda684 Clarify audit CSV settings section 2026-06-11 09:28:10 +02:00
admin 0cecb1eddf Keep finance references in expert analysis 2026-06-11 09:04:25 +02:00
admin dcd845d337 Add audit CSV central source option 2026-06-11 08:57:18 +02:00
admin f23fa1662e Add product division map fallback 2026-06-10 16:17:02 +02:00
admin d1d50e9c0c Document workflow deltas and clean up docs 2026-06-10 09:15:46 +02:00
admin 586adc33fe Fix India SAGE HANA mapping 2026-06-10 08:25:40 +02:00
admin 4ca8c31e6c Add Alphaplan discovery exporter and finance docs 2026-06-08 16:06:44 +02:00
admin d7e96edf14 Document purchasing database restore 2026-06-08 06:53:20 +02:00
admin d882755485 Prevent publishing runtime database 2026-06-05 14:33:39 +02:00
admin 9b43d483fc Show purchasing commitments with supplier context 2026-06-05 14:28:01 +02:00
admin a41ef0a564 Align purchasing analytics with Power BI 2026-06-05 14:04:27 +02:00
admin aa6d0d0804 Add purchasing period analytics 2026-06-05 13:54:36 +02:00
admin 43250a4abc Add purchasing full load cache 2026-06-05 12:43:12 +02:00
admin b1bff57370 Expand purchasing ideas and fix navigation refresh 2026-06-05 11:29:59 +02:00
admin fe729e026d Split purchasing dashboard into navigation pages 2026-06-05 11:13:45 +02:00
admin 2fa410ec31 Load purchasing EKPO and EKET metrics 2026-06-05 11:05:04 +02:00
admin e7e408fc20 Add purchasing ideas tab and SAP table test 2026-06-05 10:45:48 +02:00
admin 989ff66102 Polish purchasing cockpit visuals 2026-06-05 10:17:25 +02:00
admin bf20b3a240 Build out purchasing dashboard sections 2026-06-05 09:11:55 +02:00
admin 146c7481e1 Load purchasing dashboard live EKKO data 2026-06-05 08:58:20 +02:00
admin baeee3e49b Stabilize export dashboard and fill purchasing KPIs 2026-06-05 08:15:50 +02:00
admin 0ce96d9eb4 Handle null navigation menu values 2026-06-05 07:57:49 +02:00
admin bb5e5150b9 Add purchasing data sources and 3D simulation 2026-06-05 07:45:30 +02:00
admin 9b287c15ef Expand purchasing dashboard from PBIX 2026-06-05 07:22:05 +02:00
admin 6f094fcac6 Add configurable menu structure and purchasing area 2026-06-05 07:03:08 +02:00
admin bed1f5f0ba Document Spain delta import deployment 2026-06-05 06:50:42 +02:00
admin 825e8063a0 Support Spain sales delta folder sync 2026-06-05 06:40:31 +02:00
admin 195b430836 Document finance and Spain rclone updates 2026-06-05 06:26:51 +02:00
admin 3fd19a8bb5 Detect nested Spain rclone executable 2026-06-04 15:40:51 +02:00
admin af097cafad Fix Spain all-in-one rclone upload 2026-06-04 15:38:47 +02:00
admin 8e0b696150 Default Spain export range to last seven days 2026-06-04 15:27:21 +02:00
admin e55a86ccca Add Spain all-in-one export upload script 2026-06-04 15:24:31 +02:00
admin cad2140da6 Add finance 3D label size control 2026-06-04 14:32:51 +02:00
admin 9c63c36128 Fix finance 3D canvas sizing 2026-06-04 14:27:21 +02:00
admin e33a2fd9e6 Expand finance 3D indicators 2026-06-04 14:22:22 +02:00
admin 1049216049 Label finance 3D axes 2026-06-04 14:16:55 +02:00
admin fde7f6bc95 Add finance 3D chart modes 2026-06-04 14:11:11 +02:00
admin 9409174a07 Fix finance 3D scenario scaling 2026-06-04 13:48:27 +02:00
admin 13a7331f3d Improve finance 3D controls and simulation 2026-06-04 13:42:38 +02:00
admin a8dc565478 Add finance 3D data analysis 2026-06-04 13:36:03 +02:00
admin b44e8babf4 Expose quick finance overview in navigation 2026-06-04 12:37:30 +02:00
admin 40805e0222 Simplify finance dashboard overview 2026-06-04 10:42:11 +02:00
admin 37a175551b Document finance and mapping updates 2026-06-02 18:06:30 +02:00
admin 6470cb8751 Update finance session follow-ups 2026-06-01 15:35:23 +02:00
admin 715977beda Document finance dashboard status 2026-06-01 06:38:18 +02:00
admin d1c9d21227 Document management division analysis updates 2026-05-29 14:08:50 +02:00
admin 36ca822fbc Add browser favicon 2026-05-29 13:47:21 +02:00
admin 674c103478 Expose management analysis tabs in navigation 2026-05-29 13:42:00 +02:00
admin 61de1bebe9 Document division analysis in finance training 2026-05-29 13:39:48 +02:00
admin 18208cbcc5 Add product division category icons 2026-05-29 13:23:06 +02:00
admin 3c827472e1 Add division finance grouping controls 2026-05-29 13:18:46 +02:00
admin 0a7aafbd51 Add management analysis navigation group 2026-05-29 13:13:08 +02:00
admin dc2bc7dd83 Group division analysis tabs 2026-05-29 13:03:48 +02:00
admin 26854b999c Document product division finance deployment 2026-05-29 11:49:07 +02:00
admin aeb20fc565 Add product division finance analysis 2026-05-29 10:40:46 +02:00
admin 6593bf41be Keep SAP product division source active 2026-05-29 10:26:45 +02:00
admin 41d663254e Document product mapping deployment 2026-05-29 09:29:53 +02:00
admin 8cb5f98562 Add central product assignment tab 2026-05-29 08:54:30 +02:00
admin 7e9a61f877 Add product division gateway mapping 2026-05-29 08:49:37 +02:00
admin d3d75ccea7 Clarify ABAP product provider class pool 2026-05-29 07:12:29 +02:00
admin c638529dd0 Document ABAP product division mapping reports 2026-05-28 14:43:13 +02:00
admin da0f39235c Add finance management analysis tabs 2026-05-28 12:51:18 +02:00
admin d0762ec18b Document Upgreat firewall requirements 2026-05-27 11:30:49 +02:00
admin a3ab38a7c0 Document product division mapping task 2026-05-27 10:25:32 +02:00
admin 9a74e248e9 Organize markdown docs for RAG routing 2026-05-27 09:40:12 +02:00
admin 48fd2f65a0 Document rebase and refactoring changes 2026-05-26 14:18:39 +02:00
admin d853f53df8 Add published HR KPI workflow fixes 2026-05-26 13:33:09 +02:00
admin 5f3c3497b8 Separate admin access from finance lock 2026-05-26 13:33:09 +02:00
admin 9471c5c310 Add admin access and landing dashboard 2026-05-26 13:33:09 +02:00
admin 6b3dc2de60 Document local dev server fallback 2026-05-26 13:33:09 +02:00
admin c8728595a4 Add in-app training documentation 2026-05-26 13:33:09 +02:00
admin 16449f1dc1 Add finance details export and translations 2026-05-26 13:33:09 +02:00
admin b2ede7f8fd Add importable trading engine package 2026-05-24 19:22:05 +02:00
admin 20f6fd9b51 Add trading cockpit engine 2026-05-24 10:28:10 +02:00
admin f9971253a8 Add trading cockpit frontend logic 2026-05-24 09:40:25 +02:00
admin 307887366d Add trading cockpit documentation 2026-05-24 09:37:32 +02:00
admin ed68f2f885 Add trading cockpit readme 2026-05-24 09:36:35 +02:00
admin a8f6c38a6c Add trading cockpit styles 2026-05-24 09:35:56 +02:00
admin e24a1ddfa0 Add trading cockpit Flask app 2026-05-24 09:34:33 +02:00
admin bc3c244052 Add trading cockpit web template 2026-05-24 09:33:44 +02:00
admin 3a80c35a59 Add trading cockpit tests 2026-05-24 09:33:20 +02:00
admin e8166e8016 Add trading cockpit storage 2026-05-24 09:33:07 +02:00
admin e9ccc49d11 Add trading cockpit exchange guard 2026-05-24 09:32:47 +02:00
admin e20d87a64a Add trading cockpit deploy script 2026-05-24 09:32:36 +02:00
admin aab932727b Add trading cockpit config 2026-05-24 09:32:29 +02:00
admin 5b1ffa94dd Add trading cockpit docker compose 2026-05-24 09:32:18 +02:00
admin 0059c1e418 Add trading cockpit requirements 2026-05-24 09:32:13 +02:00
admin 0653322337 Add trading cockpit Dockerfile 2026-05-24 09:32:08 +02:00
admin 217d0a8ca3 Add trading cockpit gitignore 2026-05-24 09:31:55 +02:00
admin ab74f851c8 Add trading cockpit web app 2026-05-24 09:31:20 +02:00
admin 83acd5a148 Update markdown documentation status 2026-05-20 15:50:59 +02:00
admin a044040ada Improve cockpit user guide documents 2026-05-20 15:46:29 +02:00
admin 0bff161465 Document HR cockpit feature list 2026-05-20 15:32:56 +02:00
admin 1350e59e6a Document cockpit updates 2026-05-20 15:28:47 +02:00
admin 06fb56075f Expand HR KPI cockpit and add user guides 2026-05-20 15:27:03 +02:00
admin 610e771b9b Add finance summary view and HR guide 2026-05-20 15:17:10 +02:00
admin de0b12ba37 Document final Excel verification 2026-05-20 14:24:04 +02:00
admin d66074b740 Add configurable finance rules and dashboard basis indicators 2026-05-20 13:10:33 +02:00
admin 5e305ae396 Add DE finance rules and IIS path base 2026-05-20 12:21:06 +02:00
admin 6246c886ca Make SQLite WAL publish files optional 2026-05-20 10:44:02 +02:00
admin 0d8500f4d7 Add manual import guide tab 2026-05-20 10:09:21 +02:00
admin a1fdea56ba Improve keyuser export workflow 2026-05-20 09:52:55 +02:00
admin b5e0545fbf Add keyuser process SVG 2026-05-20 09:43:29 +02:00
admin 5fb05c500b Add technical architecture SVG 2026-05-20 09:36:43 +02:00
admin 930f124aae Add provisional Germany Alphaplan import 2026-05-20 09:26:28 +02:00
admin 1b898a1efe Document IIS deployment diagnostics 2026-05-20 08:35:31 +02:00
admin 1dc336dc47 Enable IIS detailed startup diagnostics 2026-05-20 08:29:02 +02:00
admin e3b9d8d0c0 Switch IIS hosting to out-of-process 2026-05-20 08:25:10 +02:00
admin 6f8528ac54 Apply confirmed Italy finance method 2026-05-20 08:10:02 +02:00
admin b2aa7b046f Document IIS deployment handoff 2026-05-19 15:37:25 +02:00
admin 5087a7c271 Enable IIS publish diagnostics 2026-05-19 15:31:27 +02:00
admin 15335703fe Exclude local build artifacts from web publish 2026-05-19 15:28:01 +02:00
admin e9b616ff26 Align Trafag publish output with BiDashboard 2026-05-19 15:22:58 +02:00
admin f128d3528a Publish web app without apphost 2026-05-19 15:18:48 +02:00
admin 8d10372614 Configure Trafag web publish profile 2026-05-19 14:31:15 +02:00
admin 383796df87 Document finance workflow and security toggle 2026-05-19 14:26:04 +02:00
admin b23f73ecd6 Add finance filter help sheet 2026-05-19 14:00:52 +02:00
admin ebbc5a13a8 Add finance filter columns to consolidated export 2026-05-19 13:51:25 +02:00
admin 9c544afa20 Protect finance cockpit with login 2026-05-19 09:40:15 +02:00
admin 5c654ad848 Document finance formulas by country 2026-05-19 08:07:29 +02:00
admin f855e060d1 Filter empty actual finance rows 2026-05-19 08:01:04 +02:00
admin 8f1b1b88de Align main finance comparison with probe 2026-05-18 21:39:10 +02:00
admin bc6bfdfa27 Document finance reconciliation handoff 2026-05-18 21:30:05 +02:00
admin f721d95b32 Add updated finance Excel and Spain cache 2026-05-18 21:28:21 +02:00
admin 3d40d76d8e Use GBP local reference for UK finance 2026-05-18 21:07:23 +02:00
admin fb85e2e57a Correct Sage finance calculations 2026-05-18 20:57:22 +02:00
admin cf0d3e21f1 Document architecture hardcoding review 2026-05-15 12:02:24 +02:00
admin 9daf54b8d9 Document AI role and HR exclusions 2026-05-15 11:55:40 +02:00
admin 83e556e89e Refine cockpit navigation and HR access 2026-05-15 11:14:46 +02:00
admin e20693243d Update HR KPI and finance dashboard docs 2026-05-15 10:25:01 +02:00
admin 001e2a73d5 Commit pending finance and Power BI work 2026-05-13 07:33:00 +02:00
admin 1cd0ad998f Refactor HR KPI cockpit architecture 2026-05-13 07:30:43 +02:00
admin 20be752adc Add HR KPI cockpit 2026-05-13 07:10:13 +02:00
admin 819a023163 Add SharePoint manual source handling and finance status 2026-05-11 08:43:52 +02:00
admin 57cb09bc50 Document program processes and source systems 2026-05-08 09:00:19 +02:00
admin bbd1f62062 Fix site deletion with dependent logs 2026-05-07 15:48:12 +02:00
admin dc3fd77c86 Consolidate mapping and finance configuration 2026-05-07 15:20:54 +02:00
admin dea171862c Ensure ZSCHWEIZ OData mapping seed 2026-05-07 14:55:30 +02:00
admin 34be4a5b49 Clarify SAP OData source mapping 2026-05-07 14:39:26 +02:00
admin 306bfca5d2 Ignore nested Trafag workspace 2026-05-07 14:18:43 +02:00
admin ce935d9eb5 Add Power BI reference files 2026-05-07 14:10:22 +02:00
admin 8477894758 Add Sage Spain export artifacts 2026-05-07 14:09:32 +02:00
admin 6717843f18 Add finance probe Spain reconciliation updates 2026-05-07 14:08:54 +02:00
admin 7442d45d9c Add configurable HANA mapping for ZSCHWEIZ 2026-05-07 14:04:17 +02:00
admin c862a559f6 Add manual Excel column mapping 2026-05-04 16:08:56 +02:00
admin 749a3209d9 Document finance reconciliation questions 2026-05-04 15:00:57 +02:00
admin 15dec06f31 Add finance reconciliation probe 2026-05-04 09:32:50 +02:00
admin 4a1561d85f Add AD auth and B1 currency fields 2026-04-29 11:07:35 +02:00
admin 3ac03a4782 Enhance management cockpit analysis 2026-04-29 07:00:29 +02:00
admin 49c03b9673 Update handoff docs for adapter and HANA refactor 2026-04-17 14:48:53 +02:00
admin ad2c6dbd53 Refactor HANA access to async and parameterized queries 2026-04-17 14:43:15 +02:00
admin 70a54c98d7 Merge pull request #61 from metacube2/claude/review-trafag-tool-JONMq
Refactor SiteExportService to use adapter pattern for data sources
2026-04-17 14:17:09 +02:00
Claude 82ac7df0ec DataSourceAdapter-Pattern + SiteExportService schlanker + Page-Services Scoped
- IDataSourceAdapter mit 3 Implementierungen (HANA, SAP_GATEWAY, MANUAL_EXCEL)
  und DataSourceAdapterResolver ersetzen das if/else auf ConnectionKind.
- SiteExportService von 338 auf 187 Zeilen reduziert: Pipeline
  Resolve -> Fetch -> Transform -> Excel -> Central -> SharePoint.
- Page-Services auf Scoped (per Blazor-Circuit); Orchestrator bleibt Singleton
  fuer geteilten Export-Status.
2026-04-17 12:11:35 +00:00
admin 2a56ba53ba umfangreiches refactoring 2026-04-17 13:56:41 +02:00
admin eb187cdc15 manometer 2026-04-17 12:00:03 +02:00
admin bec0410ef4 refactoring 2026-04-17 10:29:41 +02:00
admin 83a400a90e tests wähurngsverwaltung 2026-04-17 08:09:31 +02:00
admin 0d3bd47f7a englisch 2026-04-17 07:08:04 +02:00
admin ca91af9682 unittests 2026-04-16 09:45:10 +02:00
admin a25e5900c7 Regelsteuerung grafisch und per C# Templates 2026-04-16 08:47:13 +02:00
admin d02f4abb57 cockpit vorbereitung 2026-04-15 16:22:48 +02:00
admin 264e64bbf5 zentraler export 2026-04-15 14:47:32 +02:00
admin 7891dfb3dd sammelxport 2026-04-15 11:37:47 +02:00
admin 90133cd0e2 diverse Aenderungen 2026-04-15 11:18:26 +02:00
admin 59e195af71 import exxport settings, join over sap hana tables 2026-04-14 11:34:43 +02:00
admin 36a22202bf SAP GWQ 2026-04-14 10:54:52 +02:00
admin df90a4a172 Zentrales PW 2026-04-14 10:05:59 +02:00
admin cf20bd94d0 RefactoringDI 2026-04-13 14:37:21 +02:00
admin 9a93920b71 zentrales excel der standarte 2026-04-13 14:25:03 +02:00
admin 474d2215a2 New Design 2026-04-13 14:14:06 +02:00
admin e1259b9ca8 wqer 2026-04-13 14:06:51 +02:00
admin 2b9b40af93 Merge pull request #59 from metacube2/codex/fix-git-permission-denied-error-92qoc3
Add transformation rules UI and engine; add connection testing/status and Site SourceSystem
2026-04-13 12:20:43 +02:00
admin eb427ac608 Merge branch 'main' into codex/fix-git-permission-denied-error-92qoc3 2026-04-13 12:20:33 +02:00
admin 97e598fe3b Fix MudBlazor generic/value callback compile errors 2026-04-13 12:19:42 +02:00
admin 9406843988 Merge pull request #58 from metacube2/codex/fix-git-permission-denied-error
Add field transformation rules, UI, DB schema and integrate into export; improve HANA connection testing
2026-04-13 11:52:17 +02:00
admin c4a93a7f15 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	TrafagSalesExporter/TrafagSalesExporter.csproj
2026-04-13 11:31:18 +02:00
admin 0d11315848 Merge pull request #57 from metacube2/codex/fix-git-permission-denied-error
Ignore Visual Studio workspace files in TrafagSalesExporter
2026-04-13 11:24:18 +02:00
admin c336c1c7f8 Ignore Visual Studio workspace files in TrafagSalesExporter 2026-04-13 11:24:04 +02:00
admin 3b6f66d0fb asdf 2026-04-13 11:22:40 +02:00
1043 changed files with 301704 additions and 817 deletions
+11
View File
@@ -0,0 +1,11 @@
# Ignore Visual Studio + build artifacts
.vs/
TrafagSalesExporter/.vs/
TrafagSalesExporter/bin/
TrafagSalesExporter/obj/
TrafagSalesExporter/*.user
TrafagSalesExporter/*.suo
TrafagSalesExporter/*.db
TrafagSalesExporter/*.db-shm
TrafagSalesExporter/*.db-wal
Trafag/
+15
View File
@@ -1,8 +1,23 @@
# Build artifacts
bin/
obj/
verify_probe_out*/
build_verify/
output/
# Visual Studio user/IDE files
.vs/
*.user
*.suo
# Local diagnostics and scratch artifacts
.config/
.tmp_tools/
Tools/FinanceProbe/.tmp_tools/
Tools/FinanceProbe/verify_probe_out*/
*.out.log
*.err.log
mainapp*.log
financeprobe*.log
netsh
11.15.0
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
$ErrorActionPreference = 'Stop'
$exe = Join-Path $PSScriptRoot 'bin\x86\Release\net48\SapProbe.exe'
$log = Join-Path $PSScriptRoot 'sap_probe_last_run.log'
if (-not (Test-Path -LiteralPath $exe)) {
Write-Host "SapProbe.exe was not found:"
Write-Host $exe
Read-Host "Press Enter to close"
exit 2
}
if (Test-Path -LiteralPath $log) {
Remove-Item -LiteralPath $log -Force
}
Start-Transcript -Path $log -Force | Out-Null
try {
& $exe @args
$exitCode = $LASTEXITCODE
Write-Host ''
Write-Host "Exit code: $exitCode"
}
finally {
Stop-Transcript | Out-Null
}
if (Test-Path -LiteralPath $log) {
$content = Get-Content -LiteralPath $log -Raw
$content = [regex]::Replace($content, '(?m)^Password for .*$','Password prompt: [masked input omitted]')
Set-Content -LiteralPath $log -Value $content -Encoding UTF8
}
Write-Host ''
Write-Host "Log file: $log"
Read-Host "Press Enter to close"
exit $exitCode
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<PlatformTarget>x86</PlatformTarget>
<Prefer32Bit>true</Prefer32Bit>
<LangVersion>latest</LangVersion>
<Nullable>disable</Nullable>
<AssemblyName>SapProbe</AssemblyName>
<RootNamespace>SapProbe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Reference Include="sapnco">
<HintPath>C:\Windows\Microsoft.NET\assembly\GAC_32\sapnco\v4.0_3.1.0.42__50436dca5c7f7d23\sapnco.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="sapnco_utils">
<HintPath>C:\Windows\Microsoft.NET\assembly\GAC_32\sapnco_utils\v4.0_3.1.0.42__50436dca5c7f7d23\sapnco_utils.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
@@ -0,0 +1,36 @@
**********************
nStart der Windows PowerShell-Aufzeichnung
Startzeit: 20260427082528
Benutzername: TRAFAGCH\koi
RunAs-Benutzer: TRAFAGCH\koi
Konfigurationsname:
Computer: NB61258 (Microsoft Windows NT 10.0.26200.0)
Hostanwendung: C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Users\koi\source\repos\Ai\TrafagSalesExporter\.tmp_sap_probe\RunSapProbeInteractive.ps1 abap-activate Z_TEST3 --dry-run
Prozess-ID: 452
PSVersion: 5.1.26100.8115
PSEdition: Desktop
PSCompatibleVersions: 1.0, 2.0, 3.0, 4.0, 5.0, 5.1.26100.8115
BuildVersion: 10.0.26100.8115
CLRVersion: 4.0.30319.42000
WSManStackVersion: 3.0
PSRemotingProtocolVersion: 2.3
SerializationVersion: 1.1.0.1
**********************
SAP NCo CLI
Architecture : x86
NCo Assembly : sapnco, Version=3.1.0.42, Culture=neutral, PublicKeyToken=50436dca5c7f7d23
Password prompt: [masked input omitted]
Target : travt762.sap.trafag.com / SYSNR 00 / CLIENT 100 / USER KOI
Ping : OK
Program : Z_TEST3
Lines : 69
Activation : RPY_PROGRAM_INSERT with SAVE_INACTIVE blank
Dry run : no SAP repository changes were written.
Exit code: 0
**********************
Ende der Windows PowerShell-Aufzeichnung
Endzeit: 20260427082529
**********************
Binary file not shown.
@@ -0,0 +1,136 @@
Alphaplan SQL Discovery Exporter
================================
Purpose
-------
Run this package on the German Alphaplan SQL Server machine.
It performs Phase 1 discovery only:
- scan accessible SQL Server databases
- identify tables/views that look relevant for finance, invoices, sales, customers, articles and amounts
- write candidate_objects.csv
- write export_summary.csv
- optionally write small sample_*.csv files
- optionally upload the run folder to SharePoint with rclone
The script only reads SQL Server metadata/data. It does not change Alphaplan, SQL Server or BiDashboard.
Default SharePoint target
-------------------------
The default rclone target is:
trafag-bi:Import/Finance/Deutschland/AlphaplanRaw
Use this raw folder first so the existing Germany import is not disturbed.
Typical commands
----------------
Open PowerShell on the Alphaplan server in this package folder.
Allow script execution for this PowerShell window:
Set-ExecutionPolicy -Scope Process Bypass
Run discovery and upload with defaults:
.\Run-AlphaplanDiscoveryAndUpload.ps1
Run discovery for one known database:
.\Run-AlphaplanDiscoveryAndUpload.ps1 -Database "ALPHAPLAN"
Run discovery without SharePoint upload:
.\Run-AlphaplanDiscoveryAndUpload.ps1 -SkipUpload
Run discovery and include small samples from top candidate tables/views:
.\Run-AlphaplanDiscoveryAndUpload.ps1 -Database "ALPHAPLAN" -ExportSamples
Use another SQL Server instance:
.\Run-AlphaplanDiscoveryAndUpload.ps1 -ServerInstance "SERVERNAME\INSTANCE" -Database "ALPHAPLAN"
Use SQL authentication:
$cred = Get-Credential
.\Run-AlphaplanDiscoveryAndUpload.ps1 -ServerInstance "SERVERNAME\INSTANCE" -Database "ALPHAPLAN" -SqlCredential $cred
Use another rclone remote name:
.\Run-AlphaplanDiscoveryAndUpload.ps1 -RcloneRemote "YOUR_REMOTE"
Use another rclone executable:
.\Run-AlphaplanDiscoveryAndUpload.ps1 -RcloneExe "C:\Tools\rclone\rclone.exe"
Output
------
Default local folder:
C:\Trafag\AlphaplanExport\out\Alphaplan_SQL_Discovery_YYYYMMDD_HHMMSS
Main files:
- candidate_objects.csv
- export_summary.csv
- README.txt
- sample_*.csv when -ExportSamples is used
rclone prerequisites
--------------------
rclone must already be configured on the Alphaplan server.
Expected remote:
trafag-bi
The remote should point to the "Shared Documents" document library of:
https://trafagag.sharepoint.com/sites/WorldwideBIPlatform
Quick rclone checks:
rclone lsd trafag-bi:
rclone lsd trafag-bi:"Import/Finance"
rclone lsd trafag-bi:"Import/Finance/Deutschland"
Recommended Phase 1 workflow
----------------------------
1. Run discovery without samples:
.\Run-AlphaplanDiscoveryAndUpload.ps1 -SkipUpload
2. Check candidate_objects.csv locally.
3. If the result looks plausible, run with upload:
.\Run-AlphaplanDiscoveryAndUpload.ps1
4. If Andreas/DE IT needs examples, run samples:
.\Run-AlphaplanDiscoveryAndUpload.ps1 -ExportSamples
5. Use candidate_objects.csv to identify the correct invoice header, invoice line, customer, article and credit note/storno objects.
Notes
-----
- If -Database is empty, all accessible user databases are scanned.
- If the SQL user has limited permissions, candidate_objects.csv may be empty or incomplete.
- Use a read-only SQL user or Windows account.
- For Phase 1, no BiDashboard import mapping is required.
@@ -0,0 +1,584 @@
param(
[string]$ServerInstance = "localhost",
[string]$Database = "",
[System.Management.Automation.PSCredential]$SqlCredential,
[string]$BaseDirectory = "C:\Trafag\AlphaplanExport",
[int]$MaxCandidatesPerDatabase = 80,
[switch]$ExportSamples,
[int]$MaxSampleObjects = 15,
[int]$SampleRows = 200,
[switch]$IncludeSystemDatabases,
[switch]$SkipUpload,
[string]$RcloneExe = "C:\Tools\rclone.exe",
[string]$RcloneRemote = "trafag-bi",
[string]$RcloneTarget = "Import/Finance/Deutschland/AlphaplanRaw"
)
$ErrorActionPreference = "Stop"
function New-Connection {
param([string]$DbName)
$builder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
$builder["Data Source"] = $ServerInstance
$builder["Initial Catalog"] = $DbName
$builder["TrustServerCertificate"] = $true
$builder["Connect Timeout"] = 15
if ($null -ne $SqlCredential) {
$builder["Integrated Security"] = $false
$builder["User ID"] = $SqlCredential.UserName
$builder["Password"] = $SqlCredential.GetNetworkCredential().Password
}
else {
$builder["Integrated Security"] = $true
}
return New-Object System.Data.SqlClient.SqlConnection($builder.ConnectionString)
}
function Add-SqlParameter {
param(
[System.Data.SqlClient.SqlCommand]$Command,
[string]$Name,
$Value
)
if ($Value -is [int]) {
$parameter = $Command.Parameters.Add("@$Name", [System.Data.SqlDbType]::Int)
$parameter.Value = $Value
return
}
if ($Value -is [datetime]) {
$parameter = $Command.Parameters.Add("@$Name", [System.Data.SqlDbType]::DateTime)
$parameter.Value = $Value
return
}
$textParameter = $Command.Parameters.Add("@$Name", [System.Data.SqlDbType]::NVarChar, 4000)
if ($null -eq $Value) {
$textParameter.Value = [System.DBNull]::Value
}
else {
$textParameter.Value = [string]$Value
}
}
function Invoke-DataTable {
param(
[string]$DbName,
[string]$Sql,
[hashtable]$Parameters = @{}
)
$conn = New-Connection $DbName
$cmd = $conn.CreateCommand()
$cmd.CommandText = $Sql
$cmd.CommandTimeout = 300
foreach ($key in $Parameters.Keys) {
Add-SqlParameter -Command $cmd -Name $key -Value $Parameters[$key]
}
$table = New-Object System.Data.DataTable
try {
$conn.Open()
$reader = $cmd.ExecuteReader()
$table.Load($reader)
$reader.Dispose()
}
finally {
$cmd.Dispose()
$conn.Dispose()
}
return $table
}
function Convert-DataRowToObject {
param([System.Data.DataRow]$Row)
$props = [ordered]@{}
foreach ($column in $Row.Table.Columns) {
$value = $Row[$column.ColumnName]
if ($null -eq $value -or $value -is [System.DBNull]) {
$props[$column.ColumnName] = ""
}
else {
$props[$column.ColumnName] = $value
}
}
return [pscustomobject]$props
}
function Convert-ToCsvValue {
param($Value)
if ($null -eq $Value -or $Value -is [System.DBNull]) {
return ""
}
if ($Value -is [datetime]) {
$text = $Value.ToString("yyyy-MM-dd HH:mm:ss")
}
else {
$text = [string]$Value
}
$text = $text.Replace('"', '""')
return '"' + $text + '"'
}
function Export-QueryToCsv {
param(
[string]$DbName,
[string]$Sql,
[string]$Path
)
$conn = New-Connection $DbName
$cmd = $conn.CreateCommand()
$cmd.CommandText = $Sql
$cmd.CommandTimeout = 0
$writer = New-Object System.IO.StreamWriter($Path, $false, [System.Text.Encoding]::UTF8)
$rowCount = 0
try {
$conn.Open()
$reader = $cmd.ExecuteReader()
$headers = for ($i = 0; $i -lt $reader.FieldCount; $i++) {
Convert-ToCsvValue $reader.GetName($i)
}
$writer.WriteLine(($headers -join ";"))
while ($reader.Read()) {
$values = for ($i = 0; $i -lt $reader.FieldCount; $i++) {
Convert-ToCsvValue $reader.GetValue($i)
}
$writer.WriteLine(($values -join ";"))
$rowCount++
}
$reader.Dispose()
}
finally {
$writer.Dispose()
$cmd.Dispose()
$conn.Dispose()
}
return $rowCount
}
function Quote-NamePart {
param([string]$Name)
return "[" + $Name.Replace("]", "]]") + "]"
}
function Normalize-FileName {
param([string]$Value)
$safe = ($Value -replace '[\\/:*?"<>|]', '_')
if ($safe.Length -gt 150) {
return $safe.Substring(0, 150)
}
return $safe
}
function Get-UserDatabases {
$systemFilter = if ($IncludeSystemDatabases) { "" } else { "AND database_id > 4" }
$sql = @"
SELECT name
FROM sys.databases
WHERE state_desc = 'ONLINE'
AND HAS_DBACCESS(name) = 1
$systemFilter
ORDER BY name;
"@
$table = Invoke-DataTable "master" $sql
$result = New-Object System.Collections.Generic.List[string]
foreach ($row in $table.Rows) {
$result.Add([string]$row["name"])
}
return @($result)
}
function Get-CandidateObjects {
param([string]$DbName)
$sql = @"
WITH object_columns AS (
SELECT
o.object_id AS ObjectId,
s.name AS SchemaName,
o.name AS ObjectName,
o.type_desc AS ObjectType,
c.name AS ColumnName,
t.name AS TypeName,
c.column_id AS ColumnId
FROM sys.objects o
JOIN sys.schemas s ON s.schema_id = o.schema_id
JOIN sys.columns c ON c.object_id = o.object_id
JOIN sys.types t ON t.user_type_id = c.user_type_id
WHERE o.type IN ('U', 'V')
AND o.is_ms_shipped = 0
),
scored AS (
SELECT
ObjectId,
SchemaName,
ObjectName,
ObjectType,
COUNT(*) AS ColumnCount,
SUM(
CASE WHEN LOWER(ObjectName) LIKE '%rechnung%' OR LOWER(ObjectName) LIKE '%fakt%' OR LOWER(ObjectName) LIKE '%invoice%' OR LOWER(ObjectName) LIKE '%fact%' OR LOWER(ObjectName) LIKE '%beleg%' THEN 8 ELSE 0 END +
CASE WHEN LOWER(ObjectName) LIKE '%umsatz%' OR LOWER(ObjectName) LIKE '%verkauf%' OR LOWER(ObjectName) LIKE '%sales%' OR LOWER(ObjectName) LIKE '%revenue%' THEN 6 ELSE 0 END +
CASE WHEN LOWER(ObjectName) LIKE '%position%' OR LOWER(ObjectName) LIKE '%zeile%' OR LOWER(ObjectName) LIKE '%line%' THEN 4 ELSE 0 END +
CASE WHEN LOWER(ObjectName) LIKE '%auftrag%' OR LOWER(ObjectName) LIKE '%order%' THEN 4 ELSE 0 END +
CASE WHEN LOWER(ObjectName) LIKE '%artikel%' OR LOWER(ObjectName) LIKE '%material%' OR LOWER(ObjectName) LIKE '%item%' OR LOWER(ObjectName) LIKE '%produkt%' THEN 5 ELSE 0 END +
CASE WHEN LOWER(ObjectName) LIKE '%kunde%' OR LOWER(ObjectName) LIKE '%debitor%' OR LOWER(ObjectName) LIKE '%customer%' OR LOWER(ObjectName) LIKE '%adresse%' THEN 4 ELSE 0 END +
CASE WHEN LOWER(ObjectName) LIKE '%gutschrift%' OR LOWER(ObjectName) LIKE '%storno%' OR LOWER(ObjectName) LIKE '%credit%' THEN 4 ELSE 0 END +
CASE WHEN LOWER(ColumnName) LIKE '%datum%' OR LOWER(ColumnName) LIKE '%date%' OR LOWER(ColumnName) LIKE '%zeit%' THEN 2 ELSE 0 END +
CASE WHEN LOWER(ColumnName) LIKE '%rechnung%' OR LOWER(ColumnName) LIKE '%fakt%' OR LOWER(ColumnName) LIKE '%invoice%' OR LOWER(ColumnName) LIKE '%beleg%' THEN 2 ELSE 0 END +
CASE WHEN LOWER(ColumnName) LIKE '%kunde%' OR LOWER(ColumnName) LIKE '%debitor%' OR LOWER(ColumnName) LIKE '%customer%' OR LOWER(ColumnName) LIKE '%adresse%' THEN 2 ELSE 0 END +
CASE WHEN LOWER(ColumnName) LIKE '%artikel%' OR LOWER(ColumnName) LIKE '%material%' OR LOWER(ColumnName) LIKE '%item%' OR LOWER(ColumnName) LIKE '%produkt%' OR LOWER(ColumnName) LIKE '%artnr%' THEN 2 ELSE 0 END +
CASE WHEN LOWER(ColumnName) LIKE '%betrag%' OR LOWER(ColumnName) LIKE '%netto%' OR LOWER(ColumnName) LIKE '%umsatz%' OR LOWER(ColumnName) LIKE '%amount%' OR LOWER(ColumnName) LIKE '%price%' OR LOWER(ColumnName) LIKE '%preis%' OR LOWER(ColumnName) LIKE '%summe%' THEN 3 ELSE 0 END +
CASE WHEN LOWER(ColumnName) LIKE '%menge%' OR LOWER(ColumnName) LIKE '%anzahl%' OR LOWER(ColumnName) LIKE '%quantity%' OR LOWER(ColumnName) LIKE '%qty%' THEN 2 ELSE 0 END +
CASE WHEN LOWER(ColumnName) LIKE '%waehr%' OR LOWER(ColumnName) LIKE '%currency%' OR LOWER(ColumnName) LIKE '%whrg%' THEN 1 ELSE 0 END +
CASE WHEN LOWER(ColumnName) LIKE '%warengruppe%' OR LOWER(ColumnName) LIKE '%produktgruppe%' OR LOWER(ColumnName) LIKE '%productgroup%' THEN 1 ELSE 0 END
) AS Score
FROM object_columns
GROUP BY ObjectId, SchemaName, ObjectName, ObjectType
)
SELECT TOP (@MaxCandidates)
DB_NAME() AS DatabaseName,
s.SchemaName,
s.ObjectName,
s.ObjectType,
s.Score,
s.ColumnCount,
ISNULL((
SELECT SUM(p.row_count)
FROM sys.dm_db_partition_stats p
WHERE p.object_id = s.ObjectId
AND p.index_id IN (0, 1)
), 0) AS RowCountEstimate,
STUFF((
SELECT ', ' + oc.ColumnName
FROM object_columns oc
WHERE oc.ObjectId = s.ObjectId
AND (
oc.TypeName IN ('date', 'datetime', 'datetime2', 'smalldatetime')
OR LOWER(oc.ColumnName) LIKE '%datum%'
OR LOWER(oc.ColumnName) LIKE '%date%'
OR LOWER(oc.ColumnName) LIKE '%zeit%'
)
ORDER BY oc.ColumnId
FOR XML PATH(''), TYPE
).value('.', 'nvarchar(max)'), 1, 2, '') AS DateColumnCandidates,
STUFF((
SELECT ', ' + oc.ColumnName
FROM object_columns oc
WHERE oc.ObjectId = s.ObjectId
AND (
LOWER(oc.ColumnName) LIKE '%betrag%'
OR LOWER(oc.ColumnName) LIKE '%netto%'
OR LOWER(oc.ColumnName) LIKE '%umsatz%'
OR LOWER(oc.ColumnName) LIKE '%amount%'
OR LOWER(oc.ColumnName) LIKE '%price%'
OR LOWER(oc.ColumnName) LIKE '%preis%'
OR LOWER(oc.ColumnName) LIKE '%summe%'
)
ORDER BY oc.ColumnId
FOR XML PATH(''), TYPE
).value('.', 'nvarchar(max)'), 1, 2, '') AS AmountColumnCandidates,
STUFF((
SELECT ', ' + oc.ColumnName
FROM object_columns oc
WHERE oc.ObjectId = s.ObjectId
AND (
LOWER(oc.ColumnName) LIKE '%rechnung%'
OR LOWER(oc.ColumnName) LIKE '%fakt%'
OR LOWER(oc.ColumnName) LIKE '%beleg%'
OR LOWER(oc.ColumnName) LIKE '%kunde%'
OR LOWER(oc.ColumnName) LIKE '%debitor%'
OR LOWER(oc.ColumnName) LIKE '%artikel%'
OR LOWER(oc.ColumnName) LIKE '%material%'
OR LOWER(oc.ColumnName) LIKE '%position%'
)
ORDER BY oc.ColumnId
FOR XML PATH(''), TYPE
).value('.', 'nvarchar(max)'), 1, 2, '') AS KeyColumnCandidates,
STUFF((
SELECT ', ' + oc.ColumnName
FROM object_columns oc
WHERE oc.ObjectId = s.ObjectId
ORDER BY oc.ColumnId
FOR XML PATH(''), TYPE
).value('.', 'nvarchar(max)'), 1, 2, '') AS Columns
FROM scored s
WHERE s.Score > 0
ORDER BY s.Score DESC, RowCountEstimate DESC, s.ObjectName;
"@
$table = Invoke-DataTable $DbName $sql @{ MaxCandidates = $MaxCandidatesPerDatabase }
$result = New-Object System.Collections.Generic.List[object]
foreach ($row in $table.Rows) {
$result.Add((Convert-DataRowToObject $row))
}
return @($result)
}
function Build-SampleSql {
param(
[string]$SchemaName,
[string]$ObjectName,
[int]$Rows
)
$safeRows = [Math]::Max(1, $Rows)
$qualifiedName = "$(Quote-NamePart $SchemaName).$(Quote-NamePart $ObjectName)"
return "SELECT TOP ($safeRows) * FROM $qualifiedName;"
}
function Resolve-RcloneExecutable {
param([string]$ConfiguredPath)
$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
$candidates = @(
$ConfiguredPath,
(Join-Path $scriptDirectory "rclone.exe"),
"C:\Tools\rclone.exe",
"C:\Tools\rclone\rclone.exe",
"C:\Tools\rclone\rclone\rclone.exe",
"rclone"
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath $candidate) {
return (Resolve-Path -LiteralPath $candidate).Path
}
$command = Get-Command $candidate -ErrorAction SilentlyContinue
if ($null -ne $command) {
return $command.Source
}
}
throw "rclone executable not found. Checked: $($candidates -join ', ')"
}
function Throw-RcloneError {
param(
[string]$Message,
[string]$LogPath
)
if (Test-Path -LiteralPath $LogPath) {
Write-Host ""
Write-Host "Last rclone log lines:"
Get-Content -LiteralPath $LogPath -Tail 80 | ForEach-Object { Write-Host $_ }
}
throw "$Message Log: $LogPath"
}
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$outputDirectory = Join-Path $BaseDirectory "out"
$logDirectory = Join-Path $BaseDirectory "logs"
New-Item -ItemType Directory -Force -Path $outputDirectory, $logDirectory | Out-Null
$runDirectory = Join-Path $outputDirectory "Alphaplan_SQL_Discovery_$timestamp"
New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null
$summary = New-Object System.Collections.Generic.List[object]
$allCandidates = New-Object System.Collections.Generic.List[object]
Write-Host "Alphaplan SQL discovery"
Write-Host "Server instance: $ServerInstance"
Write-Host "Database filter: $(if ([string]::IsNullOrWhiteSpace($Database)) { '(all accessible user databases)' } else { $Database })"
Write-Host "Run directory: $runDirectory"
$databases = if ([string]::IsNullOrWhiteSpace($Database)) {
@(Get-UserDatabases)
}
else {
@($Database)
}
if ($databases.Count -eq 0) {
throw "No accessible databases found."
}
foreach ($db in $databases) {
Write-Host "Scanning database: $db"
try {
$candidates = @(Get-CandidateObjects $db)
foreach ($candidate in $candidates) {
$allCandidates.Add($candidate)
}
$summary.Add([pscustomobject]@{
Created = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
Database = $db
Object = ""
Action = "Discovery completed"
Rows = $candidates.Count
File = "candidate_objects.csv"
Error = ""
})
}
catch {
$summary.Add([pscustomobject]@{
Created = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
Database = $db
Object = ""
Action = "Discovery failed"
Rows = 0
File = ""
Error = $_.Exception.Message
})
}
}
$candidatePath = Join-Path $runDirectory "candidate_objects.csv"
if ($allCandidates.Count -gt 0) {
$allCandidates |
Sort-Object DatabaseName, @{ Expression = "Score"; Descending = $true }, ObjectName |
Export-Csv -LiteralPath $candidatePath -NoTypeInformation -Encoding UTF8 -Delimiter ";"
}
else {
"" | Set-Content -LiteralPath $candidatePath -Encoding UTF8
}
if ($ExportSamples -and $allCandidates.Count -gt 0) {
$sampleObjects = @(
$allCandidates |
Sort-Object @{ Expression = "Score"; Descending = $true }, DatabaseName, ObjectName |
Select-Object -First $MaxSampleObjects
)
foreach ($candidate in $sampleObjects) {
$db = [string]$candidate.DatabaseName
$schema = [string]$candidate.SchemaName
$objectName = [string]$candidate.ObjectName
$fileName = Normalize-FileName "sample_$db.$schema.$objectName.csv"
$samplePath = Join-Path $runDirectory $fileName
try {
Write-Host "Exporting sample: $db.$schema.$objectName"
$sql = Build-SampleSql -SchemaName $schema -ObjectName $objectName -Rows $SampleRows
$rows = Export-QueryToCsv -DbName $db -Sql $sql -Path $samplePath
$summary.Add([pscustomobject]@{
Created = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
Database = $db
Object = "$schema.$objectName"
Action = "Sample exported"
Rows = $rows
File = $fileName
Error = ""
})
}
catch {
$summary.Add([pscustomobject]@{
Created = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
Database = $db
Object = "$schema.$objectName"
Action = "Sample export failed"
Rows = 0
File = $fileName
Error = $_.Exception.Message
})
}
}
}
$summaryPath = Join-Path $runDirectory "export_summary.csv"
$summary | Export-Csv -LiteralPath $summaryPath -NoTypeInformation -Encoding UTF8 -Delimiter ";"
$readmePath = Join-Path $runDirectory "README.txt"
@"
Alphaplan SQL discovery export
==============================
Created: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")
Server instance: $ServerInstance
Database filter: $(if ([string]::IsNullOrWhiteSpace($Database)) { "(all accessible user databases)" } else { $Database })
Export samples: $ExportSamples
Sample rows: $SampleRows
Files:
- candidate_objects.csv: SQL tables/views that look relevant for finance, invoices, sales, customers, articles or amounts.
- export_summary.csv: discovery and optional sample export status.
- sample_*.csv: optional small samples from top candidate objects when -ExportSamples is used.
Important:
- The script only reads SQL Server metadata and data.
- It does not change Alphaplan or SQL Server.
- Sample exports are limited with SELECT TOP.
- This is Phase 1 only. The BiDashboard import mapping is a separate step.
Recommended next step:
Send candidate_objects.csv and export_summary.csv to the Alphaplan/DE key user or IT.
They should identify the correct invoice header, invoice line, customer, article and credit note/storno objects.
"@ | Set-Content -LiteralPath $readmePath -Encoding UTF8
if (-not $SkipUpload) {
$resolvedRclone = Resolve-RcloneExecutable -ConfiguredPath $RcloneExe
$target = "${RcloneRemote}:$RcloneTarget"
$rcloneLog = Join-Path $logDirectory ("rclone-alphaplan-discovery-" + (Get-Date -Format "yyyyMMdd") + ".log")
Write-Host "Using rclone: $resolvedRclone"
Write-Host "Checking SharePoint target: $target"
& $resolvedRclone mkdir $target --log-file $rcloneLog --log-level INFO
if ($LASTEXITCODE -ne 0) {
Throw-RcloneError -Message "Could not create/check SharePoint target '$target'. rclone exit code $LASTEXITCODE." -LogPath $rcloneLog
}
$targetListing = & $resolvedRclone lsf $target --max-depth 1 --log-file $rcloneLog --log-level INFO
if ($LASTEXITCODE -ne 0) {
Throw-RcloneError -Message "SharePoint target '$target' is not reachable. rclone exit code $LASTEXITCODE." -LogPath $rcloneLog
}
Write-Host "SharePoint target reachable. Existing items: $(@($targetListing).Count)"
Write-Host "Uploading discovery folder to SharePoint target: $target"
& $resolvedRclone copy $runDirectory $target `
--include "*.csv" `
--include "*.txt" `
--log-file $rcloneLog `
--log-level INFO
if ($LASTEXITCODE -ne 0) {
Throw-RcloneError -Message "rclone upload failed with exit code $LASTEXITCODE." -LogPath $rcloneLog
}
$uploadedCandidate = & $resolvedRclone lsf $target --files-only --include "candidate_objects.csv" --log-file $rcloneLog --log-level INFO
if ($LASTEXITCODE -ne 0 -or -not ($uploadedCandidate | Where-Object { $_ -eq "candidate_objects.csv" })) {
Throw-RcloneError -Message "Upload verification failed. candidate_objects.csv was not listed in '$target'." -LogPath $rcloneLog
}
Write-Host "Upload verified."
Write-Host "rclone log: $rcloneLog"
}
else {
Write-Host "Upload skipped because -SkipUpload was used."
}
Write-Host ""
Write-Host "Alphaplan discovery finished."
Write-Host "Local export: $runDirectory"
Write-Host "Candidates: $candidatePath"
Write-Host "Summary: $summaryPath"
Write-Host "Candidate count: $($allCandidates.Count)"
Write-Host "Upload target: $(if ($SkipUpload) { '(skipped)' } else { "${RcloneRemote}:$RcloneTarget" })"
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

@@ -0,0 +1,111 @@
@using TrafagSalesExporter.Services
@inject IAdminAccessService AdminAccess
@inject ISnackbar Snackbar
@inject IUiTextService UiText
@inject ILogger<AdminAccessPanel> Logger
@inject NavigationManager Navigation
<MudPaper Class="pa-4 mb-4" Elevation="1" Style="max-width:520px;">
<MudStack Spacing="3">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
@T("Adminbereich ist geschützt. Bitte anmelden.", "Admin area is protected. Please sign in.")
</MudAlert>
@if (!AdminAccess.IsConfigured)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
@T("Admin-Zugang ist noch nicht konfiguriert.", "Admin access is not configured yet.")
</MudAlert>
}
<form method="post" action="@AccessUrl">
<input type="hidden" name="returnUrl" value="@Navigation.Uri" />
<MudStack Spacing="3">
<MudTextField T="string" Name="username" Label="@T("Name", "Name")" Disabled="@(!AdminAccess.IsConfigured)" />
<MudTextField T="string" Name="password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
<button type="submit" class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-button-filled-size-medium mud-ripple">
@T("Admin entsperren", "Unlock admin")
</button>
</MudStack>
</form>
<MudText Typo="Typo.caption">
@T("Server-Klicks", "Server clicks"): @_unlockClickCount |
@T("Konfiguriert", "Configured"): @(AdminAccess.IsConfigured ? "JA" : "NEIN")
</MudText>
<MudDivider />
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
<MudStack Spacing="3" Class="pt-2">
<MudTextField @bind-Value="_changeUsername" Label="@T("Name", "Name")" Disabled="@(!AdminAccess.IsConfigured)" />
<MudTextField @bind-Value="_currentPassword" Label="@T("Aktuelles Passwort", "Current password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPassword" Label="@T("Neues Passwort", "New password")" InputType="InputType.Password" HelperText="@T("Mindestens 8 Zeichen.", "At least 8 characters.")" Disabled="@(!AdminAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPasswordRepeat" Label="@T("Neues Passwort wiederholen", "Repeat new password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="ChangePassword"
StartIcon="@Icons.Material.Filled.Save" Disabled="@(!AdminAccess.IsConfigured)">
@T("Passwort speichern", "Save password")
</MudButton>
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
</MudStack>
</MudPaper>
@code {
private string? _username;
private string? _password;
private string? _changeUsername;
private string? _currentPassword;
private string? _newPassword;
private string? _newPasswordRepeat;
private int _unlockClickCount;
private string AccessUrl => new Uri(new Uri(Navigation.BaseUri), "access/admin").ToString();
private void Unlock()
{
_unlockClickCount++;
Logger.LogInformation(
"Admin unlock button handler reached. ClickCount={ClickCount}, IsConfigured={IsConfigured}, UsernameLength={UsernameLength}, PasswordLength={PasswordLength}",
_unlockClickCount,
AdminAccess.IsConfigured,
_username?.Length ?? 0,
_password?.Length ?? 0);
if (!AdminAccess.TryUnlock(_username ?? string.Empty, _password ?? string.Empty))
{
Snackbar.Add(T("Admin-Anmeldung fehlgeschlagen.", "Admin sign-in failed."), Severity.Error);
return;
}
_password = string.Empty;
OnUnlocked.InvokeAsync();
}
private void ChangePassword()
{
if (string.IsNullOrWhiteSpace(_newPassword) || _newPassword.Length < 8)
{
Snackbar.Add(T("Das neue Passwort muss mindestens 8 Zeichen lang sein.", "The new password must be at least 8 characters long."), Severity.Warning);
return;
}
if (_newPassword != _newPasswordRepeat)
{
Snackbar.Add(T("Die neuen Passwörter stimmen nicht überein.", "The new passwords do not match."), Severity.Warning);
return;
}
if (!AdminAccess.TryChangePassword(_changeUsername ?? string.Empty, _currentPassword ?? string.Empty, _newPassword))
{
Snackbar.Add(T("Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen.", "Password could not be changed. Check the name or current password."), Severity.Error);
return;
}
_currentPassword = string.Empty;
_newPassword = string.Empty;
_newPasswordRepeat = string.Empty;
Snackbar.Add(T("Passwort wurde geändert.", "Password has been changed."), Severity.Success);
}
private string T(string german, string english) => UiText.Text(german, english);
[Parameter]
public EventCallback OnUnlocked { get; set; }
}
+33 -4
View File
@@ -3,16 +3,45 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trafag Sales Exporter</title>
<base href="/" />
<title>Trafag Finanze/Sales Management Cockpit</title>
<base href="@BaseHref" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link href="css/app.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700,800&display=swap" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<HeadOutlet @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
</head>
<body>
<Routes @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
<script src="@($"{BaseHref}_framework/blazor.web.js")" autostart="false"></script>
<script>
Blazor.start({
circuit: {
configureSignalR: builder => builder.withUrl('@($"{BaseHref}_blazor")')
}
});
</script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="js/download.js"></script>
<script src="js/vendor/three.min.js"></script>
<script src="js/finance3d.js"></script>
</body>
</html>
@code {
[Inject]
private IConfiguration Configuration { get; set; } = default!;
private string BaseHref
{
get
{
var pathBase = Configuration["ASPNETCORE_PATHBASE"]?.Trim();
if (string.IsNullOrWhiteSpace(pathBase) || pathBase == "/")
return "/";
pathBase = "/" + pathBase.Trim('/');
return $"{pathBase}/";
}
}
}
@@ -0,0 +1,113 @@
@using TrafagSalesExporter.Services
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@inject IFinanceCockpitAccessService FinanceAccess
@inject ISnackbar Snackbar
@inject NavigationManager Navigation
@inject IUiTextService UiText
@inject ILogger<FinanceCockpitUnlockPanel> Logger
<MudText Typo="Typo.h4" Class="mb-4">@T("Finance Cockpit", "Finance Cockpit")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1" Style="max-width:520px;">
<MudStack Spacing="3">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
@T("Finance Cockpit ist geschuetzt. Bitte separat anmelden.", "Finance Cockpit is protected. Please sign in separately.")
</MudAlert>
@if (!FinanceAccess.IsConfigured)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
@T("Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren.", "Finance Cockpit access is not configured yet. Please configure Username and PasswordHash in FinanceCockpitAccess.")
</MudAlert>
}
<form method="post" action="@AccessUrl">
<input type="hidden" name="returnUrl" value="@Navigation.Uri" />
<MudStack Spacing="3">
<MudTextField T="string" Name="username" Label="@T("Name", "Name")" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField T="string" Name="password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
<button type="submit" class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-button-filled-size-medium mud-ripple">
@T("Finance Cockpit entsperren", "Unlock Finance Cockpit")
</button>
</MudStack>
</form>
<MudText Typo="Typo.caption">
@T("Server-Klicks", "Server clicks"): @_unlockClickCount |
@T("Konfiguriert", "Configured"): @(FinanceAccess.IsConfigured ? "JA" : "NEIN")
</MudText>
<MudDivider />
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
<MudStack Spacing="3" Class="pt-2">
<MudTextField @bind-Value="_changeUsername" Label="@T("Name", "Name")" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField @bind-Value="_currentPassword" Label="@T("Aktuelles Passwort", "Current password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPassword" Label="@T("Neues Passwort", "New password")" InputType="InputType.Password" HelperText="@T("Mindestens 8 Zeichen.", "At least 8 characters.")" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPasswordRepeat" Label="@T("Neues Passwort wiederholen", "Repeat new password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="ChangePasswordAsync"
StartIcon="@Icons.Material.Filled.Save" Disabled="@(!FinanceAccess.IsConfigured)">
@T("Passwort speichern", "Save password")
</MudButton>
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
</MudStack>
</MudPaper>
@code {
private string? _username;
private string? _password;
private string? _changeUsername;
private string? _currentPassword;
private string? _newPassword;
private string? _newPasswordRepeat;
private int _unlockClickCount;
private string AccessUrl => new Uri(new Uri(Navigation.BaseUri), "access/finance").ToString();
private Task UnlockAsync()
{
_unlockClickCount++;
Logger.LogInformation(
"Finance unlock button handler reached. ClickCount={ClickCount}, IsConfigured={IsConfigured}, UsernameLength={UsernameLength}, PasswordLength={PasswordLength}",
_unlockClickCount,
FinanceAccess.IsConfigured,
_username?.Length ?? 0,
_password?.Length ?? 0);
if (!FinanceAccess.TryUnlock(_username ?? string.Empty, _password ?? string.Empty))
{
Snackbar.Add(T("Finance-Cockpit-Anmeldung fehlgeschlagen.", "Finance Cockpit sign-in failed."), Severity.Error);
return Task.CompletedTask;
}
_password = string.Empty;
Navigation.Refresh(forceReload: false);
return Task.CompletedTask;
}
private Task ChangePasswordAsync()
{
if (string.IsNullOrWhiteSpace(_newPassword) || _newPassword.Length < 8)
{
Snackbar.Add(T("Das neue Passwort muss mindestens 8 Zeichen lang sein.", "The new password must be at least 8 characters long."), Severity.Warning);
return Task.CompletedTask;
}
if (_newPassword != _newPasswordRepeat)
{
Snackbar.Add(T("Die neuen Passwörter stimmen nicht überein.", "The new passwords do not match."), Severity.Warning);
return Task.CompletedTask;
}
if (!FinanceAccess.TryChangePassword(_changeUsername ?? string.Empty, _currentPassword ?? string.Empty, _newPassword))
{
Snackbar.Add(T("Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen.", "Password could not be changed. Check the name or current password."), Severity.Error);
return Task.CompletedTask;
}
_currentPassword = string.Empty;
_newPassword = string.Empty;
_newPasswordRepeat = string.Empty;
Snackbar.Add(T("Passwort wurde geändert.", "Password has been changed."), Severity.Success);
return Task.CompletedTask;
}
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,943 @@
@using Microsoft.AspNetCore.Components
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IUiTextService UiText
@inject IJSRuntime JsRuntime
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
<MudTabPanel Text="@T("Ueberblick", "Overview")" Icon="@Icons.Material.Filled.Dashboard">
@PrintToolbar("hr-kpi-print-overview", T("Ueberblick als PDF", "Overview as PDF"))
<div id="hr-kpi-print-overview" class="hr-print-section">
@PrintHeader(T("Ueberblick", "Overview"))
@MetricGrid(Result.Metrics)
<MudGrid Class="mt-4">
<MudItem xs="12" md="7">
@TrafficLightPanel(Result.TrafficLights)
</MudItem>
<MudItem xs="12" md="5">
@MetricGrid(Result.PeriodComparisonMetrics)
</MudItem>
</MudGrid>
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@HeadcountByOrganisationTable(Result.HeadcountByOrganisation)
</MudItem>
<MudItem xs="12" md="6">
@CriticalBalancesTable(Result.CriticalTimeBalances)
</MudItem>
</MudGrid>
</div>
</MudTabPanel>
<MudTabPanel Text="@T("Fluktuation", "Turnover")" Icon="@Icons.Material.Filled.TrendingDown">
@PrintToolbar("hr-kpi-print-turnover", T("Fluktuation als PDF", "Turnover as PDF"))
<div id="hr-kpi-print-turnover" class="hr-print-section">
@PrintHeader(T("Fluktuation", "Turnover"))
@MetricGrid(Result.TurnoverMetrics)
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@TurnoverRelevantTable(Result.FluctuationRelevantLeavers)
</MudItem>
<MudItem xs="12" md="6">
@LeaverExclusionTable(Result.Leavers)
</MudItem>
</MudGrid>
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@GroupValueTable(T("Austritte nach Austrittsart", "Leavers by exit type"), Result.LeaversByType, T("Austritte", "Leavers"))
</MudItem>
<MudItem xs="12" md="6">
@GroupValueTable(T("Austritte nach Organisation", "Leavers by organisation"), Result.LeaversByOrganisation, T("Austritte", "Leavers"))
</MudItem>
</MudGrid>
<MudGrid Class="mt-4">
<MudItem xs="12" md="4">
@TurnoverGauge(Result.TurnoverVisuals)
</MudItem>
<MudItem xs="12" md="4">
@TurnoverFunnel(Result.TurnoverVisuals.FunnelSteps)
</MudItem>
<MudItem xs="12" md="4">
@TurnoverDonut(Result.TurnoverVisuals.ExclusionReasons)
</MudItem>
</MudGrid>
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@HorizontalBars(Result.TurnoverVisuals.RelevantByOrganisation)
</MudItem>
<MudItem xs="12" md="6">
@MonthlyBars(Result.TurnoverVisuals)
</MudItem>
</MudGrid>
</div>
</MudTabPanel>
<MudTabPanel Text="@T("Ampel", "Status")" Icon="@Icons.Material.Filled.Traffic">
@PrintToolbar("hr-kpi-print-status", T("Ampel als PDF", "Status as PDF"))
<div id="hr-kpi-print-status" class="hr-print-section">
@PrintHeader(T("Ampel", "Status"))
@TrafficLightPanel(Result.TrafficLights)
@MetricGrid(Result.PeriodComparisonMetrics)
</div>
</MudTabPanel>
<MudTabPanel Text="@T("Absenzen", "Absences")" Icon="@Icons.Material.Filled.Sick">
@PrintToolbar("hr-kpi-print-absences", T("Absenzen als PDF", "Absences as PDF"))
<div id="hr-kpi-print-absences" class="hr-print-section">
@PrintHeader(T("Absenzen", "Absences"))
@MetricGrid(Result.AbsenceMetrics)
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@GroupValueTable(T("Absenzen nach Organisation", "Absences by organisation"), Result.AbsenceByOrganisation, T("Krankheitstage", "Sick days"))
</MudItem>
<MudItem xs="12" md="6">
@TopAbsencesTable(Result.Absences)
</MudItem>
</MudGrid>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Absenzen je Mitarbeiter", "Absences by employee")</MudText>
<MudTable Items="Result.Absences.OrderByDescending(x => x.KrankheitstageGesamt).Take(100)" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Personalnr.", "Personnel no.")</MudTh>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Kurz", "Short")</MudTh>
<MudTh>@T("Lang", "Long")</MudTh>
<MudTh>@T("Gesamt", "Total")</MudTh>
<MudTh>@T("Quote", "Rate")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Personalnummer</MudTd>
<MudTd>@DisplayPersonName(context.Name, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.KrankheitstageKurz.ToString("N1")</MudTd>
<MudTd>@context.KrankheitstageLang.ToString("N1")</MudTd>
<MudTd>@context.KrankheitstageGesamt.ToString("N1")</MudTd>
<MudTd>@context.KrankenquoteMa.ToString("P1")</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>
</MudPaper>
</div>
</MudTabPanel>
<MudTabPanel Text="@T("Zeit / Ferien", "Time / Vacation")" Icon="@Icons.Material.Filled.EventAvailable">
@PrintToolbar("hr-kpi-print-time-vacation", T("Zeit/Ferien als PDF", "Time/vacation as PDF"))
<div id="hr-kpi-print-time-vacation" class="hr-print-section">
@PrintHeader(T("Zeit / Ferien", "Time / Vacation"))
@MetricGrid(Result.TimeVacationMetrics)
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@CriticalBalancesTable(Result.CriticalTimeBalances)
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Kritische Restferien", "Critical vacation balance")</MudText>
<MudTable Items="Result.Employees.OrderByDescending(x => x.UrlaubRest).Take(25)" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Rest", "Left")</MudTh>
<MudTh>@T("Ausstehend", "Open")</MudTh>
<MudTh>@T("Ampel", "Status")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.UrlaubRest.ToString("N1")</MudTd>
<MudTd>@context.FerienAusstehend.ToString("N1")</MudTd>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@TrafficLightColor(context.RestferienAmpel)" Variant="Variant.Outlined">
@context.RestferienAmpel
</MudChip>
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
</MudItem>
</MudGrid>
</div>
</MudTabPanel>
<MudTabPanel Text="@T("Mitarbeitende", "Employees")" Icon="@Icons.Material.Filled.Groups">
@PrintToolbar("hr-kpi-print-employees", T("Mitarbeitende als PDF", "Employees as PDF"))
<div id="hr-kpi-print-employees" class="hr-print-section">
@PrintHeader(T("Mitarbeitende", "Employees"))
@EmployeesTable(Result.Employees)
</div>
</MudTabPanel>
<MudTabPanel Text="@T("Datenstatus", "Data status")" Icon="@Icons.Material.Filled.FactCheck">
@PrintToolbar("hr-kpi-print-data-status", T("Datenstatus als PDF", "Data status as PDF"))
<div id="hr-kpi-print-data-status" class="hr-print-section">
@PrintHeader(T("Datenstatus", "Data status"))
@FileStatusTable(Result.FileStatuses)
<MudGrid Class="mt-4">
<MudItem xs="12">
@DataQualityTable(Result.DataQualityIssues)
</MudItem>
</MudGrid>
</div>
</MudTabPanel>
<MudTabPanel Text="@T("Anleitung", "Guide")" Icon="@Icons.Material.Filled.HelpOutline">
@GuidePanel()
</MudTabPanel>
</MudTabs>
@code {
[Parameter, EditorRequired] public HrKpiResult Result { get; set; } = new();
private string T(string german, string english) => UiText.Text(german, english);
private RenderFragment PrintToolbar(string targetId, string label) => @<MudStack Row Justify="Justify.FlexEnd" Class="mb-3 hr-print-toolbar">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small"
StartIcon="@Icons.Material.Filled.PictureAsPdf"
OnClick="@(() => PrintSectionAsync(targetId))">
@label
</MudButton>
</MudStack>;
private RenderFragment PrintHeader(string title) => @<div class="hr-print-header">
<h1>@title</h1>
<p>@Result.Options.DataFolder</p>
<p>@BuildFilterSummary()</p>
</div>;
private async Task PrintSectionAsync(string targetId)
{
await JsRuntime.InvokeVoidAsync("trafagDownload.printElement", targetId);
}
private string BuildFilterSummary()
{
var parts = new List<string>();
if (Result.Options.FromDate.HasValue || Result.Options.ToDate.HasValue)
parts.Add($"{T("Zeitraum", "Period")}: {FormatDate(Result.Options.FromDate)} - {FormatDate(Result.Options.ToDate)}");
if (Result.Options.Year.HasValue)
parts.Add($"{T("Austrittsjahr", "Leaver year")}: {Result.Options.Year.Value}");
parts.Add($"{T("Organisation", "Organisation")}: {BlankAsAll(Result.Options.Organisationseinheit)}");
parts.Add($"{T("Mitarbeitertyp", "Employee type")}: {BlankAsAll(Result.Options.Mitarbeitertyp)}");
if (!string.IsNullOrWhiteSpace(Result.Options.KostenstelleText))
parts.Add($"{T("Kostenstelle", "Cost center")}: {Result.Options.KostenstelleText}");
return string.Join(" | ", parts);
}
private string BlankAsAll(string? value)
=> string.IsNullOrWhiteSpace(value) ? T("Alle", "All") : value;
private static Color MetricColor(string severity)
=> severity == "Warning" ? Color.Warning : Color.Default;
private static Color TrafficLightColor(string value)
=> value switch
{
"Rot" => Color.Error,
"Gelb" => Color.Warning,
_ => Color.Success
};
private static Color MapQualityColor(string severity)
=> severity switch
{
"Error" => Color.Error,
"Warning" => Color.Warning,
_ => Color.Info
};
private static string DisplayPersonName(string name, int? personalnummer, bool managementView)
=> managementView
? (personalnummer.HasValue ? $"Personalnr. {personalnummer.Value}" : "Person anonymisiert")
: name;
private static string FormatDate(DateTime? value)
=> value?.ToString("dd.MM.yyyy") ?? "-";
private RenderFragment<IReadOnlyList<HrKpiMetric>> MetricGrid => metrics => @<MudGrid Class="mb-4">
@foreach (var metric in metrics)
{
<MudItem xs="12" sm="6" md="3" lg="2">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.caption">@metric.Label</MudText>
<MudText Typo="Typo.h5">@metric.Value</MudText>
<MudText Typo="Typo.body2" Color="@MetricColor(metric.Severity)">@metric.Detail</MudText>
</MudPaper>
</MudItem>
}
</MudGrid>;
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> HeadcountByOrganisationTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Headcount nach Organisation", "Headcount by organisation")</MudText>
<MudTable Items="items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Headcount", "Headcount")</MudTh>
<MudTh>FTE</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.Count.ToString("N0")</MudTd>
<MudTd>@context.Value.ToString("N1")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiTrafficLight>> TrafficLightPanel => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("HR-Ampel", "HR status")</MudText>
<MudGrid>
@foreach (var item in items)
{
<MudItem xs="12" sm="6" md="4">
<MudPaper Class="pa-3" Elevation="0">
<MudStack Row AlignItems="AlignItems.Center" Spacing="2">
<MudChip T="string" Size="Size.Small" Color="@TrafficLightColor(item.Status)" Variant="Variant.Filled">
@item.Status
</MudChip>
<MudText Typo="Typo.subtitle2">@item.Area</MudText>
</MudStack>
<MudText Typo="Typo.h6">@item.Value</MudText>
<MudText Typo="Typo.body2">@item.Detail</MudText>
</MudPaper>
</MudItem>
}
</MudGrid>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiDataQualityIssue>> DataQualityTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Datenqualitaet", "Data quality")</MudText>
<MudTable Items="items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Schwere", "Severity")</MudTh>
<MudTh>@T("Bereich", "Area")</MudTh>
<MudTh>@T("Pruefpunkt", "Check")</MudTh>
<MudTh>@T("Anzahl", "Count")</MudTh>
<MudTh>@T("Hinweis", "Note")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@MapQualityColor(context.Severity)" Variant="Variant.Outlined">
@context.Severity
</MudChip>
</MudTd>
<MudTd>@context.Area</MudTd>
<MudTd>@context.Issue</MudTd>
<MudTd>@context.Count.ToString("N0")</MudTd>
<MudTd>@context.Detail</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.body2">@T("Keine Datenqualitaetswarnungen.", "No data quality warnings.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>;
private RenderFragment<(string Title, IReadOnlyList<HrKpiGroupValue> Items, string ValueLabel)> GroupValueTableTuple => data => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@data.Title</MudText>
<MudTable Items="data.Items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Gruppe", "Group")</MudTh>
<MudTh>@data.ValueLabel</MudTh>
<MudTh>%</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@(context.Value != 0 ? context.Value.ToString("N1") : context.Count.ToString("N0"))</MudTd>
<MudTd>@context.Percent.ToString("N1")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment GroupValueTable(string title, IReadOnlyList<HrKpiGroupValue> items, string valueLabel)
=> GroupValueTableTuple((title, items, valueLabel));
private RenderFragment<IReadOnlyList<HrAbsenceRow>> TopAbsencesTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Hoechste Absenzen", "Highest absences")</MudText>
<MudTable Items="items.OrderByDescending(x => x.KrankheitstageGesamt).Take(25)" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Kurz", "Short")</MudTh>
<MudTh>@T("Lang", "Long")</MudTh>
<MudTh>@T("Gesamt", "Total")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@DisplayPersonName(context.Name, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.KrankheitstageKurz.ToString("N1")</MudTd>
<MudTd>@context.KrankheitstageLang.ToString("N1")</MudTd>
<MudTd>@context.KrankheitstageGesamt.ToString("N1")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiEmployeeRow>> CriticalBalancesTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Kritische GLZ-Saldi", "Critical time balances")</MudText>
<MudTable Items="items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Saldo", "Balance")</MudTh>
<MudTh>@T("Ampel", "Status")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.StundenSaldo.ToString("N1")</MudTd>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@TrafficLightColor(context.GlzAmpel)" Variant="Variant.Outlined">
@context.GlzAmpel
</MudChip>
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrLeaverRow>> TurnoverRelevantTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Fluktuationsrelevante Austritte", "Turnover relevant leavers")</MudText>
<MudTable Items="items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Austritt", "Exit")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Austrittsart", "Exit type")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@FormatDate(context.Austrittsdatum)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.Austrittsart</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrLeaverRow>> LeaverExclusionTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Ausschlussgruende", "Exclusion reasons")</MudText>
<MudTable Items="BuildLeaverExclusionRows(items)" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Grund", "Reason")</MudTh>
<MudTh>@T("Anzahl", "Count")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.Count.ToString("N0")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiEmployeeRow>> EmployeesTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Mitarbeitende", "Employees")</MudText>
<MudTable Items="items.Take(250)" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Personalnr.", "Personnel no.")</MudTh>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Kostenstelle", "Cost center")</MudTh>
<MudTh>FTE</MudTh>
<MudTh>@T("Alter", "Age")</MudTh>
<MudTh>@T("Dienstjahre", "Service years")</MudTh>
<MudTh>@T("Typ", "Type")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Personalnummer</MudTd>
<MudTd>@DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.KostenstelleText</MudTd>
<MudTd>@context.Fte.ToString("N2")</MudTd>
<MudTd>@context.AlterJahre</MudTd>
<MudTd>@context.Dienstjahre</MudTd>
<MudTd>@context.Mitarbeitertyp</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiFileStatus>> FileStatusTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Dateistatus", "File status")</MudText>
<MudTable Items="items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Quelle", "Source")</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Stand", "Modified")</MudTh>
<MudTh>@T("Alter", "Age")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudText Typo="Typo.body2">@context.Label</MudText>
<MudText Typo="Typo.caption">@context.Path</MudText>
</MudTd>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@(context.Exists ? Color.Success : Color.Error)" Variant="Variant.Outlined">
@(context.Message ?? "-")
</MudChip>
</MudTd>
<MudTd>@FormatDate(context.LastModified)</MudTd>
<MudTd>@(context.AgeDays.HasValue ? $"{context.AgeDays:N0} Tage / {context.FreshnessStatus}" : "-")</MudTd>
<MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment GuidePanel() => @<MudGrid>
<MudItem xs="12" md="8">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Ablauf fuer HR", "HR workflow")</MudText>
<div class="hr-guide-steps">
<div class="hr-guide-step">
<MudIcon Icon="@Icons.Material.Filled.Download" Size="Size.Large" />
<span>1</span>
<strong>@T("Rexx exportieren", "Export from Rexx")</strong>
<p>@T("Die benoetigten Rexx-Abfragen manuell herunterladen. Excel/XLSX verwenden, nicht PDF.", "Download the required Rexx queries manually. Use Excel/XLSX, not PDF.")</p>
</div>
<div class="hr-guide-step">
<MudIcon Icon="@Icons.Material.Filled.FolderCopy" Size="Size.Large" />
<span>2</span>
<strong>@T("Dateien ablegen", "Place files")</strong>
<p>@T("Downloads in den Datenordner kopieren und exakt wie unten benennen.", "Copy downloads into the data folder and name them exactly as listed below.")</p>
</div>
<div class="hr-guide-step">
<MudIcon Icon="@Icons.Material.Filled.Refresh" Size="Size.Large" />
<span>3</span>
<strong>@T("Cockpit laden", "Load cockpit")</strong>
<p>@T("Im HR-KPI-Cockpit den Datenordner kontrollieren und Laden klicken.", "Check the data folder in the HR KPI cockpit and click Load.")</p>
</div>
<div class="hr-guide-step">
<MudIcon Icon="@Icons.Material.Filled.FactCheck" Size="Size.Large" />
<span>4</span>
<strong>@T("Datenstatus pruefen", "Check data status")</strong>
<p>@T("Im Reiter Datenstatus muessen die erwarteten Dateien gruen erscheinen.", "In the Data status tab, the expected files should be green.")</p>
</div>
</div>
</MudPaper>
</MudItem>
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Datenordner", "Data folder")</MudText>
<MudText Typo="Typo.body1">@Result.Options.DataFolder</MudText>
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mt-3">
@T("Der Standardordner ist konfigurierbar. Fuer einen anderen Ordner oben im HR-KPI-Filter den Datenordner anpassen und neu laden.",
"The default folder is configurable. To use another folder, change the data folder in the HR KPI filter above and reload.")
</MudAlert>
<MudAlert Severity="Severity.Warning" Dense Variant="Variant.Outlined" Class="mt-2">
@T("HR-Dateien enthalten Personendaten. Nicht per E-Mail weiterleiten und keine Kopien in ungeschuetzten Ordnern liegen lassen.",
"HR files contain personal data. Do not forward them by email and do not leave copies in unprotected folders.")
</MudAlert>
</MudPaper>
</MudItem>
<MudItem xs="12">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Neue Auswertungen im Cockpit", "New cockpit views")</MudText>
<MudGrid>
<MudItem xs="12" md="6">
<ul class="mb-0">
<li>@T("Managementsicht anonymisiert Personendaten fuer Fuehrungsberichte.", "Management view anonymizes personal data for management reports.")</li>
<li>@T("Dateistatus zeigt Pfad, Zeilen, Aenderungsdatum, Alter und Frische.", "File status shows path, rows, modification date, age and freshness.")</li>
<li>@T("HR-Ampel fasst Fluktuation, Krankheit, GLZ, Restferien und Datenqualitaet zusammen.", "HR status summarizes turnover, sickness, time balance, vacation balance and data quality.")</li>
<li>@T("GLZ- und Restferien-Ampeln koennen gefiltert werden.", "Time-balance and vacation status can be filtered.")</li>
<li>@T("Periodenvergleich zeigt die wichtigsten Vorjahreswerte, soweit Daten vorhanden sind.", "Period comparison shows key prior-year values where data is available.")</li>
</ul>
</MudItem>
<MudItem xs="12" md="6">
<ul class="mb-0">
<li>@T("Datenqualitaet markiert fehlende Dateien, alte Dateien und auffaellige Werte.", "Data quality flags missing files, old files and suspicious values.")</li>
<li>@T("Austritte werden nach Austrittsart und Organisation gruppiert.", "Leavers are grouped by exit type and organisation.")</li>
<li>@T("Absenzen werden nach Organisation ausgewertet.", "Absences are evaluated by organisation.")</li>
<li>@T("Top-Absenzen und kritische Detailtabellen helfen bei der operativen Pruefung.", "Top absences and critical detail tables support operational checks.")</li>
<li>@T("Drucken/PDF erzeugt eine weitergebbare Ansicht aus dem Browser.", "Print/PDF creates a shareable browser view.")</li>
</ul>
</MudItem>
</MudGrid>
</MudPaper>
</MudItem>
<MudItem xs="12">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Erwartete Dateien", "Expected files")</MudText>
<MudTable Items="Result.FileStatuses" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Inhalt", "Content")</MudTh>
<MudTh>@T("Datei/Pfad", "File/path")</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.Path</MudTd>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@(context.Exists ? Color.Success : Color.Error)" Variant="Variant.Outlined">
@(context.Exists ? T("gefunden", "found") : T("fehlt", "missing"))
</MudChip>
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
</MudItem>
</MudGrid>;
private static IEnumerable<HrKpiGroupValue> BuildLeaverExclusionRows(IReadOnlyList<HrLeaverRow> items)
=> items
.GroupBy(x => x.FluktuationAusschlussgrund ?? "Relevant")
.Select(g => new HrKpiGroupValue { Label = g.Key, Count = g.Count(), Value = g.Count() })
.OrderByDescending(x => x.Count);
private RenderFragment<HrTurnoverVisuals> TurnoverGauge => visual => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@visual.RateTitle</MudText>
<div class="hr-gauge" style="@($"--gauge-color:{visual.GaugeColor}; --gauge-deg:{visual.GaugeRotationDegrees.ToString("0", System.Globalization.CultureInfo.InvariantCulture)}deg")">
<div class="hr-gauge-track"></div>
<div class="hr-gauge-needle"></div>
<div class="hr-gauge-center">
<div class="hr-gauge-value">@visual.YearRateLabel</div>
<div class="hr-gauge-caption">0-20%</div>
</div>
</div>
<div class="hr-gauge-scale">
<span>0%</span>
<span>8%</span>
<span>12%</span>
<span>20%+</span>
</div>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> TurnoverFunnel => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Austritts-Funnel", "Leaver funnel")</MudText>
<div class="hr-funnel">
@foreach (var item in items)
{
<div class="hr-funnel-row">
<div class="hr-funnel-label">@item.Label</div>
<div class="hr-funnel-bar-wrap">
<div class="hr-funnel-bar" style="@($"width:{Math.Max(item.Percent, 3).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")">
<span>@item.Count.ToString("N0")</span>
</div>
</div>
</div>
}
</div>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> TurnoverDonut => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Ausschlussgruende", "Exclusion reasons")</MudText>
<div class="hr-donut-wrap">
<div class="hr-donut" style="@BuildDonutStyle(items)">
<div class="hr-donut-hole">@items.Sum(x => x.Count).ToString("N0")</div>
</div>
<div class="hr-donut-legend">
@foreach (var item in items.Take(7))
{
<div class="hr-legend-row">
<span class="hr-legend-dot" style="@($"background:{item.Color}")"></span>
<span>@item.Label</span>
<strong>@item.Count.ToString("N0")</strong>
</div>
}
</div>
</div>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> HorizontalBars => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Relevante Austritte nach Organisation", "Relevant leavers by organisation")</MudText>
<div class="hr-bars">
@foreach (var item in items)
{
<div class="hr-bar-row">
<div class="hr-bar-label">@item.Label</div>
<div class="hr-bar-track">
<div class="hr-bar-fill" style="@($"width:{Math.Max(item.Percent, 3).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")"></div>
</div>
<div class="hr-bar-value">@item.Count.ToString("N0")</div>
</div>
}
</div>
</MudPaper>;
private RenderFragment<HrTurnoverVisuals> MonthlyBars => visual => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@visual.TimelineTitle</MudText>
<div class="hr-month-bars">
@foreach (var item in visual.MonthlyRelevantLeavers)
{
<div class="hr-month">
<div class="hr-month-bar" style="@($"height:{Math.Max(item.Percent, item.Count > 0 ? 8 : 1).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")"></div>
<div class="hr-month-value">@item.Count</div>
<div class="hr-month-label">@item.Label</div>
</div>
}
</div>
</MudPaper>;
private static string BuildDonutStyle(IReadOnlyList<HrKpiGroupValue> items)
{
var total = items.Sum(x => x.Count);
if (total <= 0)
return "background:#e0e0e0";
var current = 0m;
var segments = new List<string>();
foreach (var item in items)
{
var start = current;
current += item.Count / (decimal)total * 100m;
segments.Add($"{item.Color} {start.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}% {current.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%");
}
return $"background:conic-gradient({string.Join(", ", segments)})";
}
}
<style>
.hr-viz-panel {
min-height: 100%;
}
.hr-print-header {
display: none;
}
.hr-print-toolbar {
width: 100%;
}
.hr-guide-steps {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 12px;
}
.hr-guide-step {
min-height: 175px;
padding: 16px;
border: 1px solid var(--mud-palette-lines-default);
border-top: 5px solid var(--mud-palette-primary);
display: flex;
flex-direction: column;
gap: 8px;
background: var(--mud-palette-surface);
}
.hr-guide-step span {
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-grid;
place-items: center;
color: var(--mud-palette-primary-text);
background: var(--mud-palette-primary);
font-weight: 700;
}
.hr-guide-step p {
margin: 0;
color: var(--mud-palette-text-secondary);
}
@@media (max-width: 1100px) {
.hr-guide-steps {
grid-template-columns: repeat(2, minmax(150px, 1fr));
}
}
@@media (max-width: 700px) {
.hr-guide-steps {
grid-template-columns: 1fr;
}
}
.hr-gauge {
--gauge-color: #2e7d32;
--gauge-deg: 0deg;
position: relative;
height: 170px;
display: grid;
place-items: end center;
overflow: hidden;
}
.hr-gauge-track {
width: 260px;
height: 130px;
border-radius: 260px 260px 0 0;
background: conic-gradient(from 270deg at 50% 100%, #2e7d32 0deg 72deg, #f9a825 72deg 108deg, #c62828 108deg 180deg, transparent 180deg 360deg);
position: absolute;
bottom: 0;
}
.hr-gauge-track::after {
content: "";
position: absolute;
left: 34px;
right: 34px;
bottom: 0;
height: 96px;
border-radius: 192px 192px 0 0;
background: var(--mud-palette-surface);
}
.hr-gauge-needle {
position: absolute;
bottom: 4px;
width: 4px;
height: 112px;
background: #263238;
transform-origin: bottom center;
transform: rotate(calc(var(--gauge-deg) - 90deg));
border-radius: 4px;
z-index: 2;
}
.hr-gauge-center {
z-index: 3;
text-align: center;
margin-bottom: 4px;
}
.hr-gauge-value {
font-size: 2rem;
font-weight: 700;
color: var(--gauge-color);
}
.hr-gauge-caption,
.hr-gauge-scale {
color: var(--mud-palette-text-secondary);
font-size: .8rem;
}
.hr-gauge-scale {
display: flex;
justify-content: space-between;
max-width: 280px;
margin: 4px auto 0;
}
.hr-funnel-row,
.hr-bar-row,
.hr-legend-row {
display: grid;
grid-template-columns: minmax(110px, 1fr) 2fr auto;
gap: 10px;
align-items: center;
margin: 9px 0;
}
.hr-funnel-bar-wrap,
.hr-bar-track {
background: rgba(0,0,0,.08);
border-radius: 4px;
height: 24px;
overflow: hidden;
}
.hr-funnel-bar,
.hr-bar-fill {
height: 100%;
border-radius: 4px;
color: white;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 8px;
min-width: 26px;
}
.hr-donut-wrap {
display: grid;
grid-template-columns: 150px 1fr;
gap: 18px;
align-items: center;
}
.hr-donut {
width: 150px;
height: 150px;
border-radius: 50%;
position: relative;
}
.hr-donut::after {
content: "";
position: absolute;
inset: 34px;
border-radius: 50%;
background: var(--mud-palette-surface);
}
.hr-donut-hole {
position: absolute;
inset: 0;
display: grid;
place-items: center;
z-index: 2;
font-weight: 700;
font-size: 1.35rem;
}
.hr-legend-row {
grid-template-columns: auto 1fr auto;
margin: 6px 0;
font-size: .9rem;
}
.hr-legend-dot {
width: 11px;
height: 11px;
border-radius: 50%;
display: inline-block;
}
.hr-bars {
display: grid;
gap: 7px;
}
.hr-bar-row {
grid-template-columns: minmax(130px, 1.2fr) 2fr auto;
}
.hr-month-bars {
height: 240px;
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 8px;
align-items: end;
}
.hr-month {
height: 100%;
display: grid;
grid-template-rows: 1fr auto auto;
align-items: end;
text-align: center;
color: var(--mud-palette-text-secondary);
font-size: .8rem;
}
.hr-month-bar {
width: 100%;
min-height: 2px;
border-radius: 4px 4px 0 0;
}
.hr-month-value {
color: var(--mud-palette-text-primary);
font-weight: 600;
}
@@media (max-width: 700px) {
.hr-donut-wrap {
grid-template-columns: 1fr;
justify-items: center;
}
.hr-funnel-row,
.hr-bar-row {
grid-template-columns: 1fr;
gap: 4px;
}
}
</style>
@@ -1,4 +1,7 @@
@inherits LayoutComponentBase
@implements IDisposable
@using System.Security.Claims
@inject TrafagSalesExporter.Services.IUiTextService UiText
<MudThemeProvider Theme="_theme" />
<MudPopoverProvider />
@@ -9,14 +12,43 @@
<MudAppBar Elevation="1" Color="Color.Primary">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
OnClick="ToggleDrawer" />
<MudText Typo="Typo.h6" Class="ml-3">Trafag Sales Exporter</MudText>
<MudText Typo="Typo.h6" Class="ml-3 app-title">@T("Trafag Finance/Sales Management Cockpit", "Trafag Finance/Sales Management Cockpit")</MudText>
<MudSpacer />
<MudMenu Class="mr-3 language-menu"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopRight"
Dense>
<ActivatorContent>
<MudButton Variant="Variant.Outlined"
Color="Color.Inherit"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Translate"
EndIcon="@Icons.Material.Filled.ExpandMore"
Class="language-button">
@LanguageLabel
</MudButton>
</ActivatorContent>
<ChildContent>
<MudMenuItem OnClick="@(() => ChangeLanguage("de"))">Deutsch</MudMenuItem>
<MudMenuItem OnClick="@(() => ChangeLanguage("en"))">English</MudMenuItem>
<MudMenuItem OnClick="@(() => ChangeLanguage("es"))">Español</MudMenuItem>
<MudMenuItem OnClick="@(() => ChangeLanguage("it"))">Italiano</MudMenuItem>
<MudMenuItem OnClick="@(() => ChangeLanguage("hi"))">हिन्दी</MudMenuItem>
</ChildContent>
</MudMenu>
<AuthorizeView>
<Authorized Context="authState">
<MudText Typo="Typo.caption" Class="mr-3">@ShortName(authState.User)</MudText>
</Authorized>
</AuthorizeView>
<img src="trafag.jpg" alt="Trafag" class="app-logo" />
</MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" Elevation="2" ClipMode="DrawerClipMode.Always">
<NavMenu />
</MudDrawer>
<MudMainContent Class="pa-4">
<MudMainContent Class="pa-4" @key="UiText.CurrentLanguage">
@Body
</MudMainContent>
</MudLayout>
@@ -28,11 +60,42 @@
{
PaletteLight = new PaletteLight
{
Primary = "#1565C0",
Secondary = "#00897B",
AppbarBackground = "#1565C0"
Primary = "#B71C1C",
Secondary = "#7F1D1D",
AppbarBackground = "#B71C1C"
}
};
protected override void OnInitialized()
{
UiText.Changed += HandleLanguageChanged;
}
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
private void ChangeLanguage(string language)
{
UiText.SetLanguage(language);
}
private void HandleLanguageChanged()
{
InvokeAsync(StateHasChanged);
}
private string LanguageLabel => UiText.CurrentLanguage.ToUpperInvariant();
private string T(string german, string english) => UiText.Text(german, english);
private static string ShortName(ClaimsPrincipal user)
{
var name = user.Identity?.Name ?? string.Empty;
var separator = name.LastIndexOf('\\');
return separator >= 0 && separator < name.Length - 1 ? name[(separator + 1)..] : name;
}
public void Dispose()
{
UiText.Changed -= HandleLanguageChanged;
}
}
@@ -1,17 +1,96 @@
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Security
@using TrafagSalesExporter.Services
@implements IDisposable
@inject IUiTextService UiText
@inject IFinanceCockpitAccessService FinanceAccess
@inject INavigationMenuService NavigationMenuService
@inject IConfiguration Configuration
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IAuthorizationService AuthorizationService
<MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
Dashboard
</MudNavLink>
<MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn">
Standorte
</MudNavLink>
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
Transformationen
</MudNavLink>
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
Settings
</MudNavLink>
<MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">
Logs
</MudNavLink>
@foreach (var item in RootItems)
{
<NavMenuNode Item="item"
Items="_visibleItems"
HiddenKeys="_hiddenKeys"
OnAction="HandleMenuActionAsync" />
}
</MudNavMenu>
@code {
private List<NavigationMenuItem> _visibleItems = [];
private readonly HashSet<string> _hiddenKeys = [];
private bool ShowFinanceComparison => Configuration.GetValue("Navigation:ShowFinanceComparison", true);
private IEnumerable<NavigationMenuItem> RootItems => _visibleItems
.Where(x => string.IsNullOrWhiteSpace(x.ParentKey))
.Where(x => !_hiddenKeys.Contains(x.Key))
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.TitleDe);
protected override async Task OnInitializedAsync()
{
UiText.Changed += HandleLanguageChanged;
await LoadMenuAsync();
}
private async Task LoadMenuAsync()
{
var items = await NavigationMenuService.GetItemsAsync();
var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var user = authenticationState.User;
var filtered = new List<NavigationMenuItem>();
foreach (var item in items.Where(x => x.IsVisible))
{
if (!await IsAuthorizedAsync(user, item))
continue;
filtered.Add(item);
}
_hiddenKeys.Clear();
if (!ShowFinanceComparison)
_hiddenKeys.Add("finance-comparison");
if (!FinanceAccess.IsEnabled || !FinanceAccess.IsUnlocked)
_hiddenKeys.Add("finance-lock");
_visibleItems = filtered;
}
private async Task<bool> IsAuthorizedAsync(System.Security.Claims.ClaimsPrincipal user, NavigationMenuItem item)
{
if (string.IsNullOrWhiteSpace(item.RequiredPolicy))
return true;
var result = await AuthorizationService.AuthorizeAsync(user, item.RequiredPolicy);
return result.Succeeded;
}
private Task HandleMenuActionAsync(string key)
{
if (key == "finance-lock")
{
FinanceAccess.Lock();
Navigation.NavigateTo(string.Empty);
}
return Task.CompletedTask;
}
private void HandleLanguageChanged()
{
InvokeAsync(StateHasChanged);
}
public void Dispose()
{
UiText.Changed -= HandleLanguageChanged;
}
}
@@ -0,0 +1,51 @@
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@using Microsoft.AspNetCore.Components.Routing
@if (Item.ItemType == NavigationMenuItemTypes.Group)
{
<MudNavGroup Title="@Title" Icon="@Icon" Expanded="@Item.IsExpanded">
@foreach (var child in Children)
{
<NavMenuNode Item="child"
Items="Items"
HiddenKeys="HiddenKeys"
OnAction="OnAction" />
}
</MudNavGroup>
}
else if (Item.ItemType == NavigationMenuItemTypes.Action)
{
<MudButton Variant="Variant.Text" Color="Color.Secondary" Size="Size.Small"
StartIcon="@Icon" OnClick="() => OnAction.InvokeAsync(Item.Key)" Class="ml-3">
@Title
</MudButton>
}
else
{
<MudNavLink Href="@Item.Href" Match="@Match" Icon="@Icon">
@Title
</MudNavLink>
}
@code {
[Parameter, EditorRequired] public NavigationMenuItem Item { get; set; } = default!;
[Parameter, EditorRequired] public IReadOnlyList<NavigationMenuItem> Items { get; set; } = [];
[Parameter] public HashSet<string> HiddenKeys { get; set; } = [];
[Parameter] public EventCallback<string> OnAction { get; set; }
private string Title => UiText.Text(Item.TitleDe, Item.TitleEn);
private string Icon => NavigationIconResolver.Resolve(Item.Icon);
private NavLinkMatch Match => string.Equals(Item.Match, "All", StringComparison.OrdinalIgnoreCase)
? NavLinkMatch.All
: NavLinkMatch.Prefix;
[Inject] private IUiTextService UiText { get; set; } = default!;
private IEnumerable<NavigationMenuItem> Children => Items
.Where(x => x.IsVisible)
.Where(x => !HiddenKeys.Contains(x.Key))
.Where(x => string.Equals(x.ParentKey, Item.Key, StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.TitleDe);
}
@@ -0,0 +1,104 @@
@page "/admin/sessions"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using TrafagSalesExporter.Services
@inject IAccessSessionTracker SessionTracker
@inject IAdminAccessService AdminAccess
@inject ILandingPageSettingsService LandingSettings
@inject IUiTextService UiText
<PageTitle>@T("Aktive Logins", "Active logins")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Aktive Logins", "Active logins")</MudText>
@if (!AdminAccess.IsUnlocked)
{
<AdminAccessPanel OnUnlocked="Refresh" />
}
else
{
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Class="mb-4">
<div>
<MudText Typo="Typo.h6">@T("Startseite", "Landing page")</MudText>
<MudText Typo="Typo.caption">
@T("Optionale Animation unter dem Willkommens-Text.", "Optional animation below the welcome text.")
</MudText>
</div>
<MudSpacer />
<MudSwitch T="bool" Value="LandingSettings.ShowWalkingLabFigure" ValueChanged="SetWalkingFigure"
Color="Color.Primary"
Label="@T("Strichmännchen anzeigen", "Show stick figure")" />
</MudStack>
<MudDivider Class="mb-4" />
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
<div>
<MudText Typo="Typo.h6">@T("HR-/Finance-Cockpit Sessions", "HR/Finance cockpit sessions")</MudText>
<MudText Typo="Typo.caption">
@T("Gezählt werden App-interne Entsperrungen seit dem letzten App-Start.", "Counts app-internal unlocks since the last app start.")
</MudText>
</div>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Lock" OnClick="LockAdmin">
@T("Admin sperren", "Lock admin")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh" OnClick="Refresh">
@T("Aktualisieren", "Refresh")
</MudButton>
</MudStack>
<MudTable Items="_sessions" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Bereich", "Area")</MudTh>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("IP-Adresse", "IP address")</MudTh>
<MudTh>@T("Entsperrt seit", "Unlocked since")</MudTh>
<MudTh>@T("Zuletzt gesehen", "Last seen")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Area</MudTd>
<MudTd>@context.Username</MudTd>
<MudTd>@context.RemoteAddress</MudTd>
<MudTd>@FormatDate(context.StartedAt)</MudTd>
<MudTd>@FormatDate(context.LastSeenAt)</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Keine aktiven HR-/Finance-Logins erfasst.", "No active HR/Finance logins recorded.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
@T("Hinweis: HR und Finance verwenden gemeinsame App-Logins. Diese Seite zeigt daher den verwendeten Login-Namen und die Session, nicht zwingend die echte Person.", "Note: HR and Finance use shared app logins. This page therefore shows the used login name and session, not necessarily the real person.")
</MudAlert>
}
@code {
private IReadOnlyList<AccessSessionSnapshot> _sessions = [];
protected override void OnInitialized()
{
Refresh();
}
private void Refresh()
{
_sessions = SessionTracker.GetActiveSessions();
}
private void LockAdmin()
{
AdminAccess.Lock();
_sessions = [];
}
private void SetWalkingFigure(bool value)
{
LandingSettings.SetShowWalkingLabFigure(value);
}
private static string FormatDate(DateTimeOffset value)
=> value.ToString("dd.MM.yyyy HH:mm:ss");
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -1,193 +1,252 @@
@page "/"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ExportOrchestrationService Orchestrator
@inject TimerBackgroundService TimerService
@inject ISnackbar Snackbar
@implements IDisposable
@inject IUiTextService UiText
@inject ILandingPageSettingsService LandingSettings
<PageTitle>Dashboard</PageTitle>
<PageTitle>@T("Trafag Cockpit", "Trafag Cockpit")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Dashboard</MudText>
<div class="home-shell">
<div class="home-content">
<svg class="home-manometer" viewBox="0 0 600 340" role="img" aria-label="Trafag cockpit manometer">
<rect x="0" y="0" width="600" height="340" fill="#fff" />
<path d="M70 260 A230 230 0 0 1 530 260" class="gauge-outer" />
<path d="M115 260 A185 185 0 0 1 485 260" class="gauge-inner" />
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
OnClick="ExportAll" Disabled="_anyRunning">
Alle exportieren
</MudButton>
<MudText Typo="Typo.body1">
@if (TimerService.NextRun < DateTime.MaxValue)
{
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Small" Class="mr-1" />
@($"Nächster automatischer Lauf: {TimerService.NextRun:dd.MM.yyyy HH:mm}")
}
else
{
<MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" />
@("Timer deaktiviert")
}
</MudText>
</MudStack>
</MudPaper>
<line x1="126" y1="260" x2="95" y2="260" class="gauge-tick" />
<line x1="177" y1="137" x2="155" y2="115" class="gauge-tick" />
<line x1="300" y1="86" x2="300" y2="55" class="gauge-tick" />
<line x1="423" y1="137" x2="445" y2="115" class="gauge-tick" />
<line x1="474" y1="260" x2="505" y2="260" class="gauge-tick" />
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Schema</MudTh>
<MudTh>Server</MudTh>
<MudTh>Status</MudTh>
<MudTh>Zeilen</MudTh>
<MudTh>Letzter Lauf</MudTh>
<MudTh>Dauer</MudTh>
<MudTh>Aktion</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.ServerName</MudTd>
<MudTd>
@if (Orchestrator.IsExporting(context.SiteId))
<text x="150" y="230" class="gauge-label">0</text>
<text x="205" y="154" class="gauge-label">25</text>
<text x="300" y="126" class="gauge-label">50</text>
<text x="395" y="154" class="gauge-label">75</text>
<text x="450" y="230" class="gauge-label">100</text>
<text x="300" y="222" class="gauge-brand">TRAFAG</text>
<g class="gauge-needle">
<line x1="300" y1="260" x2="300" y2="96" class="needle-line" />
</g>
<circle cx="300" cy="260" r="28" fill="#050505" />
</svg>
<div class="home-welcome">@T("Willkommen im Trafag Analyse Dashboard", "Welcome to the Trafag Analytical Dashboard")</div>
@if (LandingSettings.ShowWalkingLabFigure)
{
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
<MudText Typo="Typo.caption">@Orchestrator.GetExportStatus(context.SiteId)</MudText>
<div class="walking-stage" aria-label="Walking lab figure">
<div class="walking-person">
<span class="head"></span>
<span class="body"></span>
<span class="coat coat-left"></span>
<span class="coat coat-right"></span>
<span class="arm arm-left"></span>
<span class="arm arm-right"></span>
<span class="leg leg-left"></span>
<span class="leg leg-right"></span>
</div>
</div>
}
else if (context.LastStatus == "OK")
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
</div>
</div>
<style>
.home-shell {
min-height: calc(100vh - 112px);
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
else if (context.LastStatus == "Error")
{
<MudTooltip Text="@context.ErrorMessage">
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Small" />
</MudTooltip>
.home-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
.home-manometer {
width: min(336px, 58vw);
height: auto;
display: block;
}
</MudTd>
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.FileDownload"
OnClick="() => ExportSingle(context.SiteId)"
Disabled="Orchestrator.IsExporting(context.SiteId)">
Export
</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
.home-welcome {
color: #050505;
font-size: 24px;
font-weight: 700;
text-align: center;
letter-spacing: 0;
}
.walking-stage {
width: min(360px, 70vw);
height: 96px;
position: relative;
overflow: hidden;
background: #fff;
border-bottom: 2px solid #050505;
}
.walking-person {
position: absolute;
left: 0;
bottom: 4px;
width: 48px;
height: 82px;
animation: lab-walk-path 7s linear infinite;
}
.walking-person span {
position: absolute;
display: block;
background: #050505;
}
.walking-person .head {
left: 15px;
top: 0;
width: 18px;
height: 18px;
border: 3px solid #050505;
border-radius: 50%;
background: #fff;
}
.walking-person .body {
left: 22px;
top: 22px;
width: 4px;
height: 34px;
}
.walking-person .coat {
top: 25px;
width: 17px;
height: 36px;
border: 3px solid #050505;
background: #fff;
}
.walking-person .coat-left {
left: 8px;
transform: skewY(10deg);
border-right: 0;
}
.walking-person .coat-right {
left: 23px;
transform: skewY(-10deg);
border-left: 0;
}
.walking-person .arm,
.walking-person .leg {
width: 4px;
border-radius: 4px;
transform-origin: 50% 0;
}
.walking-person .arm {
top: 28px;
height: 28px;
animation: limb-swing 0.72s ease-in-out infinite alternate;
}
.walking-person .arm-left {
left: 13px;
}
.walking-person .arm-right {
left: 31px;
animation-direction: alternate-reverse;
}
.walking-person .leg {
top: 56px;
height: 28px;
animation: limb-swing 0.72s ease-in-out infinite alternate-reverse;
}
.walking-person .leg-left {
left: 20px;
}
.walking-person .leg-right {
left: 27px;
animation-direction: alternate;
}
@@keyframes lab-walk-path {
0% { transform: translateX(-54px); }
100% { transform: translateX(calc(min(360px, 70vw) + 54px)); }
}
@@keyframes limb-swing {
0% { transform: rotate(-24deg); }
100% { transform: rotate(24deg); }
}
.gauge-outer,
.gauge-inner,
.gauge-tick,
.needle-line {
fill: none;
stroke: #050505;
stroke-linecap: round;
}
.gauge-outer {
stroke-width: 16;
}
.gauge-inner {
stroke-width: 4;
}
.gauge-tick {
stroke-width: 7;
}
.gauge-label {
fill: #050505;
font-size: 24px;
font-weight: 800;
text-anchor: middle;
dominant-baseline: middle;
}
.gauge-brand {
fill: #050505;
font-size: 28px;
font-weight: 900;
letter-spacing: 4px;
text-anchor: middle;
}
.needle-line {
stroke-width: 9;
}
.gauge-needle {
transform-origin: 300px 260px;
animation: home-gauge-sweep 6.2s infinite cubic-bezier(.42, 0, .2, 1);
}
@@keyframes home-gauge-sweep {
0% { transform: rotate(-58deg); }
9% { transform: rotate(-12deg); }
18% { transform: rotate(43deg); }
31% { transform: rotate(8deg); }
44% { transform: rotate(68deg); }
58% { transform: rotate(-35deg); }
72% { transform: rotate(24deg); }
86% { transform: rotate(56deg); }
100% { transform: rotate(-58deg); }
}
</style>
@code {
private List<DashboardRow> _dashboardRows = new();
private bool _loading = true;
private bool _anyRunning;
protected override async Task OnInitializedAsync()
{
Orchestrator.OnExportStatusChanged += HandleStatusChanged;
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
using var db = await DbFactory.CreateDbContextAsync();
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
var logs = await db.ExportLogs
.GroupBy(l => l.SiteId)
.Select(g => g.OrderByDescending(l => l.Timestamp).First())
.ToListAsync();
_dashboardRows = sites.Select(s =>
{
var log = logs.FirstOrDefault(l => l.SiteId == s.Id);
return new DashboardRow
{
SiteId = s.Id,
Land = s.Land,
TSC = s.TSC,
Schema = s.Schema,
ServerName = s.HanaServer?.Name ?? "",
LastStatus = log?.Status ?? "",
RowCount = log?.RowCount ?? 0,
LastRun = log?.Timestamp,
DurationSeconds = log?.DurationSeconds ?? 0,
ErrorMessage = log?.ErrorMessage ?? ""
};
}).ToList();
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId));
_loading = false;
}
private async Task ExportAll()
{
_anyRunning = true;
_ = Task.Run(async () =>
{
await Orchestrator.ExportAllAsync();
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
});
Snackbar.Add("Export für alle Standorte gestartet", Severity.Info);
}
private void ExportSingle(int siteId)
{
_ = Task.Run(async () =>
{
await Orchestrator.ExportSiteByIdAsync(siteId);
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
});
Snackbar.Add("Export gestartet", Severity.Info);
}
private async void HandleStatusChanged()
{
await InvokeAsync(async () =>
{
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId));
StateHasChanged();
if (!_anyRunning)
{
await LoadDataAsync();
StateHasChanged();
}
});
}
public void Dispose()
{
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
}
private class DashboardRow
{
public int SiteId { get; set; }
public string Land { get; set; } = "";
public string TSC { get; set; } = "";
public string Schema { get; set; } = "";
public string ServerName { get; set; } = "";
public string LastStatus { get; set; } = "";
public int RowCount { get; set; }
public DateTime? LastRun { get; set; }
public double DurationSeconds { get; set; }
public string ErrorMessage { get; set; } = "";
}
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,610 @@
@page "/export-dashboard"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using System.Diagnostics
@using TrafagSalesExporter.Services
@inject IDashboardPageService DashboardPageActions
@inject ExportOrchestrationService Orchestrator
@inject TimerBackgroundService TimerService
@inject ISnackbar Snackbar
@inject IUiTextService UiText
@implements IDisposable
<PageTitle>@T("Export Dashboard", "Export dashboard")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Export Dashboard", "Export dashboard")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<div class="dashboard-header">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4" Class="dashboard-actions">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
OnClick="ExportAll" Disabled="_anyRunning">
@T("Alle exportieren", "Export all")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.TableView"
OnClick="ExportConsolidatedOnly" Disabled="_anyRunning">
@T("Zentrale Datei neu erzeugen", "Rebuild consolidated file")
</MudButton>
<MudText Typo="Typo.body1">
@if (TimerService.NextRun < DateTime.MaxValue)
{
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Small" Class="mr-1" />
@(string.Format(T("Naechster automatischer Lauf: {0}", "Next automatic run: {0}"), TimerService.NextRun.ToString("dd.MM.yyyy HH:mm")))
}
else
{
<MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" />
@T("Timer deaktiviert", "Timer disabled")
}
</MudText>
</MudStack>
<div class="dashboard-manometer">
<svg class="manometer-svg" viewBox="0 0 210 118" role="img" aria-label="Export activity manometer">
<path class="manometer-outer" d="M25 98 A80 80 0 0 1 185 98" />
<path class="manometer-inner" d="M47 98 A58 58 0 0 1 163 98" />
<line class="manometer-tick" x1="38" y1="98" x2="56" y2="98" />
<line class="manometer-tick" x1="61" y1="54" x2="74" y2="67" />
<line class="manometer-tick" x1="105" y1="34" x2="105" y2="52" />
<line class="manometer-tick" x1="149" y1="54" x2="136" y2="67" />
<line class="manometer-tick" x1="172" y1="98" x2="154" y2="98" />
<text class="manometer-label" x="43" y="89">0</text>
<text class="manometer-label" x="67" y="53">25</text>
<text class="manometer-label" x="105" y="28">50</text>
<text class="manometer-label" x="143" y="53">75</text>
<text class="manometer-label" x="167" y="89">100</text>
<text class="manometer-caption" x="105" y="113">EXPORT</text>
<g class="manometer-needle">
<line class="needle-line" x1="105" y1="98" x2="105" y2="38" />
</g>
<circle class="manometer-hub" cx="105" cy="98" r="11" />
</svg>
</div>
</div>
</MudPaper>
@if (_readinessWarnings.Count > 0)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense Class="mb-4">
<MudText Typo="Typo.body2">@T("Aktive Standorte sind noch nicht vollstaendig bereit:", "Active sites are not fully ready:")</MudText>
@foreach (var warning in _readinessWarnings)
{
<MudText Typo="Typo.caption">@warning</MudText>
}
</MudAlert>
}
@if (_consolidatedStale)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense Class="mb-4">
@T("Seit der letzten zentralen Excel wurde mindestens ein Standort neu exportiert. Bitte `Zentrale Datei neu erzeugen` ausfuehren, damit das Endexcel aktuell ist.",
"At least one site was exported after the last consolidated Excel. Please rebuild the consolidated file so the final Excel is current.")
</MudAlert>
}
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>@T("Basis", "Basis")</MudTh>
<MudTh>TSC</MudTh>
<MudTh>@T("Schema", "Schema")</MudTh>
<MudTh>@T("Server", "Server")</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Live-Status", "Live status")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
<MudTh>@T("Letzter Lauf", "Last run")</MudTh>
<MudTh>@T("Dauer", "Duration")</MudTh>
<MudTh>@T("Aktion", "Action")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>
<MudTooltip Text="@context.DataBasis">
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@GetDataBasisIcon(context.DataBasis)" Color="@GetDataBasisColor(context.DataBasis)" Size="Size.Small" />
<MudText Typo="Typo.caption">@context.DataBasis</MudText>
</MudStack>
</MudTooltip>
</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.ServerName</MudTd>
<MudTd>
@if (Orchestrator.IsExporting(context.SiteId))
{
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
<MudText Typo="Typo.caption">@Orchestrator.GetExportStatus(context.SiteId)</MudText>
}
else if (context.LastStatus == "OK")
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else if (context.LastStatus == "Error")
{
<MudTooltip Text="@context.ErrorMessage">
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Small" />
</MudTooltip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>
@if (!string.IsNullOrWhiteSpace(context.LiveMessage))
{
<MudTooltip Text="@context.LiveDetails">
<MudText Typo="Typo.caption" Style="max-width:360px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
@context.LiveMessage
</MudText>
</MudTooltip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
<MudTd>
<MudStack Row Spacing="1">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.FileDownload"
OnClick="() => ExportSingle(context.SiteId)"
Disabled="Orchestrator.IsExporting(context.SiteId)">
Export
</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
StartIcon="@Icons.Material.Filled.OpenInNew"
OnClick="() => OpenExportFile(context)"
Disabled="@(!context.HasOpenableFile || Orchestrator.IsExporting(context.SiteId))">
@T("Excel oeffnen", "Open Excel")
</MudButton>
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Datei", "Consolidated file")</MudText>
<MudTable Items="_consolidatedRows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Datei", "File")</MudTh>
<MudTh>Pfad</MudTh>
<MudTh>Letzte Änderung</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Aktion", "Action")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.DisplayPath</MudTd>
<MudTd>@(context.LastModified.HasValue ? context.LastModified.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>
@if (Orchestrator.IsConsolidatedExporting())
{
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
<MudText Typo="Typo.caption">@Orchestrator.GetConsolidatedExportStatus()</MudText>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
StartIcon="@Icons.Material.Filled.OpenInNew"
OnClick="() => OpenFile(context.FilePath)"
Disabled="@(!context.HasOpenableFile)">
@T("Excel oeffnen", "Open Excel")
</MudButton>
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Keine zentrale Excel-Datei gefunden.", "No consolidated Excel file found.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
<style>
.dashboard-header {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 24px;
align-items: center;
}
.dashboard-actions {
min-width: 0;
flex-wrap: wrap;
}
.dashboard-manometer {
justify-self: end;
width: 210px;
height: 118px;
background: #fff;
border: 1px solid #111;
border-radius: 6px;
overflow: hidden;
}
.manometer-svg {
display: block;
width: 100%;
height: 100%;
}
.manometer-outer,
.manometer-inner {
fill: none;
stroke: #111;
stroke-linecap: square;
}
.manometer-outer {
stroke-width: 8;
}
.manometer-inner {
stroke-width: 2;
}
.manometer-tick,
.needle-line {
stroke: #111;
stroke-width: 3;
stroke-linecap: square;
}
.manometer-needle {
transform-box: view-box;
transform-origin: 105px 98px;
animation: manometer-sweep 5.8s infinite cubic-bezier(.45, 0, .25, 1);
}
.manometer-hub {
fill: #111;
}
.manometer-label,
.manometer-caption {
fill: #111;
font-family: Arial, sans-serif;
text-anchor: middle;
dominant-baseline: middle;
user-select: none;
}
.manometer-label {
font-size: 10px;
font-weight: 600;
}
.manometer-caption {
font-size: 9px;
font-weight: 700;
letter-spacing: 0;
}
@@keyframes manometer-sweep {
0% { transform: rotate(-52deg); }
11% { transform: rotate(18deg); }
19% { transform: rotate(-8deg); }
33% { transform: rotate(63deg); }
48% { transform: rotate(4deg); }
61% { transform: rotate(38deg); }
74% { transform: rotate(-41deg); }
88% { transform: rotate(55deg); }
100% { transform: rotate(-52deg); }
}
@@media (max-width: 900px) {
.dashboard-header {
grid-template-columns: 1fr;
}
.dashboard-manometer {
justify-self: start;
}
}
</style>
@code {
private List<DashboardRow> _dashboardRows = new();
private List<ConsolidatedDashboardRow> _consolidatedRows = new();
private List<string> _readinessWarnings = new();
private bool _consolidatedStale;
private bool _loading = true;
private bool _anyRunning;
private CancellationTokenSource? _pollingCts;
protected override async Task OnInitializedAsync()
{
Orchestrator.OnExportStatusChanged += HandleStatusChanged;
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
var state = await DashboardPageActions.LoadAsync();
_dashboardRows = state.DashboardRows;
_consolidatedRows = state.ConsolidatedRows;
_readinessWarnings = state.ReadinessWarnings;
_consolidatedStale = state.IsConsolidatedStale;
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
_loading = false;
}
private async Task ExportAll()
{
if (_readinessWarnings.Count > 0)
{
Snackbar.Add(T("Es gibt aktive Standorte mit fehlender manueller Datei. Bitte Warnung im Dashboard pruefen.",
"There are active sites with missing manual files. Please check the dashboard warning."), Severity.Warning);
}
_anyRunning = true;
await LoadDataAsync();
StartPolling();
_ = Task.Run(async () =>
{
try
{
await Orchestrator.ExportAllAsync();
await InvokeAsync(() =>
Snackbar.Add(T("Export fuer alle Standorte beendet", "Export completed for all sites"), Severity.Success));
}
catch (Exception ex)
{
await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Export fuer alle Standorte fehlgeschlagen: {0}", "Export for all sites failed: {0}"), FormatException(ex)), Severity.Error));
}
finally
{
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
}
});
Snackbar.Add(T("Export fuer alle Standorte gestartet", "Export started for all sites"), Severity.Info);
}
private async Task ExportConsolidatedOnly()
{
_anyRunning = true;
await LoadDataAsync();
StartPolling();
_ = Task.Run(async () =>
{
try
{
var filePath = await Orchestrator.ExportConsolidatedOnlyAsync();
if (!string.IsNullOrWhiteSpace(filePath))
{
await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Zentrale Datei erzeugt: {0}", "Consolidated file created: {0}"), filePath), Severity.Success));
}
else
{
await InvokeAsync(() =>
Snackbar.Add(T("Zentrale Datei konnte nicht erzeugt werden. Details stehen in den Logs.", "Consolidated file could not be created. Details are in the logs."), Severity.Warning));
}
}
catch (Exception ex)
{
await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Zentrale Datei fehlgeschlagen: {0}", "Consolidated file failed: {0}"), FormatException(ex)), Severity.Error));
}
finally
{
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
}
});
Snackbar.Add(T("Zentrale Datei wird erzeugt", "Building consolidated file"), Severity.Info);
}
private void ExportSingle(int siteId)
{
_anyRunning = true;
_ = InvokeAsync(async () => await LoadDataAsync());
StartPolling();
_ = Task.Run(async () =>
{
try
{
var result = await Orchestrator.ExportSiteByIdAsync(siteId);
if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath))
{
await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Export gespeichert: {0}", "Export saved: {0}"), result.FilePath), Severity.Success));
await InvokeAsync(() =>
Snackbar.Add(T("Die zentrale Excel ist danach noch nicht automatisch aktualisiert. Bitte `Zentrale Datei neu erzeugen` starten.",
"The consolidated Excel is not automatically updated after this. Please rebuild the consolidated file."), Severity.Info));
}
else if (result?.Log.Status == "Error" && !string.IsNullOrWhiteSpace(result.Log.ErrorMessage))
{
await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), result.Log.ErrorMessage), Severity.Error));
}
}
catch (Exception ex)
{
await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), FormatException(ex)), Severity.Error));
}
finally
{
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
}
});
Snackbar.Add(T("Export gestartet", "Export started"), Severity.Info);
}
private async void HandleStatusChanged()
{
await InvokeAsync(async () =>
{
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting() || _dashboardRows.Count == 0;
if (_anyRunning)
{
StartPolling();
await RefreshLiveDataAsync();
StateHasChanged();
return;
}
StopPolling();
await LoadDataAsync();
StateHasChanged();
});
}
public void Dispose()
{
StopPolling();
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
}
private void OpenExportFile(DashboardRow row)
{
OpenFile(row.FilePath);
}
private void OpenFile(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
{
Snackbar.Add(T("Exportdatei nicht gefunden.", "Export file not found."), Severity.Warning);
return;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = filePath,
UseShellExecute = true
});
}
catch (Exception ex)
{
Snackbar.Add(string.Format(T("Datei konnte nicht geoeffnet werden: {0}", "Could not open file: {0}"), ex.Message), Severity.Error);
}
}
private void StartPolling()
{
if (_pollingCts is not null && !_pollingCts.IsCancellationRequested)
return;
_pollingCts = new CancellationTokenSource();
_ = PollDashboardAsync(_pollingCts.Token);
}
private void StopPolling()
{
_pollingCts?.Cancel();
_pollingCts?.Dispose();
_pollingCts = null;
}
private async Task PollDashboardAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));
try
{
while (await timer.WaitForNextTickAsync(cancellationToken))
{
var anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
if (!anyRunning)
{
await InvokeAsync(async () =>
{
_anyRunning = false;
await LoadDataAsync();
StateHasChanged();
});
StopPolling();
break;
}
await InvokeAsync(async () =>
{
_anyRunning = true;
await RefreshLiveDataAsync();
StateHasChanged();
});
}
}
catch (OperationCanceledException)
{
}
}
private Task RefreshLiveDataAsync()
{
foreach (var row in _dashboardRows)
{
if (!Orchestrator.IsExporting(row.SiteId))
continue;
row.LiveMessage = Orchestrator.GetExportStatus(row.SiteId);
row.LiveDetails = string.Empty;
}
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
return Task.CompletedTask;
}
private static string FormatException(Exception ex)
=> ex.InnerException is null ? ex.Message : $"{ex.Message} Inner: {ex.InnerException.Message}";
private static string GetDataBasisIcon(string dataBasis)
{
if (dataBasis.Contains("Excel", StringComparison.OrdinalIgnoreCase))
return Icons.Material.Filled.TableView;
if (dataBasis.Contains("CSV", StringComparison.OrdinalIgnoreCase) ||
dataBasis.Contains("Datei", StringComparison.OrdinalIgnoreCase))
return Icons.Material.Filled.Description;
if (dataBasis.Contains("SAP", StringComparison.OrdinalIgnoreCase))
return Icons.Material.Filled.CloudSync;
if (dataBasis.Contains("Server", StringComparison.OrdinalIgnoreCase))
return Icons.Material.Filled.Storage;
return Icons.Material.Filled.Source;
}
private static Color GetDataBasisColor(string dataBasis)
{
if (dataBasis.Contains("Excel", StringComparison.OrdinalIgnoreCase))
return Color.Success;
if (dataBasis.Contains("CSV", StringComparison.OrdinalIgnoreCase) ||
dataBasis.Contains("Datei", StringComparison.OrdinalIgnoreCase))
return Color.Info;
if (dataBasis.Contains("SAP", StringComparison.OrdinalIgnoreCase))
return Color.Primary;
if (dataBasis.Contains("Server", StringComparison.OrdinalIgnoreCase))
return Color.Secondary;
return Color.Default;
}
}
@code {
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,217 @@
@page "/finance-cockpit/vergleich"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IFinanceReconciliationService FinanceReconciliationService
@inject IUiTextService UiText
<PageTitle>@T("Soll/Ist Vergleich", "Actual/reference comparison")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Soll/Ist Vergleich", "Actual/reference comparison")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
<div>
<MudText Typo="Typo.h6">@T("Net Sales Actuals 2025 Referenz", "Net sales actuals 2025 reference")</MudText>
<MudText Typo="Typo.caption">@T("Verbindliche Finance-Sicht aus der aktuellen zentralen Datenquelle", "Authoritative finance view from the current central data source")</MudText>
</div>
<MudSpacer />
<MudButton Variant="@(_hideRowsWithoutActual ? Variant.Filled : Variant.Outlined)"
Color="Color.Primary"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.FilterAlt"
OnClick="ToggleActualFilter">
@T("Ohne Ist", "Without empty actuals")
</MudButton>
<MudText Typo="Typo.caption">
@string.Format(T("{0:N0}/{1:N0} Zeilen", "{0:N0}/{1:N0} rows"), FilteredNetSalesReferenceRows.Count, _netSalesReferenceRows.Count)
</MudText>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh"
OnClick="LoadAsync" Disabled="_loading">
@(_loading ? T("Lade...", "Loading...") : T("Aktualisieren", "Refresh"))
</MudButton>
</MudStack>
<MudTable Items="FilteredNetSalesReferenceRows" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>@T("Ampel", "Status")</MudTh>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>@T("Ist 2025", "Actual 2025")</MudTh>
<MudTh>@T("Referenz", "Reference")</MudTh>
<MudTh>@T("Differenz", "Difference")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Berechnung", "Calculation")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
<MudTh>@T("Varianten", "Variants")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@StatusColor(context.Status)" Variant="Variant.Filled">
@StatusText(context.Status)
</MudChip>
</MudTd>
<MudTd>
<MudText Typo="Typo.body2">@context.Label</MudText>
<MudText Typo="Typo.caption">@context.Key</MudText>
</MudTd>
<MudTd>@FormatAmount(context.ActualValue)</MudTd>
<MudTd>@FormatAmount(context.ReferenceValue)</MudTd>
<MudTd>@FormatAmount(context.Difference)</MudTd>
<MudTd>@FormatCurrency(context)</MudTd>
<MudTd>
<MudText Typo="Typo.body2">@(string.IsNullOrWhiteSpace(context.ValueField) ? "-" : context.ValueField)</MudText>
<MudText Typo="Typo.caption">@BuildCalculationHint(context)</MudText>
</MudTd>
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
<MudTd>
@if (context.Candidates.Count > 0)
{
<details>
<summary>@context.Candidates.Count @T("Varianten anzeigen", "show variants")</summary>
<table class="finance-variant-table">
<thead>
<tr>
<th>@T("Abgrenzung", "Scope")</th>
<th>@T("Waehrung", "Currency")</th>
<th>@T("Wert", "Value")</th>
<th>@T("Diff.", "Diff.")</th>
<th>@T("IC", "IC")</th>
<th>@T("Diff ohne IC", "Diff excl. IC")</th>
</tr>
</thead>
<tbody>
@foreach (var candidate in context.Candidates)
{
<tr class="@(candidate.IsPreferred ? "preferred-variant" : string.Empty)">
<td>@candidate.Label</td>
<td>@candidate.Currency</td>
<td class="num">@FormatAmount(candidate.Value)</td>
<td class="num">@FormatAmount(candidate.Difference)</td>
<td class="num">@FormatAmount(candidate.IntercompanyValue)</td>
<td class="num">@FormatAmount(candidate.DifferenceExcludingIntercompany)</td>
</tr>
}
</tbody>
</table>
</details>
}
else
{
<span>-</span>
}
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Keine Referenzdaten fuer aktive Standorte gefunden.", "No reference data found for active sites.")</MudText>
</NoRecordsContent>
</MudTable>
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mt-3">
@T("Diese Seite nutzt dieselbe FinanceReconciliationService-Logik wie das lokale Testprogramm. Vergleich: Jahr 2025 aus Buchungsdatum, sonst Invoice Date, sonst Extraction Date. Das Summenfeld wird automatisch aus Sales Price/Value, DocTotalFC - VatSumFC oder DocTotal - VatSum gewaehlt; Belegkopfwerte werden pro DocEntry nur einmal gezaehlt. IC-Abzug ist eine Diagnose fuer den aktuellen Abgleich und veraendert die Originaldaten nicht.", "This page uses the same FinanceReconciliationService logic as the local test program. Comparison: year 2025 from posting date, otherwise invoice date, otherwise extraction date. The value field is selected automatically from Sales Price/Value, DocTotalFC - VatSumFC, or DocTotal - VatSum; document header values are counted only once per DocEntry. IC deduction is a diagnostic value for the current reconciliation and does not change the original data.")
</MudAlert>
</MudPaper>
<style>
.finance-variant-table {
border-collapse: collapse;
margin-top: 8px;
min-width: 720px;
font-size: 0.82rem;
}
.finance-variant-table th,
.finance-variant-table td {
border: 1px solid var(--mud-palette-lines-default);
padding: 4px 6px;
vertical-align: top;
}
.finance-variant-table th {
background: var(--mud-palette-background-grey);
font-weight: 600;
text-align: left;
}
.finance-variant-table .num {
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.preferred-variant {
background: rgba(33, 150, 243, 0.08);
}
</style>
@code {
private List<NetSalesReferenceRow> _netSalesReferenceRows = new();
private bool _hideRowsWithoutActual = true;
private bool _loading = true;
private List<NetSalesReferenceRow> FilteredNetSalesReferenceRows
=> _hideRowsWithoutActual
? _netSalesReferenceRows.Where(row => row.ActualValue.HasValue).ToList()
: _netSalesReferenceRows;
private void ToggleActualFilter()
{
_hideRowsWithoutActual = !_hideRowsWithoutActual;
}
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
_netSalesReferenceRows = await FinanceReconciliationService.BuildNetSalesReferenceRowsAsync(2025);
_loading = false;
}
private static string FormatAmount(decimal? value)
=> value.HasValue ? value.Value.ToString("N2") : "-";
private static string FormatCurrency(NetSalesReferenceRow row)
{
if (!string.IsNullOrWhiteSpace(row.ActualCurrency))
return row.ReferenceCurrency == "LC"
? $"{row.ActualCurrency} / Soll LC"
: row.ActualCurrency;
return string.IsNullOrWhiteSpace(row.Currencies) ? "-" : row.Currencies;
}
private string BuildCalculationHint(NetSalesReferenceRow row)
{
if (row.Key.Equals("UK", StringComparison.OrdinalIgnoreCase))
return T("Sage Netto in GBP; Credit Notes negativ; Soll ist Local Currency.", "Sage net in GBP; credit notes negative; reference is local currency.");
if (row.Key.Equals("ES", StringComparison.OrdinalIgnoreCase))
return T("Sage ImporteNeto; REC/Credit Notes negativ; Zuschlaege/Nebenkosten noch pruefen.", "Sage ImporteNeto; REC/credit notes negative; surcharges/charges still to check.");
if (row.Key.Equals("IT", StringComparison.OrdinalIgnoreCase))
return T("Bestaetigte IT-Regel: Trafag Italia ausgeschlossen; doppelte Zeilen ohne Supplier country nur einmal.", "Confirmed IT rule: Trafag Italia excluded; duplicate rows without supplier country counted once.");
if (row.Key.Equals("DE", StringComparison.OrdinalIgnoreCase))
return T("Alphaplan Excel; Finance-Regeln gemäss Deutschland-Rueckmeldung: Weiterberechnungen ausgeschlossen, GS negativ, GS2510095 2024.", "Alphaplan Excel; finance rules per Germany response: recharges excluded, credit notes negative, GS2510095 in 2024.");
if (row.Key.Equals("FR", StringComparison.OrdinalIgnoreCase) ||
row.Key.Equals("IN", StringComparison.OrdinalIgnoreCase) ||
row.Key.Equals("US", StringComparison.OrdinalIgnoreCase))
return T("Passt gegen Soll; Sales Price/Value ist bevorzugte Variante.", "Matches reference; Sales Price/Value is the preferred variant.");
return row.ReferenceCurrency == "LC"
? T("Vergleich gegen Local Currency Referenz.", "Compared against local currency reference.")
: T("Vergleich gegen Check-/Sollwert.", "Compared against check/reference value.");
}
private Color StatusColor(string status)
=> status == "OK" ? Color.Success
: status == "Pruefen" ? Color.Warning
: Color.Default;
private string StatusText(string status)
=> status == "OK" ? "OK"
: status == "Pruefen" ? T("Pruefen", "Check")
: T("Keine Daten", "No data");
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,168 @@
@page "/finance-rules"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using System.Reflection
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IFinanceRulesPageService FinanceRulesPageActions
@inject ISnackbar Snackbar
@inject IUiTextService UiText
<PageTitle>@T("Finance Regeln", "Finance rules")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Finance Regeln", "Finance rules")</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
@T("Diese Regeln wirken nur auf die Finance-Sicht im zentralen Excel und im Abgleich. Rohdaten und Spaltenmapping bleiben unveraendert.",
"These rules only affect the finance view in the central Excel and reconciliation. Raw data and column mappings remain unchanged.")
</MudAlert>
<MudStack Row Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="AddRule">
@T("Regel hinzufuegen", "Add rule")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAllAsync">
@T("Alle speichern", "Save all")
</MudButton>
<MudButton Variant="Variant.Text" Color="Color.Default" StartIcon="@Icons.Material.Filled.Restore" OnClick="LoadDefaults">
@T("Default-Regeln laden", "Load default rules")
</MudButton>
</MudStack>
<MudTable Items="_rules" Dense Hover Striped Breakpoint="Breakpoint.Md">
<HeaderContent>
<MudTh>Aktiv</MudTh>
<MudTh>Land</MudTh>
<MudTh>Jahr</MudTh>
<MudTh>Regeltyp</MudTh>
<MudTh>Feld</MudTh>
<MudTh>Vergleich</MudTh>
<MudTh>Wert</MudTh>
<MudTh>Sort</MudTh>
<MudTh>Notiz</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudCheckBox @bind-Value="context.IsActive" /></MudTd>
<MudTd><MudTextField @bind-Value="context.ScopeKey" Placeholder="DE" Style="width:80px" /></MudTd>
<MudTd><MudNumericField T="int?" @bind-Value="context.Year" Style="width:90px" /></MudTd>
<MudTd>
<MudSelect T="string" @bind-Value="context.RuleType" Dense>
@foreach (var type in FinanceRuleTypes.All)
{
<MudSelectItem Value="@type">@GetRuleTypeLabel(type)</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" @bind-Value="context.FieldName" Dense Disabled="@UsesNoField(context)">
<MudSelectItem Value="@string.Empty">-</MudSelectItem>
@foreach (var field in _recordFields)
{
<MudSelectItem Value="@field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" @bind-Value="context.MatchType" Dense>
@foreach (var type in FinanceRuleMatchTypes.All)
{
<MudSelectItem Value="@type">@GetMatchTypeLabel(type)</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd><MudTextField @bind-Value="context.MatchValue" Disabled="@UsesNoMatchValue(context)" /></MudTd>
<MudTd><MudNumericField T="int" @bind-Value="context.SortOrder" Style="width:80px" /></MudTd>
<MudTd><MudTextField @bind-Value="context.Notes" /></MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
OnClick="() => RemoveRule(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
@code {
private readonly string[] _recordFields = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(property => property.Name)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
private List<FinanceRule> _rules = [];
protected override async Task OnInitializedAsync()
{
_rules = await FinanceRulesPageActions.LoadAsync();
}
private void AddRule()
{
_rules.Add(new FinanceRule
{
ScopeKey = "DE",
RuleType = FinanceRuleTypes.Exclude,
FieldName = nameof(SalesRecord.CustomerName),
MatchType = FinanceRuleMatchTypes.Contains,
SortOrder = _rules.Count == 0 ? 100 : _rules.Max(rule => rule.SortOrder) + 10,
IsActive = true
});
}
private void RemoveRule(FinanceRule rule) => _rules.Remove(rule);
private void LoadDefaults()
{
_rules = FinanceRuleEngine.CreateDefaultRules()
.Select(rule => new FinanceRule
{
ScopeKey = rule.ScopeKey,
Year = rule.Year,
RuleType = rule.RuleType,
FieldName = rule.FieldName,
MatchType = rule.MatchType,
MatchValue = rule.MatchValue,
Notes = rule.Notes,
SortOrder = rule.SortOrder,
IsActive = rule.IsActive
})
.ToList();
}
private async Task SaveAllAsync()
{
_rules = await FinanceRulesPageActions.SaveAllAsync(_rules);
Snackbar.Add(T("Finance-Regeln gespeichert.", "Finance rules saved."), Severity.Success);
}
private static bool UsesNoField(FinanceRule rule)
=> rule.RuleType == FinanceRuleTypes.ForceYear ||
rule.MatchType == FinanceRuleMatchTypes.Always;
private static bool UsesNoMatchValue(FinanceRule rule)
=> rule.MatchType is FinanceRuleMatchTypes.Always or FinanceRuleMatchTypes.IsBlank;
private string GetRuleTypeLabel(string type)
=> type switch
{
FinanceRuleTypes.Exclude => T("Ausschliessen", "Exclude"),
FinanceRuleTypes.NegateAmount => T("Betrag negativ", "Negate amount"),
FinanceRuleTypes.ForceYear => T("Jahr erzwingen", "Force year"),
FinanceRuleTypes.DeduplicateBlankSupplierCountry => T("Duplikate ohne Supplier Country", "Deduplicate blank supplier country"),
_ => type
};
private string GetMatchTypeLabel(string type)
=> type switch
{
FinanceRuleMatchTypes.Always => T("Immer", "Always"),
FinanceRuleMatchTypes.Equal => T("gleich", "equals"),
FinanceRuleMatchTypes.Contains => T("enthaelt", "contains"),
FinanceRuleMatchTypes.StartsWith => T("beginnt mit", "starts with"),
FinanceRuleMatchTypes.IsBlank => T("ist leer", "is blank"),
_ => type
};
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,275 @@
@page "/finance-cockpit/schulung"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@inject TrafagSalesExporter.Services.IUiTextService UiText
<PageTitle>@T("Finance Schulung", "Finance training")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-2">@T("Finance Schulung", "Finance training")</MudText>
<MudText Typo="Typo.body1" Class="mb-4 training-lead">
Ausführliche Anwenderunterlage für Finance-Keyuser, CFO/Finance-Leitung und Administratoren. Die Schulung beschreibt den
produktiven Ablauf vom manuellen Import bis zur zentralen Excel-Datei, inklusive Finance Summary, Finance Details und Soll/Ist Vergleich.
</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudGrid>
<MudItem xs="12" md="7">
<img class="training-hero-image" src="training/finance_cockpit_preview.png" alt="Finance Cockpit Vorschau" />
</MudItem>
<MudItem xs="12" md="5">
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
Verbindlich ist die Finance-Sicht: Finance Summary, Finance Details und Soll/Ist Vergleich müssen zusammen plausibel sein.
</MudAlert>
<MudSimpleTable Dense Hover>
<thead>
<tr><th>Rolle</th><th>Aufgabe</th></tr>
</thead>
<tbody>
<tr><td>Finance Keyuser</td><td>Importe bereitstellen, Standorte exportieren, Summen prüfen</td></tr>
<tr><td>CFO/Finance Leitung</td><td>Soll/Ist-Abgleich freigeben und Abweichungen entscheiden</td></tr>
<tr><td>Admin</td><td>Standorte, Mapping, Regeln, Settings und SharePoint konfigurieren</td></tr>
</tbody>
</MudSimpleTable>
</MudItem>
</MudGrid>
</MudPaper>
<MudTabs Rounded Border>
<MudTabPanel Text="Prozess">
<TrainingSection Title="1. Ziel der Finance-Sicht">
<p>Das Finance Cockpit bereitet Sales- und Finance-Daten so auf, dass Länder, Systeme und unterschiedliche Spaltenlogiken in
einer zentralen Sicht vergleichbar werden. Die Rohdaten bleiben nachvollziehbar, die Finance-Spalten liefern die verbindliche
Abgrenzung für Summen und Soll/Ist-Vergleich.</p>
<ul>
<li><strong>Finance Summary</strong> zeigt die aggregierten Summen nach Jahr, Land und Währung.</li>
<li><strong>Finance Details</strong> zeigt die Detailzeilen, die zu diesen Summen führen.</li>
<li><strong>Sales</strong> enthält die breitere Rohdatensicht inklusive Finance-Spaltenblock.</li>
<li><strong>Soll/Ist Vergleich</strong> prüft die App-Daten gegen die gepflegten Referenzwerte.</li>
</ul>
</TrainingSection>
<TrainingSection Title="2. Bedienreihenfolge im Tagesgeschäft">
<div class="training-flow">
<div><span>1</span><strong>Dateien bereitstellen</strong><small>SharePoint, lokaler Pfad oder Quellsystem</small></div>
<div><span>2</span><strong>Standort exportieren</strong><small>CentralSalesRecords je Standort ersetzen</small></div>
<div><span>3</span><strong>Zentrale Excel erzeugen</strong><small>Sales, Summary und Details neu schreiben</small></div>
<div><span>4</span><strong>Summen prüfen</strong><small>Summary gegen Details aggregieren</small></div>
<div><span>5</span><strong>Soll/Ist freigeben</strong><small>Abweichungen dokumentieren</small></div>
</div>
<p>Nach jeder neuen Datei muss zuerst der betroffene Standort exportiert werden. Erst danach ist die zentrale Excel-Datei aktuell.</p>
</TrainingSection>
<TrainingSection Title="3. Prozessgrafik und Systembild">
<MudGrid>
<MudItem xs="12" md="6">
<img class="training-doc-image" src="training/keyuser-prozess.svg" alt="Keyuser Prozess" />
</MudItem>
<MudItem xs="12" md="6">
<img class="training-doc-image" src="training/systemarchitektur.svg" alt="Systemarchitektur" />
</MudItem>
</MudGrid>
</TrainingSection>
</MudTabPanel>
<MudTabPanel Text="Importe">
<TrainingSection Title="4. Manuelle Importe und Delta-Regeln">
<p>Manuelle Importe ersetzen beim Standortexport den aktuellen Datenstand dieses Standorts in <code>CentralSalesRecords</code>.
Deshalb darf eine Delta-Datei nur dann verwendet werden, wenn die App sie zusammen mit einer Basisdatei liest.</p>
<MudSimpleTable Dense Hover>
<thead><tr><th>Land</th><th>Quelle</th><th>Lieferlogik</th><th>Konsequenz</th></tr></thead>
<tbody>
<tr><td>UK / England</td><td>Sage Excel/CSV im Ordner UK_B1</td><td>Jahresdatei plus Deltas</td><td>Delta-fähig, weil Basis und Deltas zusammen gelesen werden</td></tr>
<tr><td>Spanien</td><td>Sage CSV / Manual Excel</td><td>Vollfile erforderlich</td><td>Keine Delta-Dateien verwenden</td></tr>
<tr><td>Deutschland</td><td>Alphaplan Excel</td><td>Vollfile/Jahresfile erforderlich</td><td>Keine Delta-Dateien verwenden</td></tr>
<tr><td>CH/AT</td><td>SAP OData</td><td>Quellsystem wird neu gelesen</td><td>Kein manueller Delta-Prozess</td></tr>
<tr><td>FR/IT/US/IN</td><td>HANA/SAP B1/Sage</td><td>direkte Quelle</td><td>Standortdaten werden aus Quelle aufgebaut</td></tr>
</tbody>
</MudSimpleTable>
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Class="mt-3">
Spanien und Deutschland müssen vollständige Dateien liefern. Eine einzelne Delta-Datei würde technisch den bisherigen Stand ersetzen und damit unvollständig werden.
</MudAlert>
</TrainingSection>
<TrainingSection Title="5. Standortexport verstehen">
<p>Der Standortexport ist der Schritt, der Rohdaten aus der jeweiligen Quelle in die zentrale Datenbank schreibt. Er liest nicht nur
eine Datei, sondern wendet auch Mapping, Transformationen und Finance-Regeln an.</p>
<ul>
<li>Bei SAP/HANA wird die definierte Quelle abgefragt.</li>
<li>Bei manuellen Excel-/CSV-Importen wird die hinterlegte Datei oder der hinterlegte Ordner verwendet.</li>
<li>Bestehende Zeilen des Standorts werden ersetzt, damit keine veralteten Dubletten stehen bleiben.</li>
<li>Fehler stehen in den Logs und müssen vor der zentralen Excel-Erzeugung geklärt werden.</li>
</ul>
</TrainingSection>
<TrainingSection Title="6. Zentrale Excel-Datei">
<MudSimpleTable Dense Hover>
<thead><tr><th>Blatt</th><th>Zweck</th><th>Prüfung</th></tr></thead>
<tbody>
<tr><td>Sales</td><td>Rohdaten plus normalisierte Spalten</td><td>Land, TSC, Beleg, Kunde, Wert, Währung</td></tr>
<tr><td>Finance Summary</td><td>verbindliche Aggregation</td><td>Jahr, Land, Währung, Net Sales Actual</td></tr>
<tr><td>Finance Details</td><td>Detailzeilen zur Summary</td><td>Summe je Land muss Summary ergeben</td></tr>
<tr><td>Finance Filter Hilfe</td><td>Hinweise zur Excel-Prüfung</td><td>Filter und Pivot-Anleitung</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
</MudTabPanel>
<MudTabPanel Text="Abgleich">
<TrainingSection Title="7. Finance Summary lesen">
<p>Finance Summary ist die kompakte Sicht für Finance. Sie enthält nur Zeilen, die nach Finance-Regel eingeschlossen sind.
Entscheidend sind Finance-Jahr, Country Key, Währung und Net Sales Actual.</p>
<ul>
<li>Immer zuerst nach Jahr 2025 filtern, wenn der 2025-Abgleich geprüft wird.</li>
<li>Country Key ist die Finance-Länderlogik, nicht zwingend nur der sichtbare Rohdaten-Ländername.</li>
<li>Währung muss zum Referenzwert passen, zum Beispiel GBP für UK oder INR für Indien.</li>
<li>Included Rows zeigt, wie viele Detailzeilen in die Summe geflossen sind.</li>
</ul>
</TrainingSection>
<TrainingSection Title="8. Finance Details gegen Summary prüfen">
<p>Finance Details ist das Kontrollblatt für Rückfragen. Die Summe über <code>Net Sales Actual</code> in Finance Details muss
je Jahr, Land und Währung exakt mit Finance Summary übereinstimmen.</p>
<MudSimpleTable Dense Hover>
<thead><tr><th>Prüfung</th><th>Vorgehen</th><th>Erwartung</th></tr></thead>
<tbody>
<tr><td>Landessumme</td><td>Details nach Year, Country Key und Currency aggregieren</td><td>identisch zu Finance Summary</td></tr>
<tr><td>Einzelbeleg</td><td>Invoice Number, Position und Document Entry suchen</td><td>Beleg nachvollziehbar</td></tr>
<tr><td>Ausschluss</td><td>Sales-Blatt mit Finance Include vergleichen</td><td>Regelgrund sichtbar</td></tr>
<tr><td>Dubletten</td><td>Belegkopfwerte und Positionen prüfen</td><td>B1-Kopflogik wird nicht doppelt gezählt</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
<TrainingSection Title="9. Soll/Ist Vergleich">
<p>Der Soll/Ist Vergleich nutzt dieselbe Finance-Reconciliation-Logik wie die zentrale Finance-Sicht. Er ist die Seite für
Freigabe, Abweichungsanalyse und Status je Land.</p>
<MudSimpleTable Dense Hover>
<thead><tr><th>Status</th><th>Bedeutung</th><th>Aktion</th></tr></thead>
<tbody>
<tr><td>OK</td><td>Istwert passt gegen Referenz innerhalb Toleranz</td><td>für Freigabe vormerken</td></tr>
<tr><td>Prüfen</td><td>Differenz vorhanden oder Regel noch nicht final</td><td>Details und Länderregel prüfen</td></tr>
<tr><td>Keine Daten</td><td>Kein aktueller Stand in CentralSalesRecords</td><td>Standortexport oder Import prüfen</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
<TrainingSection Title="10. Länderlogik kompakt">
<MudSimpleTable Dense Hover>
<thead><tr><th>Land</th><th>Aktuelle Hauptlogik</th><th>Hinweis</th></tr></thead>
<tbody>
<tr><td>DE</td><td>Alphaplan NettoPreisGesamtX, GS negativ, Finance-Ausschlüsse</td><td>Vollfile erforderlich</td></tr>
<tr><td>ES</td><td>Sage ImporteNeto, REC/Credit negativ</td><td>Vollfile erforderlich</td></tr>
<tr><td>IT</td><td>B1 Positions-Netto, Trafag Italia ausgeschlossen, Dublettenlogik</td><td>Detailprüfung wichtig</td></tr>
<tr><td>UK</td><td>Sage Netto in GBP, Credit Notes negativ</td><td>Basis plus Deltas möglich</td></tr>
<tr><td>FR/US</td><td>B1 Sales Price/Value bevorzugt</td><td>gegen CheckValue prüfen</td></tr>
<tr><td>IN</td><td>Hauswährung INR</td><td>Referenz in Local Currency</td></tr>
<tr><td>CH/AT</td><td>SAP OData NetwrHc</td><td>AT hat Referenz, CH aktuell ohne Sollwert</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
</MudTabPanel>
<MudTabPanel Text="Spartenanalyse">
<TrainingSection Title="11. Spartenanalyse oeffnen">
<p>Die Spartenanalyse liegt unter <strong>Management Analyse</strong>. Im linken Menue kann Management Analyse aufgeklappt werden.
Dort fuehren die Punkte <strong>Sparten-Finanzanalyse</strong> und <strong>Zentrale Spartenzuordnung</strong> direkt in die passende Sicht.</p>
<ul>
<li><strong>Sparten-Finanzanalyse</strong> zeigt Umsatz, Anteil, Materialien, Zeilen und Laender nach Produktspartenlogik.</li>
<li><strong>Zentrale Spartenzuordnung</strong> prueft lokale Materialnummern gegen die fuehrende TR-AG-Referenz.</li>
<li>Die lokalen ERP-Produktsparten werden fuer diese Fuehrungssicht nicht verwendet; massgebend ist die TR-AG-Referenz aus SAP.</li>
</ul>
</TrainingSection>
<TrainingSection Title="12. Gruppierung, Top 10 und Laender">
<MudSimpleTable Dense Hover>
<thead><tr><th>Funktion</th><th>Zweck</th><th>Pruefung</th></tr></thead>
<tbody>
<tr><td>Gruppierung PAPH1 Detail</td><td>Feinste Sicht auf einzelne Produkt-Hierarchie-Codes</td><td>Geeignet fuer Detailanalyse und auffaellige Einzelgruppen</td></tr>
<tr><td>Gruppierung Produktfamilie</td><td>Fasst mehrere PAPH1-Zeilen zusammen, z.B. Gas Density Monitor</td><td>Geeignet fuer Managementsicht und Portfolioanalyse</td></tr>
<tr><td>Gruppierung Produktsparte</td><td>Verdichtet auf die oberste Spartenebene</td><td>Geeignet fuer schnelle Umsatzverteilung nach Sparten</td></tr>
<tr><td>Top 10 anzeigen</td><td>Reduziert die Tabelle auf die groessten Umsatzbloecke</td><td>Hilft, die wichtigsten Sparten ohne lange Detailliste zu beurteilen</td></tr>
<tr><td>Flaggen bei Laendern</td><td>Zeigt Landkuerzel optisch schneller erkennbar an</td><td>Bei mehreren Laendern werden die Laender kommasepariert angezeigt</td></tr>
</tbody>
</MudSimpleTable>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mt-3">
Die Umsaetze und Anteile werden je Gruppierung neu aggregiert. Top 10 filtert nur die Anzeige, nicht die Berechnungsbasis.
</MudAlert>
</TrainingSection>
<TrainingSection Title="13. Sparten-Icons lesen">
<p>Die Icons neben der Produktsparte sind eine visuelle Orientierung. Sie werden aus Sparten-, Familien- und PAPH1-Texten abgeleitet
und aendern keine Zahlen oder Zuordnung.</p>
<MudSimpleTable Dense Hover>
<thead><tr><th>Textmuster</th><th>Icon</th><th>Bedeutung</th></tr></thead>
<tbody>
<tr><td>Gas / Density</td><td>Sensors</td><td>Gas-Density-Produkte</td></tr>
<tr><td>Pressure / Druck</td><td>Compress</td><td>Druckprodukte</td></tr>
<tr><td>Temp / Thermostat</td><td>DeviceThermostat</td><td>Temperatur und Thermostate</td></tr>
<tr><td>Switch / Schalter</td><td>ToggleOn</td><td>Schalterbasierte Produkte</td></tr>
<tr><td>Access / Zubehoer</td><td>Extension</td><td>Zubehoer</td></tr>
<tr><td>UNASS / Nicht zugeordnet</td><td>HelpOutline</td><td>TR-AG-Referenz vorhanden, aber ohne gueltige Sparte</td></tr>
<tr><td>Sonstige Texte</td><td>Category</td><td>Keine spezifische Icon-Regel getroffen</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
<TrainingSection Title="14. Interpretation fuer Finance">
<ul>
<li><strong>Zugeordnet</strong>: Material wurde im TR-AG-Stamm gefunden und hat eine verwertbare Sparteninformation.</li>
<li><strong>Nicht zugeordnet</strong>: Material wurde gefunden, aber die TR-AG-Referenz ist unassigned oder leer.</li>
<li><strong>Nicht im TR-AG-Stamm</strong>: Lokale Materialnummer konnte nicht gegen die TR-AG-Referenz gematcht werden.</li>
<li><strong>Material fehlt</strong>: In der Finance-Zeile fehlt die Materialnummer; diese Zeilen koennen nicht zugeordnet werden.</li>
</ul>
<p>Fuer Finanzfragen ist die wichtigste Sicht zuerst die aggregierte Produktfamilie oder Produktsparte. PAPH1 Detail wird genutzt,
wenn eine Abweichung oder ein auffaelliger Umsatzblock genauer erklaert werden muss.</p>
</TrainingSection>
</MudTabPanel>
<MudTabPanel Text="Fehler & Freigabe">
<TrainingSection Title="15. Typische Fehler und Massnahmen">
<MudSimpleTable Dense Hover>
<thead><tr><th>Fehler</th><th>Ursache</th><th>Massnahme</th></tr></thead>
<tbody>
<tr><td>Zentrale Datei konnte nicht erzeugt werden</td><td>Excel/SharePoint-Datei gesperrt oder Uploadfehler</td><td>Datei schliessen, Logs prüfen, ggf. Zeitstempeldatei verwenden</td></tr>
<tr><td>Land fehlt in Summary</td><td>Standort nicht exportiert oder keine Finance Include Zeilen</td><td>Standortexport und Finance-Regeln prüfen</td></tr>
<tr><td>Summe passt nicht zum Soll</td><td>falsche Datei, falsches Jahr, Länderregel offen</td><td>Finance Details aggregieren und Länderlogik prüfen</td></tr>
<tr><td>Zu wenige Zeilen nach Import</td><td>Delta statt Vollfile verwendet</td><td>bei ES/DE vollständige Datei neu liefern lassen</td></tr>
<tr><td>Keine Verbindung zur Quelle</td><td>Credentials, Netzwerk, HANA/SAP nicht erreichbar</td><td>Settings und Logs prüfen</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
<TrainingSection Title="16. Freigabe-Checkliste">
<MudSimpleTable Dense Hover>
<thead><tr><th>Nr.</th><th>Prüfpunkt</th><th>OK</th></tr></thead>
<tbody>
<tr><td>1</td><td>Alle relevanten Standorte wurden nach letzter Dateiänderung exportiert</td><td></td></tr>
<tr><td>2</td><td>Zentrale Excel wurde danach neu erzeugt</td><td></td></tr>
<tr><td>3</td><td>Finance Summary stimmt aggregiert mit Finance Details überein</td><td></td></tr>
<tr><td>4</td><td>Soll/Ist Vergleich enthält keine unerwarteten Abweichungen</td><td></td></tr>
<tr><td>5</td><td>ES und DE wurden als Vollfile geliefert</td><td></td></tr>
<tr><td>6</td><td>Offene Länder- oder Regelentscheidungen sind dokumentiert</td><td></td></tr>
<tr><td>7</td><td>SharePoint/Excel-Datei ist nicht mehr gesperrt</td><td></td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
</MudTabPanel>
</MudTabs>
<style>
.training-lead { max-width: 980px; color: var(--mud-palette-text-secondary); }
.training-hero-image, .training-doc-image { width: 100%; border: 1px solid var(--mud-palette-lines-default); border-radius: 6px; background: #fff; }
.training-doc-image { margin-bottom: 12px; }
.training-section { margin: 18px 0 28px; max-width: 1180px; }
.training-section p { margin-bottom: 12px; line-height: 1.55; }
.training-section ul { margin-top: 8px; }
.training-flow { display: grid; grid-template-columns: repeat(5, minmax(120px, 1fr)); gap: 10px; margin: 12px 0 18px; }
.training-flow div { border: 1px solid var(--mud-palette-lines-default); border-radius: 6px; padding: 12px; background: var(--mud-palette-surface); }
.training-flow span { display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; border-radius: 50%; background: var(--mud-palette-primary); color: var(--mud-palette-primary-text); font-weight: 700; margin-bottom: 8px; }
.training-flow strong, .training-flow small { display: block; }
.training-flow small { color: var(--mud-palette-text-secondary); margin-top: 4px; }
@@media (max-width: 900px) { .training-flow { grid-template-columns: 1fr; } }
</style>
@code {
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,669 @@
@page "/hr-kpi"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using System.Globalization
@using System.Text.Json
@using TrafagSalesExporter.Components.HrKpi
@using TrafagSalesExporter.Services
@inject IHrKpiService HrKpiService
@inject IHrKpiAccessService HrKpiAccess
@inject ISnackbar Snackbar
@inject IUiTextService UiText
@inject IJSRuntime JsRuntime
@inject NavigationManager Navigation
@inject IWebHostEnvironment Environment
<PageTitle>@T("HR KPI", "HR KPI")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("HR KPI", "HR KPI")</MudText>
@if (!CanShowHrKpi)
{
<MudPaper Class="pa-4 mb-4" Elevation="1" Style="max-width:520px;">
<MudStack Spacing="3">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
@T("HR KPI enthaelt sensible Personaldaten. Bitte separat anmelden.", "HR KPI contains sensitive HR data. Please sign in separately.")
</MudAlert>
@if (!HrKpiAccess.IsConfigured)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
@T("HR-KPI-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in HrKpiAccess konfigurieren.", "HR KPI access is not configured yet. Please configure Username and PasswordHash in HrKpiAccess.")
</MudAlert>
}
<form method="post" action="@AccessUrl">
<input type="hidden" name="returnUrl" value="@Navigation.Uri" />
<MudStack Spacing="3">
<MudTextField T="string" Name="username" Label="@T("Name", "Name")" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudTextField T="string" Name="password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
<button type="submit" class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-button-filled-size-medium mud-ripple">
@T("HR KPI entsperren", "Unlock HR KPI")
</button>
</MudStack>
</form>
<MudText Typo="Typo.caption">
@T("Server-Klicks", "Server clicks"): @_unlockClickCount |
@T("Konfiguriert", "Configured"): @(HrKpiAccess.IsConfigured ? "JA" : "NEIN")
</MudText>
<MudDivider />
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
<MudStack Spacing="3" Class="pt-2">
<MudTextField @bind-Value="_changeUsername" Label="@T("Name", "Name")" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudTextField @bind-Value="_currentPassword" Label="@T("Aktuelles Passwort", "Current password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPassword" Label="@T("Neues Passwort", "New password")" InputType="InputType.Password" HelperText="@T("Mindestens 8 Zeichen.", "At least 8 characters.")" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPasswordRepeat" Label="@T("Neues Passwort wiederholen", "Repeat new password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="ChangeHrPasswordAsync"
StartIcon="@Icons.Material.Filled.Save" Disabled="@(!HrKpiAccess.IsConfigured)">
@T("Passwort speichern", "Save password")
</MudButton>
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
</MudStack>
</MudPaper>
}
else
{
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudGrid>
<MudItem xs="12" md="5">
<MudTextField @bind-Value="_dataFolder"
Label="@T("Datenordner fuer Rexx/SAP-Dateien", "Data folder for Rexx/SAP files")"
HelperText="@T("Serverordner fuer hochgeladene HR-KPI-Dateien. Auf der publizierten Webseite ist das ein Ordner auf dem Webserver, nicht C:\\temp auf dem lokalen PC.", "Server folder for uploaded HR KPI files. On the published site this is a folder on the web server, not C:\\temp on the local PC.")" />
</MudItem>
<MudItem xs="12" md="5">
<MudStack Spacing="1">
<MudText Typo="Typo.caption">
@T("Erwartete Dateien", "Expected files"): @string.Join(", ", ExpectedUploadFileNames)
</MudText>
<InputFile OnChange="UploadHrKpiFilesAsync" multiple accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" disabled="@(_loading || _uploading)" />
<MudText Typo="Typo.caption">
@T("Uploadziel", "Upload target"): @_serverUploadFolder
</MudText>
</MudStack>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="UseServerUploadFolderAsync"
StartIcon="@Icons.Material.Filled.Folder" Disabled="@(_loading || _uploading)" FullWidth>
@T("Serverordner nutzen", "Use server folder")
</MudButton>
</MudItem>
<MudItem xs="6" md="2">
<MudSelect T="int?" @bind-Value="_year" Label="@T("Austrittsjahr", "Leaver year")" Dense Clearable>
@foreach (var option in _result?.ExitYearOptions ?? [])
{
<MudSelectItem Value="@((int?)option)">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_organisation" Label="@T("Organisation", "Organisation")" Dense Clearable>
@foreach (var option in _result?.OrganisationOptions ?? [])
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loading" FullWidth>
@(_loading ? T("Lade...", "Loading...") : T("Laden", "Load"))
</MudButton>
</MudItem>
<MudItem xs="12" md="2">
<MudSwitch T="bool" @bind-Value="_managementView" Color="Color.Primary"
Label="@T("Managementsicht", "Management view")" />
</MudItem>
<MudItem xs="12" md="3">
<MudDatePicker @bind-Date="_fromDate" Label="@T("Von Datum", "From date")" Clearable DateFormat="dd.MM.yyyy" Culture="_dateCulture" />
</MudItem>
<MudItem xs="12" md="3">
<MudDatePicker @bind-Date="_toDate" Label="@T("Bis Datum", "To date")" Clearable DateFormat="dd.MM.yyyy" Culture="_dateCulture" />
</MudItem>
<MudItem xs="12" md="2">
<MudSelect T="int?" @bind-Value="_entryYear" Label="@T("Eintrittsjahr", "Entry year")" Dense Clearable>
@foreach (var option in _result?.EntryYearOptions ?? [])
{
<MudSelectItem Value="@((int?)option)">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_searchText" Label="@T("Suche Name / Personalnr.", "Search name / personnel no.")" />
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_kostenstelle" Label="@T("Kostenstelle", "Cost center")" Dense Clearable>
@foreach (var option in _result?.KostenstelleOptions ?? [])
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_mitarbeitertyp" Label="@T("Mitarbeitertyp", "Employee type")" Dense Clearable>
@foreach (var option in _result?.MitarbeitertypOptions ?? [])
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudSelect T="string" @bind-Value="_fluktuationFilter" Label="@T("Fluktuation", "Turnover")" Dense>
@foreach (var option in _fluktuationOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="6" md="2">
<MudSelect T="string" @bind-Value="_glzAmpel" Label="@T("GLZ", "Time")" Dense Clearable>
@foreach (var option in _ampelOptions)
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="6" md="2">
<MudSelect T="string" @bind-Value="_restferienAmpel" Label="@T("Restferien", "Vacation")" Dense Clearable>
@foreach (var option in _restferienOptions)
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" OnClick="LockHrKpi"
StartIcon="@Icons.Material.Filled.Lock" FullWidth>
@T("Sperren", "Lock")
</MudButton>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="PrintAsync"
StartIcon="@Icons.Material.Filled.Print" FullWidth>
@T("Drucken/PDF", "Print/PDF")
</MudButton>
</MudItem>
<MudItem xs="12">
<MudDivider Class="my-2" />
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_selectedVariantName" Label="@T("Variante", "Variant")" Dense Clearable>
@foreach (var variant in _variantNames)
{
<MudSelectItem Value="@variant">@variant</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudTextField @bind-Value="_variantName" Label="@T("Variantenname", "Variant name")" />
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="SaveVariantAsync"
StartIcon="@Icons.Material.Filled.Save" Disabled="_loading" FullWidth>
@T("Variante speichern", "Save variant")
</MudButton>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="RenameVariantAsync"
StartIcon="@Icons.Material.Filled.Edit" Disabled="@(_loading || string.IsNullOrWhiteSpace(_selectedVariantName) || string.IsNullOrWhiteSpace(_variantName))" FullWidth>
@T("Umbenennen", "Rename")
</MudButton>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="LoadVariantAsync"
StartIcon="@Icons.Material.Filled.FileOpen" Disabled="@(_loading || string.IsNullOrWhiteSpace(_selectedVariantName))" FullWidth>
@T("Variante laden", "Load variant")
</MudButton>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteVariantAsync"
StartIcon="@Icons.Material.Filled.Delete" Disabled="@(_loading || string.IsNullOrWhiteSpace(_selectedVariantName))" FullWidth>
@T("Löschen", "Delete")
</MudButton>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" OnClick="UpdateSelectedVariantAsync"
StartIcon="@Icons.Material.Filled.Update" Disabled="@(_loading || string.IsNullOrWhiteSpace(_selectedVariantName))" FullWidth>
@T("Bestehende anpassen", "Update existing")
</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
}
@if (CanShowHrKpi && _result is not null)
{
@if (_result.Notices.Count > 0)
{
<MudPaper Class="pa-4 mb-4" Elevation="1">
@foreach (var notice in _result.Notices)
{
<MudAlert Severity="Severity.Warning" Dense Variant="Variant.Outlined" Class="mb-2">@notice</MudAlert>
}
</MudPaper>
}
<HrKpiDashboardTabs Result="_result" />
}
@code {
private string _dataFolder = HrKpiDataSourceOptions.DefaultFolder;
private int? _year;
private DateTime? _fromDate;
private DateTime? _toDate;
private int? _entryYear;
private string? _organisation;
private string? _kostenstelle;
private string? _mitarbeitertyp;
private string _fluktuationFilter = "Alle";
private string? _glzAmpel;
private string? _restferienAmpel;
private string? _searchText;
private bool _managementView;
private string? _hrUsername;
private string? _hrPassword;
private string? _changeUsername;
private string? _currentPassword;
private string? _newPassword;
private string? _newPasswordRepeat;
private int _unlockClickCount;
private string AccessUrl => new Uri(new Uri(Navigation.BaseUri), "access/hr").ToString();
private bool _loading;
private bool _uploading;
private string _serverUploadFolder = string.Empty;
private HrKpiResult? _result;
private string _selectionStorePath = string.Empty;
private HrKpiSelectionStore _selectionStore = new();
private string? _variantName;
private string? _selectedVariantName;
private List<string> _variantNames = [];
private static readonly SemaphoreSlim SelectionStoreLock = new(1, 1);
private static readonly string[] ExpectedUploadFileNames =
[
"Saldiperstichdatum.xlsx",
"Exportkommengehen.xlsx",
"HR_KPI_Export.xlsx",
"Abwesenheitinstunden.xlsx",
"Personalausgeschieden.xlsx"
];
private readonly List<(string Key, string Label)> _fluktuationOptions =
[
("Alle", "Alle"),
("Fluktuationsrelevant", "Relevant"),
("Arbeitnehmerkuendigung", "Arbeitnehmerkuendigung"),
("Ausgeschlossen", "Ausgeschlossen")
];
private readonly List<string> _ampelOptions = ["Gruen", "Gelb", "Rot"];
private readonly List<string> _restferienOptions = ["Gruen", "Rot"];
private readonly CultureInfo _dateCulture = CultureInfo.GetCultureInfo("de-CH");
protected override async Task OnInitializedAsync()
{
_serverUploadFolder = Path.Combine(Environment.ContentRootPath, "hrdata");
Directory.CreateDirectory(_serverUploadFolder);
_selectionStorePath = Path.Combine(_serverUploadFolder, "hr-kpi-variants.json");
_selectionStore = await ReadSelectionStoreAsync();
_dataFolder = _serverUploadFolder;
if (_selectionStore.LastSelection is not null)
ApplySelectionState(_selectionStore.LastSelection);
else
_mitarbeitertyp = "Festangestellt";
RefreshVariantNames();
if (CanShowHrKpi)
{
await LoadAsync();
}
}
private async Task LoadAsync()
{
if (!CanShowHrKpi)
{
return;
}
_loading = true;
try
{
_result = await HrKpiService.BuildAsync(new HrKpiOptions
{
DataFolder = _dataFolder,
Year = _year,
FromDate = _fromDate,
ToDate = _toDate,
EntryYear = _entryYear,
Organisationseinheit = _organisation,
KostenstelleText = _kostenstelle,
Mitarbeitertyp = _mitarbeitertyp,
FluktuationFilter = _fluktuationFilter,
GlzAmpel = _glzAmpel,
RestferienAmpel = _restferienAmpel,
SearchText = _searchText,
ManagementView = _managementView
});
_selectionStore.LastSelection = CreateSelectionState();
await WriteSelectionStoreAsync();
}
catch (Exception ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
finally
{
_loading = false;
}
}
private async Task UploadHrKpiFilesAsync(InputFileChangeEventArgs args)
{
if (!CanShowHrKpi)
{
return;
}
_uploading = true;
try
{
Directory.CreateDirectory(_serverUploadFolder);
var uploaded = 0;
var skipped = new List<string>();
var expected = ExpectedUploadFileNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var file in args.GetMultipleFiles(10))
{
var fileName = Path.GetFileName(file.Name);
if (!expected.Contains(fileName))
{
skipped.Add(fileName);
continue;
}
var targetPath = Path.Combine(_serverUploadFolder, fileName);
await using var source = file.OpenReadStream(50 * 1024 * 1024);
await using var target = File.Create(targetPath);
await source.CopyToAsync(target);
uploaded++;
}
_dataFolder = _serverUploadFolder;
if (uploaded > 0)
{
Snackbar.Add($"{uploaded} HR-KPI-Datei(en) auf den Server geladen.", Severity.Success);
await LoadAsync();
}
if (skipped.Count > 0)
Snackbar.Add($"Nicht uebernommen, weil Dateiname nicht erwartet wird: {string.Join(", ", skipped)}", Severity.Warning);
}
catch (Exception ex)
{
Snackbar.Add($"Upload fehlgeschlagen: {ex.Message}", Severity.Error);
}
finally
{
_uploading = false;
}
}
private async Task UseServerUploadFolderAsync()
{
_dataFolder = _serverUploadFolder;
await LoadAsync();
}
private async Task SaveVariantAsync()
{
var name = (_variantName ?? _selectedVariantName)?.Trim();
if (string.IsNullOrWhiteSpace(name))
{
Snackbar.Add(T("Bitte Variantenname eingeben.", "Please enter a variant name."), Severity.Warning);
return;
}
_selectionStore = await ReadSelectionStoreAsync();
_selectionStore.Variants[name] = CreateSelectionState();
await WriteSelectionStoreAsync();
RefreshVariantNames();
_selectedVariantName = name;
_variantName = name;
Snackbar.Add($"{T("Variante gespeichert", "Variant saved")}: {name}", Severity.Success);
}
private async Task UpdateSelectedVariantAsync()
{
if (string.IsNullOrWhiteSpace(_selectedVariantName))
return;
var name = _selectedVariantName.Trim();
_selectionStore = await ReadSelectionStoreAsync();
_selectionStore.Variants[name] = CreateSelectionState();
await WriteSelectionStoreAsync();
RefreshVariantNames();
_selectedVariantName = name;
_variantName = name;
Snackbar.Add($"{T("Variante aktualisiert", "Variant updated")}: {name}", Severity.Success);
}
private async Task RenameVariantAsync()
{
if (string.IsNullOrWhiteSpace(_selectedVariantName) || string.IsNullOrWhiteSpace(_variantName))
return;
var oldName = _selectedVariantName.Trim();
var newName = _variantName.Trim();
if (string.Equals(oldName, newName, StringComparison.OrdinalIgnoreCase))
{
Snackbar.Add(T("Der Variantenname ist unverändert.", "The variant name is unchanged."), Severity.Info);
return;
}
_selectionStore = await ReadSelectionStoreAsync();
if (!_selectionStore.Variants.TryGetValue(oldName, out var selection))
{
Snackbar.Add(T("Variante nicht gefunden.", "Variant not found."), Severity.Warning);
RefreshVariantNames();
return;
}
_selectionStore.Variants.Remove(oldName);
_selectionStore.Variants[newName] = selection;
await WriteSelectionStoreAsync();
RefreshVariantNames();
_selectedVariantName = newName;
_variantName = newName;
Snackbar.Add($"{T("Variante umbenannt", "Variant renamed")}: {oldName} -> {newName}", Severity.Success);
}
private async Task LoadVariantAsync()
{
if (string.IsNullOrWhiteSpace(_selectedVariantName))
return;
_selectionStore = await ReadSelectionStoreAsync();
if (!_selectionStore.Variants.TryGetValue(_selectedVariantName.Trim(), out var selection))
{
Snackbar.Add(T("Variante nicht gefunden.", "Variant not found."), Severity.Warning);
RefreshVariantNames();
return;
}
ApplySelectionState(selection);
_variantName = _selectedVariantName;
await LoadAsync();
}
private async Task DeleteVariantAsync()
{
if (string.IsNullOrWhiteSpace(_selectedVariantName))
return;
var name = _selectedVariantName.Trim();
_selectionStore = await ReadSelectionStoreAsync();
_selectionStore.Variants.Remove(name);
await WriteSelectionStoreAsync();
RefreshVariantNames();
_selectedVariantName = null;
_variantName = null;
Snackbar.Add($"{T("Variante gelöscht", "Variant deleted")}: {name}", Severity.Success);
}
private void RefreshVariantNames()
{
_variantNames = _selectionStore.Variants.Keys
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private HrKpiSelectionState CreateSelectionState()
=> new()
{
DataFolder = _dataFolder,
Year = _year,
FromDate = _fromDate,
ToDate = _toDate,
EntryYear = _entryYear,
Organisation = _organisation,
Kostenstelle = _kostenstelle,
Mitarbeitertyp = _mitarbeitertyp,
FluktuationFilter = _fluktuationFilter,
GlzAmpel = _glzAmpel,
RestferienAmpel = _restferienAmpel,
SearchText = _searchText,
ManagementView = _managementView
};
private void ApplySelectionState(HrKpiSelectionState state)
{
_dataFolder = string.IsNullOrWhiteSpace(state.DataFolder) ? _serverUploadFolder : state.DataFolder;
_year = state.Year;
_fromDate = state.FromDate;
_toDate = state.ToDate;
_entryYear = state.EntryYear;
_organisation = state.Organisation;
_kostenstelle = state.Kostenstelle;
_mitarbeitertyp = string.IsNullOrWhiteSpace(state.Mitarbeitertyp) ? "Festangestellt" : state.Mitarbeitertyp;
_fluktuationFilter = string.IsNullOrWhiteSpace(state.FluktuationFilter) ? "Alle" : state.FluktuationFilter;
_glzAmpel = state.GlzAmpel;
_restferienAmpel = state.RestferienAmpel;
_searchText = state.SearchText;
_managementView = state.ManagementView;
}
private async Task<HrKpiSelectionStore> ReadSelectionStoreAsync()
{
await SelectionStoreLock.WaitAsync();
try
{
if (!File.Exists(_selectionStorePath))
return new HrKpiSelectionStore();
await using var stream = File.OpenRead(_selectionStorePath);
return await JsonSerializer.DeserializeAsync<HrKpiSelectionStore>(stream) ?? new HrKpiSelectionStore();
}
catch
{
return new HrKpiSelectionStore();
}
finally
{
SelectionStoreLock.Release();
}
}
private async Task WriteSelectionStoreAsync()
{
await SelectionStoreLock.WaitAsync();
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_selectionStorePath) ?? _serverUploadFolder);
var options = new JsonSerializerOptions { WriteIndented = true };
await using var stream = File.Create(_selectionStorePath);
await JsonSerializer.SerializeAsync(stream, _selectionStore, options);
}
finally
{
SelectionStoreLock.Release();
}
}
private async Task UnlockHrKpiAsync()
{
_unlockClickCount++;
if (!HrKpiAccess.TryUnlock(_hrUsername ?? string.Empty, _hrPassword ?? string.Empty))
{
Snackbar.Add(T("HR-KPI-Anmeldung fehlgeschlagen.", "HR KPI sign-in failed."), Severity.Error);
return;
}
_hrPassword = string.Empty;
await LoadAsync();
}
private Task ChangeHrPasswordAsync()
{
if (string.IsNullOrWhiteSpace(_newPassword) || _newPassword.Length < 8)
{
Snackbar.Add(T("Das neue Passwort muss mindestens 8 Zeichen lang sein.", "The new password must be at least 8 characters long."), Severity.Warning);
return Task.CompletedTask;
}
if (_newPassword != _newPasswordRepeat)
{
Snackbar.Add(T("Die neuen Passwörter stimmen nicht überein.", "The new passwords do not match."), Severity.Warning);
return Task.CompletedTask;
}
if (!HrKpiAccess.TryChangePassword(_changeUsername ?? string.Empty, _currentPassword ?? string.Empty, _newPassword))
{
Snackbar.Add(T("Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen.", "Password could not be changed. Check the name or current password."), Severity.Error);
return Task.CompletedTask;
}
_currentPassword = string.Empty;
_newPassword = string.Empty;
_newPasswordRepeat = string.Empty;
Snackbar.Add(T("Passwort wurde geändert.", "Password has been changed."), Severity.Success);
return Task.CompletedTask;
}
private void LockHrKpi()
{
HrKpiAccess.Lock();
_result = null;
_hrPassword = string.Empty;
}
private async Task PrintAsync()
{
await JsRuntime.InvokeVoidAsync("print");
}
private bool CanShowHrKpi => !HrKpiAccess.IsEnabled || HrKpiAccess.IsUnlocked;
private string T(string german, string english) => UiText.Text(german, english);
private sealed class HrKpiSelectionStore
{
public HrKpiSelectionState? LastSelection { get; set; }
public Dictionary<string, HrKpiSelectionState> Variants { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
private sealed class HrKpiSelectionState
{
public string? DataFolder { get; set; }
public int? Year { get; set; }
public DateTime? FromDate { get; set; }
public DateTime? ToDate { get; set; }
public int? EntryYear { get; set; }
public string? Organisation { get; set; }
public string? Kostenstelle { get; set; }
public string? Mitarbeitertyp { get; set; }
public string? FluktuationFilter { get; set; }
public string? GlzAmpel { get; set; }
public string? RestferienAmpel { get; set; }
public string? SearchText { get; set; }
public bool ManagementView { get; set; }
}
}
@@ -0,0 +1,188 @@
@page "/hr-kpi/schulung"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@inject TrafagSalesExporter.Services.IUiTextService UiText
<PageTitle>@T("HR KPI Schulung", "HR KPI training")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-2">@T("HR KPI Schulung", "HR KPI training")</MudText>
<MudText Typo="Typo.body1" Class="mb-4 training-lead">
Ausführliche Anwenderunterlage für HR-Keyuser, HR-Leitung und Stellvertretungen. Die Seite erklärt nicht nur die Bedienung,
sondern auch die Reihenfolge, die Prüfpflichten und die fachliche Interpretation der wichtigsten Kennzahlen.
</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudGrid>
<MudItem xs="12" md="7">
<img class="training-hero-image" src="training/hr_kpi_cockpit_preview.png" alt="HR KPI Cockpit Vorschau" />
</MudItem>
<MudItem xs="12" md="5">
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
Diese Schulung ist als Arbeitsanleitung gedacht: erst Datenstatus prüfen, dann filtern, danach Kennzahlen interpretieren.
</MudAlert>
<MudSimpleTable Dense Hover>
<thead>
<tr><th>Rolle</th><th>Schwerpunkt</th></tr>
</thead>
<tbody>
<tr><td>HR Keyuser</td><td>Daten bereitstellen, Plausibilität prüfen, Monatsauswertung erstellen</td></tr>
<tr><td>HR Leitung</td><td>Kennzahlen bewerten, Managementsicht freigeben, Massnahmen ableiten</td></tr>
<tr><td>IT/Admin</td><td>Zugriff, Pfad, Konfiguration und technische Fehler klären</td></tr>
</tbody>
</MudSimpleTable>
</MudItem>
</MudGrid>
</MudPaper>
<MudTabs Rounded Border>
<MudTabPanel Text="Überblick">
<TrainingSection Title="1. Zweck und Grundprinzip">
<p>Das HR KPI Cockpit bündelt operative HR-Auswertungen in einer geschützten Oberfläche. Es ersetzt keine fachliche Freigabe,
sondern liefert eine nachvollziehbare Sicht auf Headcount, Fluktuation, Absenzen, GLZ, Restferien und Datenqualität.</p>
<p>Die wichtigste Regel lautet: Eine Kennzahl ist nur so belastbar wie die Quelldateien, Filter und fachlichen Ausschlüsse,
die zu ihrer Berechnung verwendet wurden. Deshalb beginnt jede Auswertung mit dem Datenstatus.</p>
<ul>
<li>Vor jeder Weitergabe den Reiter <strong>Datenstatus</strong> öffnen.</li>
<li>Rote Datenqualitätsmeldungen zuerst klären.</li>
<li>Filter und Zeitraum in Bericht oder E-Mail nennen.</li>
<li>Für Managementberichte personenbezogene Detailtabellen vermeiden.</li>
</ul>
</TrainingSection>
<TrainingSection Title="2. Bedienreihenfolge">
<div class="training-flow">
<div><span>1</span><strong>Dateien exportieren</strong><small>Rexx/SAP-Quellen aktualisieren</small></div>
<div><span>2</span><strong>Datenordner prüfen</strong><small>Pfad und Dateistand kontrollieren</small></div>
<div><span>3</span><strong>Laden</strong><small>Cockpit neu aufbauen</small></div>
<div><span>4</span><strong>Datenqualität</strong><small>Warnungen und Fehler lesen</small></div>
<div><span>5</span><strong>KPI freigeben</strong><small>Interpretieren und dokumentieren</small></div>
</div>
<p>Diese Reihenfolge verhindert, dass alte Dateien, leere Filter oder unvollständige Exporte als Managementzahlen verwendet werden.</p>
</TrainingSection>
<TrainingSection Title="3. Datenquellen und Dateistatus">
<MudSimpleTable Dense Hover>
<thead><tr><th>Datei</th><th>Inhalt</th><th>Prüfung</th></tr></thead>
<tbody>
<tr><td>Saldiperstichdatum.xlsx</td><td>Aktive Mitarbeitende, Saldi, Ferien, Organisation, Kostenstelle</td><td>Zeilenanzahl, Alter, Organisationsabdeckung</td></tr>
<tr><td>Exportkommengehen.xlsx</td><td>Arbeitszeitmodell, Sollzeit, Geburtsdatum</td><td>FTE-/Sollzeit-Fallback prüfen</td></tr>
<tr><td>HR_KPI_Export.xlsx</td><td>SAP-HR-Felder, Beschäftigungsgrad, Geschlecht, BU/NBU, Planstelle</td><td>Join auf Personalnummer plausibilisieren</td></tr>
<tr><td>Abwesenheitinstunden.xlsx</td><td>Krankheit kurz/lang, Unfall, Stundenwerte</td><td>Stunden-zu-Tage-Logik und Zeitraum prüfen</td></tr>
<tr><td>Personalausgeschieden.xlsx</td><td>Austritte, Austrittsart, Austrittsdatum</td><td>Austrittsarten und Ausschlüsse kontrollieren</td></tr>
</tbody>
</MudSimpleTable>
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Class="mt-3">
PDF-Dateien sind für diese Auswertung ungeeignet. Die Dateien müssen als strukturierte Excel-Dateien vorliegen.
</MudAlert>
</TrainingSection>
</MudTabPanel>
<MudTabPanel Text="Auswertung">
<TrainingSection Title="4. Filter richtig verwenden">
<p>Filter sind fachliche Eingriffe in die Sicht. Deshalb muss bei jeder Auswertung klar sein, ob ein Filter nur aktive Mitarbeitende,
Austritte oder beide Datenbereiche betrifft.</p>
<MudSimpleTable Dense Hover>
<thead><tr><th>Filter</th><th>Wirkung</th><th>Hinweis</th></tr></thead>
<tbody>
<tr><td>Austrittsjahr</td><td>grenzt Austritte nach Jahr ein</td><td>Leer bedeutet alle verfügbaren Jahre</td></tr>
<tr><td>Von/Bis Austritt</td><td>hat Vorrang vor Austrittsjahr</td><td>Für Quartals- und Sonderauswertungen verwenden</td></tr>
<tr><td>Organisation</td><td>wirkt auf aktive Mitarbeitende und passende Austrittsdaten</td><td>Leere Organisationen sind Datenqualitätsthema</td></tr>
<tr><td>Kostenstelle</td><td>wirkt stabil auf aktive Mitarbeitende</td><td>Nicht jede Austrittsquelle enthält Kostenstellen sauber</td></tr>
<tr><td>GLZ/Restferien Ampel</td><td>fokussiert operative Prüffälle</td><td>Nicht als Fluktuationsfilter interpretieren</td></tr>
<tr><td>Managementsicht</td><td>reduziert personenbezogene Details</td><td>Für Weitergabe und Ausdruck bevorzugen</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
<TrainingSection Title="5. Headcount und Mitarbeitende">
<p>Headcount ist die Basis fast aller HR-Kennzahlen. Sprünge gegenüber Vormonat oder Vorjahr müssen erklärbar sein,
zum Beispiel durch Eintritte, Austritte, Organisationswechsel oder geänderte Quelldateien.</p>
<ul>
<li>Headcount nach Organisation zählt eindeutige Personalnummern.</li>
<li>Leere Personalnummern werden nicht als verlässlicher Distinct-Headcount verwendet.</li>
<li>FTE kann aus SAP-Beschäftigungsgrad oder aus Arbeitszeitmodell/Sollzeit abgeleitet werden.</li>
<li>Bei unerwarteten Sprüngen zuerst Datenstatus und Join-Hinweise prüfen.</li>
</ul>
</TrainingSection>
<TrainingSection Title="6. Fluktuation fachlich lesen">
<p>Die Fluktuation wird aus ausgeschiedenen Personen berechnet. Relevant sind vor allem Arbeitnehmerkündigungen.
Praktikanten, befristete Verträge, Pensionierungen und Arbeitgeberkündigungen werden für die relevante Fluktuation ausgeschlossen.</p>
<MudSimpleTable Dense Hover>
<thead><tr><th>Kennzahl</th><th>Berechnung</th><th>Interpretation</th></tr></thead>
<tbody>
<tr><td>Monatsfluktuation</td><td>relevante Austritte im Monat / Headcount des Monats</td><td>Operativer Frühindikator</td></tr>
<tr><td>Quartalsfluktuation</td><td>relevante Austritte im Quartal / durchschnittlicher Quartals-Headcount</td><td>Stabiler als Monatswert</td></tr>
<tr><td>Jahresfluktuation</td><td>relevante Austritte im Jahr / durchschnittlicher Jahres-Headcount</td><td>Management-KPI</td></tr>
<tr><td>Hochrechnung</td><td>aktuelle Quartalsfluktuation x 4</td><td>Nur als Prognose lesen</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
<TrainingSection Title="7. Absenzen, GLZ und Restferien">
<p>Absenzen werden nach Stunden/Tagen und Organisation ausgewertet. Die Krankenquote nutzt Krankheitstage im Verhältnis
zu FTE und Arbeitstagen. Unfalltage können je nach Quelle anders abgegrenzt sein und müssen daher vorsichtig interpretiert werden.</p>
<ul>
<li>Top-Absenzen dienen der operativen Prüfung, nicht der direkten Weitergabe.</li>
<li>Kritische Restferien zeigen Planungsbedarf oder Datenfehler.</li>
<li>Kritische GLZ-Saldi sollten mit Linienverantwortlichen geprüft werden.</li>
<li>Gelbe Ampeln sind Beobachtungspunkte, rote Ampeln brauchen aktive Klärung.</li>
</ul>
</TrainingSection>
</MudTabPanel>
<MudTabPanel Text="Qualität & Freigabe">
<TrainingSection Title="8. Datenqualität systematisch prüfen">
<MudSimpleTable Dense Hover>
<thead><tr><th>Fehlerbild</th><th>Mögliche Ursache</th><th>Massnahme</th></tr></thead>
<tbody>
<tr><td>Keine Daten</td><td>Datei fehlt, falscher Ordner, falscher Dateiname</td><td>Pfad korrigieren und neu laden</td></tr>
<tr><td>Alter Dateistand</td><td>Export wurde nicht erneuert</td><td>Quelle neu exportieren</td></tr>
<tr><td>Leere Organisation</td><td>Join oder Stammdatenfeld fehlt</td><td>Quelldaten und Personalnummer prüfen</td></tr>
<tr><td>Sprung im Headcount</td><td>neuer Export, Filter, Stichtagswechsel</td><td>Vorperiode und Dateistatus vergleichen</td></tr>
<tr><td>Ungewöhnliche Absenzen</td><td>Stundenlogik, Zeitraum, Doppelerfassung</td><td>Einzelzeilen und Quelle prüfen</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
<TrainingSection Title="9. Managementsicht und Datenschutz">
<p>HR-Daten enthalten personenbezogene Informationen. Für Berichte an Management oder Dritte ist die Managementsicht zu bevorzugen.
Detailtabellen mit Namen, Personalnummern oder Einzelfällen dürfen nur an berechtigte Personen gehen.</p>
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">
Keine HR-Dateien per E-Mail weiterleiten und keine Kopien in ungeschützten Ordnern liegen lassen.
</MudAlert>
</TrainingSection>
<TrainingSection Title="10. Monatsabschluss-Checkliste">
<MudSimpleTable Dense Hover>
<thead><tr><th>Nr.</th><th>Prüfpunkt</th><th>Erledigt</th></tr></thead>
<tbody>
<tr><td>1</td><td>Alle HR-Quelldateien neu exportiert und im richtigen Ordner abgelegt</td><td></td></tr>
<tr><td>2</td><td>Cockpit neu geladen, keine roten Dateistatusmeldungen</td><td></td></tr>
<tr><td>3</td><td>Headcount gegen Vorperiode plausibilisiert</td><td></td></tr>
<tr><td>4</td><td>Fluktuationsausschlüsse fachlich geprüft</td><td></td></tr>
<tr><td>5</td><td>Absenzen, GLZ und Restferien auf Ausreisser geprüft</td><td></td></tr>
<tr><td>6</td><td>Filter, Zeitraum und Datenstand im Bericht dokumentiert</td><td></td></tr>
<tr><td>7</td><td>Managementsicht für Weitergabe verwendet</td><td></td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
</MudTabPanel>
</MudTabs>
<style>
.training-lead { max-width: 980px; color: var(--mud-palette-text-secondary); }
.training-hero-image { width: 100%; border: 1px solid var(--mud-palette-lines-default); border-radius: 6px; }
.training-section { margin: 18px 0 28px; max-width: 1180px; }
.training-section p { margin-bottom: 12px; line-height: 1.55; }
.training-section ul { margin-top: 8px; }
.training-flow { display: grid; grid-template-columns: repeat(5, minmax(120px, 1fr)); gap: 10px; margin: 12px 0 18px; }
.training-flow div { border: 1px solid var(--mud-palette-lines-default); border-radius: 6px; padding: 12px; background: var(--mud-palette-surface); }
.training-flow span { display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; border-radius: 50%; background: var(--mud-palette-primary); color: var(--mud-palette-primary-text); font-weight: 700; margin-bottom: 8px; }
.training-flow strong, .training-flow small { display: block; }
.training-flow small { color: var(--mud-palette-text-secondary); margin-top: 4px; }
@@media (max-width: 900px) { .training-flow { grid-template-columns: 1fr; } }
</style>
@code {
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,60 @@
@page "/diagnostics/interactive"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@inject ILogger<InteractiveDiagnostics> Logger
@inject NavigationManager Navigation
<PageTitle>Interaktivitaet Diagnose</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Interaktivitaet Diagnose</MudText>
<MudPaper Class="pa-4" Elevation="1" Style="max-width:760px;">
<MudStack Spacing="3">
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
HTML wurde vom Server gerendert.
</MudAlert>
<MudText>Adresse: @Navigation.Uri</MudText>
<MudText>Blazor interaktiv verbunden: @(_interactive ? "JA" : "NEIN")</MudText>
<MudText>Server-Klicks angekommen: @_clickCount</MudText>
<MudText>JavaScript Diagnose: <span id="js-diagnostic-status">nicht ausgefuehrt</span></MudText>
<MudText>Blazor Objekt im Browser: <span id="blazor-diagnostic-status">unbekannt</span></MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="RegisterClick">
Server-Klick testen
</MudButton>
</MudStack>
</MudPaper>
<script>
(function () {
var jsStatus = document.getElementById('js-diagnostic-status');
var blazorStatus = document.getElementById('blazor-diagnostic-status');
if (jsStatus) {
jsStatus.textContent = 'ausgefuehrt';
}
if (blazorStatus) {
blazorStatus.textContent = window.Blazor ? 'vorhanden' : 'fehlt';
}
})();
</script>
@code {
private bool _interactive;
private int _clickCount;
protected override void OnAfterRender(bool firstRender)
{
if (!firstRender)
return;
_interactive = true;
Logger.LogInformation("Interactive diagnostics became interactive for {Uri}", Navigation.Uri);
StateHasChanged();
}
private void RegisterClick()
{
_clickCount++;
Logger.LogInformation("Interactive diagnostics server click received. Count={ClickCount}", _clickCount);
}
}
+62 -41
View File
@@ -1,49 +1,50 @@
@page "/logs"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@inject IDbContextFactory<AppDbContext> DbFactory
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using TrafagSalesExporter.Services
@inject ILogsPageService LogsPageActions
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject TrafagSalesExporter.Services.IUiTextService UiText
<PageTitle>Logs</PageTitle>
<PageTitle>@T("Logs", "Logs")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Export Logs</MudText>
<MudText Typo="Typo.h4" Class="mb-4">@T("Export Logs", "Export Logs")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="3">
<MudSelect @bind-Value="_filterLand" Label="Land" Clearable Dense Style="max-width:200px;">
<MudSelect @bind-Value="_filterLand" Label="@T("Land", "Country")" Clearable Dense Style="max-width:200px;">
@foreach (var land in _availableLands)
{
<MudSelectItem Value="@land">@land</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="_filterStatus" Label="Status" Clearable Dense Style="max-width:150px;">
<MudSelect @bind-Value="_filterStatus" Label="@T("Status", "Status")" Clearable Dense Style="max-width:150px;">
<MudSelectItem Value="@("OK")">OK</MudSelectItem>
<MudSelectItem Value="@("Error")">Error</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="_filterDate" Label="Datum" Clearable Dense Style="max-width:200px;" />
<MudDatePicker @bind-Date="_filterDate" Label="@T("Datum", "Date")" Clearable Dense Style="max-width:200px;" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ApplyFilter"
StartIcon="@Icons.Material.Filled.FilterAlt">
Filtern
@T("Filtern", "Filter")
</MudButton>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOldLogs"
StartIcon="@Icons.Material.Filled.DeleteSweep">
Alte Logs löschen
@T("Alte Logs loeschen", "Delete old logs")
</MudButton>
</MudStack>
</MudPaper>
<MudTable Items="_logs" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>Zeitpunkt</MudTh>
<MudTh>Land</MudTh>
<MudTh>@T("Zeitpunkt", "Timestamp")</MudTh>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Status</MudTh>
<MudTh>Zeilen</MudTh>
<MudTh>Dauer</MudTh>
<MudTh>Dateiname</MudTh>
<MudTh>Fehler</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
<MudTh>@T("Dauer", "Duration")</MudTh>
<MudTh>@T("Dateiname", "File name")</MudTh>
<MudTh>@T("Fehler", "Error")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
@@ -75,8 +76,39 @@
</RowTemplate>
</MudTable>
<MudText Typo="Typo.h5" Class="mt-6 mb-2">@T("Technische Logs", "Technical logs")</MudText>
<MudTable Items="_appLogs" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>@T("Zeitpunkt", "Timestamp")</MudTh>
<MudTh>Level</MudTh>
<MudTh>@T("Kategorie", "Category")</MudTh>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>@T("Meldung", "Message")</MudTh>
<MudTh>Details</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
<MudTd>@context.Level</MudTd>
<MudTd>@context.Category</MudTd>
<MudTd>@(string.IsNullOrWhiteSpace(context.Land) ? "-" : context.Land)</MudTd>
<MudTd>@context.Message</MudTd>
<MudTd>
@if (!string.IsNullOrWhiteSpace(context.Details))
{
<MudTooltip Text="@context.Details">
<MudText Typo="Typo.caption" Style="max-width:420px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
@context.Details
</MudText>
</MudTooltip>
}
</MudTd>
</RowTemplate>
</MudTable>
@code {
private List<ExportLog> _logs = new();
private List<AppEventLog> _appLogs = new();
private List<string> _availableLands = new();
private string? _filterLand;
private string? _filterStatus;
@@ -85,27 +117,16 @@
protected override async Task OnInitializedAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_availableLands = await db.ExportLogs.Select(l => l.Land).Distinct().OrderBy(l => l).ToListAsync();
await LoadLogsAsync();
}
private async Task LoadLogsAsync()
{
_loading = true;
using var db = await DbFactory.CreateDbContextAsync();
IQueryable<ExportLog> query = db.ExportLogs.OrderByDescending(l => l.Timestamp);
if (!string.IsNullOrEmpty(_filterLand))
query = query.Where(l => l.Land == _filterLand);
if (!string.IsNullOrEmpty(_filterStatus))
query = query.Where(l => l.Status == _filterStatus);
if (_filterDate.HasValue)
query = query.Where(l => l.Timestamp.Date == _filterDate.Value.Date);
_logs = await query.Take(500).ToListAsync();
var state = await LogsPageActions.LoadAsync(_filterLand, _filterStatus, _filterDate);
_availableLands = state.AvailableLands;
_logs = state.Logs;
_appLogs = state.AppLogs;
_loading = false;
}
@@ -117,18 +138,18 @@
private async Task DeleteOldLogs()
{
var result = await DialogService.ShowMessageBox(
"Alte Logs löschen",
"Logs älter als 90 Tage löschen?",
yesText: "Löschen", cancelText: "Abbrechen");
T("Alte Logs loeschen", "Delete old logs"),
T("Logs aelter als 90 Tage loeschen?", "Delete logs older than 90 days?"),
yesText: T("Loeschen", "Delete"), cancelText: T("Abbrechen", "Cancel"));
if (result != true) return;
using var db = await DbFactory.CreateDbContextAsync();
var cutoff = DateTime.Now.AddDays(-90);
var oldLogs = await db.ExportLogs.Where(l => l.Timestamp < cutoff).ToListAsync();
db.ExportLogs.RemoveRange(oldLogs);
var count = await db.SaveChangesAsync();
var deletedCount = await LogsPageActions.DeleteOldLogsAsync(90);
await LoadLogsAsync();
Snackbar.Add($"{oldLogs.Count} alte Logs gelöscht", Severity.Info);
Snackbar.Add(string.Format(T("{0} alte Logs geloescht", "{0} old logs deleted"), deletedCount), Severity.Info);
}
}
@code {
private string T(string german, string english) => UiText.Text(german, english);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,368 @@
@page "/manual-imports"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject IStandortePageService StandortePageService
@inject ISnackbar Snackbar
@inject IUiTextService UiText
<PageTitle>@T("Manuelle Importe", "Manual imports")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Manuelle Importe", "Manual imports")</MudText>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense Class="mb-4">
@T("Diese Seite ist fuer Keyuser: Hier werden Excel-/CSV-Dateien fuer manuelle Laender wie DE, UK und ES hinterlegt und aktiviert. Technische Spaltenmappings bleiben in Admin -> Standorte.",
"This page is for key users: Excel/CSV files for manual countries such as DE, UK and ES are maintained and activated here. Technical column mappings remain in Admin -> Sites.")
</MudAlert>
<MudTabs Elevation="0" Rounded="false" PanelClass="manual-import-tab-panel">
<MudTabPanel Text="@T("Importdateien", "Import files")" Icon="@Icons.Material.Filled.UploadFile">
<MudTable Items="_rows" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>TSC</MudTh>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh>@T("Datei / SharePoint-Ordner", "File / SharePoint folder")</MudTh>
<MudTh>@T("Letzter Upload", "Last upload")</MudTh>
<MudTh>@T("Aktionen", "Actions")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd><MudSwitch @bind-Value="context.IsActive" Color="Color.Primary" /></MudTd>
<MudTd>
<MudTextField @bind-Value="context.ManualImportFilePath"
Placeholder="@T("lokaler Pfad, UNC, SharePoint-Datei oder SharePoint-Ordner", "local path, UNC, SharePoint file or SharePoint folder")"
Margin="Margin.Dense" />
</MudTd>
<MudTd>@(context.ManualImportLastUploadedAtUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") ?? "-")</MudTd>
<MudTd>
<MudStack Row Spacing="1">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Info"
StartIcon="@Icons.Material.Filled.FactCheck"
OnClick="() => ValidatePathAsync(context)" Disabled="_busySiteId == context.Id">
@T("Pfad pruefen", "Check path")
</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
OnClick="() => SaveAsync(context)" Disabled="_busySiteId == context.Id">
@T("Speichern", "Save")
</MudButton>
</MudStack>
<InputFile OnChange="args => UploadAsync(context, args)" accept=".xlsx,.csv" />
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Keine manuellen Excel-/CSV-Standorte gefunden.", "No manual Excel/CSV sites found.")</MudText>
</NoRecordsContent>
</MudTable>
</MudTabPanel>
<MudTabPanel Text="@T("Anleitung", "Guide")" Icon="@Icons.Material.Filled.Route">
<div class="workflow-shell">
<div class="workflow-step import">
<MudIcon Icon="@Icons.Material.Filled.UploadFile" Size="Size.Large" />
<span class="workflow-index">1</span>
<h3>@T("Excel bereitstellen", "Provide Excel")</h3>
<p>@T("Datei hochladen oder SharePoint-/UNC-Pfad eintragen.", "Upload a file or enter a SharePoint/UNC path.")</p>
</div>
<div class="workflow-arrow">
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" />
</div>
<div class="workflow-step save">
<MudIcon Icon="@Icons.Material.Filled.Save" Size="Size.Large" />
<span class="workflow-index">2</span>
<h3>@T("Speichern und aktivieren", "Save and activate")</h3>
<p>@T("Pfad pruefen, Standort aktiv setzen und speichern.", "Check the path, set the site active, and save.")</p>
</div>
<div class="workflow-arrow">
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" />
</div>
<div class="workflow-step export">
<MudIcon Icon="@Icons.Material.Filled.PlayArrow" Size="Size.Large" />
<span class="workflow-index">3</span>
<h3>@T("Standort exportieren", "Export site")</h3>
<p>@T("Im Export Dashboard den Standort starten. Die Daten landen in CentralSalesRecords.", "Start the site in the export dashboard. Data is written to CentralSalesRecords.")</p>
</div>
<div class="workflow-arrow">
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" />
</div>
<div class="workflow-step central">
<MudIcon Icon="@Icons.Material.Filled.TableView" Size="Size.Large" />
<span class="workflow-index">4</span>
<h3>@T("Zentrale Excel erzeugen", "Build final Excel")</h3>
<p>@T("Danach `Zentrale Datei neu erzeugen` ausfuehren.", "Then run `Rebuild consolidated file`.")</p>
</div>
<div class="workflow-arrow">
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" />
</div>
<div class="workflow-step check">
<MudIcon Icon="@Icons.Material.Filled.CompareArrows" Size="Size.Large" />
<span class="workflow-index">5</span>
<h3>@T("Finance pruefen", "Check finance")</h3>
<p>@T("Im Endexcel `Finance | ...` oder im Reiter `Soll/Ist Vergleich` kontrollieren.", "Check the `Finance | ...` columns in the final Excel or the `Actual/reference comparison` tab.")</p>
</div>
</div>
<div class="workflow-notes">
<div class="workflow-note good">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" />
<div>
<strong>@T("Richtige Reihenfolge", "Correct order")</strong>
<p>@T("Ein Standortexport aktualisiert die Datenbasis. Die zentrale Excel muss danach neu erzeugt werden.", "A site export updates the data basis. The final Excel must be rebuilt afterwards.")</p>
</div>
</div>
<div class="workflow-note warn">
<MudIcon Icon="@Icons.Material.Filled.Warning" />
<div>
<strong>@T("DE bleibt fachlich offen", "DE remains open")</strong>
<p>@T("Alphaplan ist technisch importierbar. Kundenlaender und Filter fuer den offiziellen DE-Istwert muessen noch bestaetigt werden.", "Alphaplan is technically importable. Customer countries and filters for the official DE actual still need confirmation.")</p>
</div>
</div>
<div class="workflow-note info">
<MudIcon Icon="@Icons.Material.Filled.Info" />
<div>
<strong>@T("Server-Hinweis", "Server note")</strong>
<p>@T("Der Server braucht kein Microsoft Excel. XLSX/CSV wird direkt von der Anwendung gelesen.", "The server does not need Microsoft Excel. XLSX/CSV is read directly by the application.")</p>
</div>
</div>
</div>
</MudTabPanel>
</MudTabs>
<style>
.manual-import-tab-panel {
padding-top: 18px;
}
.workflow-shell {
display: grid;
grid-template-columns: repeat(5, minmax(150px, 1fr));
gap: 12px;
align-items: stretch;
}
.workflow-step {
position: relative;
min-height: 190px;
padding: 18px 16px;
border: 1px solid var(--mud-palette-lines-default);
background: var(--mud-palette-surface);
display: flex;
flex-direction: column;
gap: 8px;
}
.workflow-step.import { border-top: 5px solid var(--mud-palette-info); }
.workflow-step.save { border-top: 5px solid var(--mud-palette-primary); }
.workflow-step.export { border-top: 5px solid var(--mud-palette-success); }
.workflow-step.central { border-top: 5px solid var(--mud-palette-secondary); }
.workflow-step.check { border-top: 5px solid var(--mud-palette-warning); }
.workflow-step h3 {
margin: 6px 0 0 0;
font-size: 1rem;
font-weight: 700;
}
.workflow-step p,
.workflow-note p {
margin: 0;
color: var(--mud-palette-text-secondary);
font-size: .9rem;
line-height: 1.35;
}
.workflow-index {
position: absolute;
top: 14px;
right: 14px;
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--mud-palette-dark);
color: var(--mud-palette-dark-text);
font-weight: 700;
}
.workflow-arrow {
display: none;
}
.workflow-notes {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.workflow-note {
display: grid;
grid-template-columns: 34px 1fr;
gap: 10px;
padding: 14px;
border: 1px solid var(--mud-palette-lines-default);
background: var(--mud-palette-surface);
}
.workflow-note.good { border-left: 5px solid var(--mud-palette-success); }
.workflow-note.warn { border-left: 5px solid var(--mud-palette-warning); }
.workflow-note.info { border-left: 5px solid var(--mud-palette-info); }
@@media (max-width: 1100px) {
.workflow-shell {
grid-template-columns: 1fr;
}
.workflow-arrow {
display: flex;
justify-content: center;
transform: rotate(90deg);
}
.workflow-notes {
grid-template-columns: 1fr;
}
}
</style>
@code {
private List<ManualImportRow> _rows = [];
private bool _loading = true;
private int? _busySiteId;
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
await using var db = await DbFactory.CreateDbContextAsync();
var manualSourceCodes = await db.SourceSystemDefinitions
.Where(x => x.ConnectionKind == SourceSystemConnectionKinds.ManualExcel)
.Select(x => x.Code)
.ToListAsync();
_rows = await db.Sites
.Where(site => manualSourceCodes.Contains(site.SourceSystem))
.OrderBy(site => site.Land)
.ThenBy(site => site.TSC)
.Select(site => new ManualImportRow
{
Id = site.Id,
Land = site.Land,
TSC = site.TSC,
IsActive = site.IsActive,
ManualImportFilePath = site.ManualImportFilePath,
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc
})
.ToListAsync();
_loading = false;
}
private async Task SaveAsync(ManualImportRow row)
{
_busySiteId = row.Id;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var site = await db.Sites.FirstAsync(x => x.Id == row.Id);
site.IsActive = row.IsActive;
site.ManualImportFilePath = row.ManualImportFilePath.Trim();
site.ManualImportLastUploadedAtUtc = row.ManualImportLastUploadedAtUtc;
await db.SaveChangesAsync();
Snackbar.Add(T("Import-Einstellungen gespeichert.", "Import settings saved."), Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"{T("Speichern fehlgeschlagen", "Save failed")}: {ex.Message}", Severity.Error);
}
finally
{
_busySiteId = null;
}
}
private async Task ValidatePathAsync(ManualImportRow row)
{
_busySiteId = row.Id;
try
{
row.ManualImportLastUploadedAtUtc = await StandortePageService.ValidateManualImportPathAsync(row.ManualImportFilePath);
Snackbar.Add(T("Datei oder SharePoint-Referenz ist erreichbar.", "File or SharePoint reference is reachable."), Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"{T("Pfadpruefung fehlgeschlagen", "Path check failed")}: {ex.Message}", Severity.Error);
}
finally
{
_busySiteId = null;
}
}
private async Task UploadAsync(ManualImportRow row, InputFileChangeEventArgs args)
{
var file = args.File;
if (file is null)
return;
_busySiteId = row.Id;
try
{
var extension = Path.GetExtension(file.Name);
if (!string.Equals(extension, ".xlsx", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(extension, ".csv", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(T("Bitte eine .xlsx- oder .csv-Datei auswaehlen.", "Please choose a .xlsx or .csv file."));
}
var uploadDirectory = Path.Combine(AppContext.BaseDirectory, "manual-imports");
Directory.CreateDirectory(uploadDirectory);
var safeBaseName = string.Concat(Path.GetFileNameWithoutExtension(file.Name)
.Select(ch => char.IsLetterOrDigit(ch) || ch == '-' || ch == '_' ? ch : '_'));
if (string.IsNullOrWhiteSpace(safeBaseName))
safeBaseName = "manual_import";
var targetPath = Path.Combine(uploadDirectory, $"{safeBaseName}_{Guid.NewGuid():N}{extension}");
await using (var sourceStream = file.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024))
await using (var targetStream = File.Create(targetPath))
{
await sourceStream.CopyToAsync(targetStream);
}
row.ManualImportFilePath = targetPath;
row.ManualImportLastUploadedAtUtc = DateTime.UtcNow;
await SaveAsync(row);
Snackbar.Add(T("Datei hochgeladen.", "File uploaded."), Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"{T("Upload fehlgeschlagen", "Upload failed")}: {ex.Message}", Severity.Error);
}
finally
{
_busySiteId = null;
}
}
private string T(string german, string english) => UiText.Text(german, english);
private sealed class ManualImportRow
{
public int Id { get; set; }
public string Land { get; set; } = string.Empty;
public string TSC { get; set; } = string.Empty;
public bool IsActive { get; set; }
public string ManualImportFilePath { get; set; } = string.Empty;
public DateTime? ManualImportLastUploadedAtUtc { get; set; }
}
}
@@ -0,0 +1,295 @@
@page "/admin/menu-structure"
@using Microsoft.AspNetCore.Authorization
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Security
@using TrafagSalesExporter.Services
@attribute [Authorize(Policy = SecurityPolicies.AdminOnly)]
@inject INavigationMenuService NavigationMenuService
@inject IUiTextService UiText
@inject ISnackbar Snackbar
<PageTitle>@T("Menuestruktur", "Menu structure")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Menuestruktur", "Menu structure")</MudText>
<MudStack Row="true" Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAsync" Disabled="_loading">
@T("Speichern", "Save")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Restore" OnClick="ResetAsync" Disabled="_loading">
@T("Standard wiederherstellen", "Restore default")
</MudButton>
</MudStack>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
@T("Bestehende Menuepunkte koennen in andere Gruppen gehaengt, sortiert, aus- oder eingeblendet und umbenannt werden. Die Zielseite bleibt unveraendert.",
"Existing menu entries can be moved into other groups, sorted, hidden/shown and renamed. The target page stays unchanged.")
</MudAlert>
@if (_loading)
{
<MudProgressCircular Indeterminate="true" />
}
else
{
<MudText Typo="Typo.h6" Class="mb-2">@T("Drag & Drop", "Drag & drop")</MudText>
<div class="menu-drop-root"
@ondragover:preventDefault
@ondrop="DropOnRoot">
@T("Hier ablegen fuer Hauptmenue", "Drop here for root menu")
</div>
<MudPaper Class="pa-2 mb-4" Outlined="true">
@foreach (var item in OrderedItems)
{
<div class="menu-drag-row"
draggable="true"
@ondragstart="() => StartDrag(item)"
@ondragover:preventDefault
@ondrop="() => DropOn(item)">
<MudIcon Icon="@NavigationIconResolver.Resolve(item.Icon)" Size="Size.Small" />
<span class="menu-drag-title">@Indent(item)@item.TitleDe</span>
<span class="menu-drag-meta">@item.ItemType</span>
</div>
}
</MudPaper>
<MudText Typo="Typo.h6" Class="mb-2">@T("Details", "Details")</MudText>
<MudTable Items="@OrderedItems" Dense="true" Hover="true">
<HeaderContent>
<MudTh>@T("Reihenfolge", "Order")</MudTh>
<MudTh>@T("Titel", "Title")</MudTh>
<MudTh>@T("Typ", "Type")</MudTh>
<MudTh>@T("Untermenue von", "Parent")</MudTh>
<MudTh>@T("Sichtbar", "Visible")</MudTh>
<MudTh>@T("Aktion", "Action")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.KeyboardArrowUp" Size="Size.Small" OnClick="@(() => Move(context, -1))" />
<MudIconButton Icon="@Icons.Material.Filled.KeyboardArrowDown" Size="Size.Small" OnClick="@(() => Move(context, 1))" />
</MudTd>
<MudTd>
<MudTextField @bind-Value="context.TitleDe" Label="DE" Dense="true" Margin="Margin.Dense" />
<MudTextField @bind-Value="context.TitleEn" Label="EN" Dense="true" Margin="Margin.Dense" />
@if (!string.IsNullOrWhiteSpace(context.Href))
{
<MudText Typo="Typo.caption">@context.Href</MudText>
}
</MudTd>
<MudTd>@context.ItemType</MudTd>
<MudTd>
<MudSelect T="string" Value="@NormalizeParent(context.ParentKey)" ValueChanged="value => ChangeParent(context, value)" Dense="true">
<MudSelectItem Value="@RootParentValue">@T("Hauptmenue", "Root menu")</MudSelectItem>
@foreach (var group in ParentOptions(context))
{
<MudSelectItem Value="@group.Key">@GroupLabel(group)</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSwitch @bind-Value="context.IsVisible" Color="Color.Primary" />
</MudTd>
<MudTd>
<MudText Typo="Typo.caption">@context.Key</MudText>
</MudTd>
</RowTemplate>
</MudTable>
}
@code {
private const string RootParentValue = "__root__";
private List<NavigationMenuItem> _items = [];
private NavigationMenuItem? _draggedItem;
private bool _loading = true;
private IEnumerable<NavigationMenuItem> OrderedItems => _items
.OrderBy(x => x.ParentKey ?? string.Empty)
.ThenBy(x => x.SortOrder)
.ThenBy(x => x.TitleDe);
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
_items = await NavigationMenuService.GetItemsAsync();
_loading = false;
}
private async Task SaveAsync()
{
NormalizeSortOrders();
await NavigationMenuService.SaveItemsAsync(_items);
Snackbar.Add(T("Menuestruktur gespeichert.", "Menu structure saved."), Severity.Success);
}
private async Task ResetAsync()
{
await NavigationMenuService.ResetToDefaultsAsync();
await LoadAsync();
Snackbar.Add(T("Standard-Menuestruktur wiederhergestellt.", "Default menu structure restored."), Severity.Info);
}
private IEnumerable<NavigationMenuItem> ParentOptions(NavigationMenuItem item)
=> _items
.Where(x => x.ItemType == NavigationMenuItemTypes.Group)
.Where(x => x.Key != item.Key)
.Where(x => !WouldCreateCycle(item.Key, x.Key))
.OrderBy(x => x.TitleDe);
private void ChangeParent(NavigationMenuItem item, string parentKey)
{
item.ParentKey = parentKey == RootParentValue ? null : parentKey;
item.SortOrder = NextSortOrder(item.ParentKey);
}
private void Move(NavigationMenuItem item, int direction)
{
var siblings = _items
.Where(x => string.Equals(x.ParentKey, item.ParentKey, StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.TitleDe)
.ToList();
var index = siblings.FindIndex(x => x.Key == item.Key);
var targetIndex = index + direction;
if (index < 0 || targetIndex < 0 || targetIndex >= siblings.Count)
return;
(siblings[index].SortOrder, siblings[targetIndex].SortOrder) = (siblings[targetIndex].SortOrder, siblings[index].SortOrder);
}
private void StartDrag(NavigationMenuItem item)
{
_draggedItem = item;
}
private void DropOnRoot()
{
if (_draggedItem is null)
return;
_draggedItem.ParentKey = null;
_draggedItem.SortOrder = NextSortOrder(null);
_draggedItem = null;
}
private void DropOn(NavigationMenuItem target)
{
if (_draggedItem is null || _draggedItem.Key == target.Key)
return;
if (target.ItemType == NavigationMenuItemTypes.Group && !WouldCreateCycle(_draggedItem.Key, target.Key))
{
_draggedItem.ParentKey = target.Key;
_draggedItem.SortOrder = NextSortOrder(target.Key);
}
else
{
_draggedItem.ParentKey = target.ParentKey;
_draggedItem.SortOrder = target.SortOrder - 1;
NormalizeSortOrders();
}
_draggedItem = null;
}
private bool WouldCreateCycle(string itemKey, string candidateParentKey)
{
var current = _items.FirstOrDefault(x => x.Key == candidateParentKey);
while (current is not null)
{
if (current.Key == itemKey)
return true;
current = string.IsNullOrWhiteSpace(current.ParentKey)
? null
: _items.FirstOrDefault(x => x.Key == current.ParentKey);
}
return false;
}
private int NextSortOrder(string? parentKey)
=> _items
.Where(x => string.Equals(x.ParentKey, parentKey, StringComparison.OrdinalIgnoreCase))
.Select(x => x.SortOrder)
.DefaultIfEmpty(0)
.Max() + 10;
private void NormalizeSortOrders()
{
foreach (var group in _items.GroupBy(x => x.ParentKey ?? string.Empty))
{
var sortOrder = 10;
foreach (var item in group.OrderBy(x => x.SortOrder).ThenBy(x => x.TitleDe))
{
item.SortOrder = sortOrder;
sortOrder += 10;
}
}
}
private static string NormalizeParent(string? parentKey)
=> string.IsNullOrWhiteSpace(parentKey) ? RootParentValue : parentKey;
private string GroupLabel(NavigationMenuItem item)
=> UiText.Text(item.TitleDe, item.TitleEn);
private string T(string german, string english) => UiText.Text(german, english);
private string Indent(NavigationMenuItem item)
{
var depth = 0;
var current = item;
while (!string.IsNullOrWhiteSpace(current.ParentKey))
{
depth++;
var next = _items.FirstOrDefault(x => x.Key == current.ParentKey);
if (next is null || next.Key == current.Key)
break;
current = next;
}
return new string(' ', depth * 4);
}
}
<style>
.menu-drop-root {
border: 1px dashed var(--mud-palette-lines-default);
border-radius: 4px;
padding: 8px 10px;
margin-bottom: 8px;
color: var(--mud-palette-text-secondary);
font-size: 0.875rem;
}
.menu-drag-row {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 4px 8px;
border-bottom: 1px solid var(--mud-palette-lines-default);
cursor: grab;
}
.menu-drag-row:last-child {
border-bottom: 0;
}
.menu-drag-title {
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-drag-meta {
color: var(--mud-palette-text-secondary);
font-size: 0.75rem;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,289 @@
@page "/einkauf/verbindungen"
@using TrafagSalesExporter.Services
@inject IPurchasingDataSourcePageService DataSourceService
@inject TrafagSalesExporter.Services.IUiTextService UiText
@inject ISnackbar Snackbar
<PageTitle>@T("Einkauf Datenquellen", "Purchasing data sources")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-1">@T("Einkauf Datenquellen", "Purchasing data sources")</MudText>
<MudText Typo="Typo.body2" Class="mb-4 purchasing-muted">
@T("Grafische SAP/OData-Anbindung fuer das Einkaufsdashboard, analog zur Finance-Quellenpflege.",
"Graphical SAP/OData connection for the purchasing dashboard, following the finance source configuration pattern.")
</MudText>
@if (_loading)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<MudGrid Spacing="2" Class="mb-4">
<MudItem xs="12" md="7">
<MudPaper Class="pa-3" Outlined="true">
<MudText Typo="Typo.h6" Class="mb-3">@T("Verbindung", "Connection")</MudText>
<MudGrid Spacing="2">
<MudItem xs="12" sm="6">
<MudTextField Value="_state.Site.TSC" Label="TSC" ReadOnly="true" />
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField Value="_state.Site.SourceSystem" Label="@T("Quellsystem", "Source system")" ReadOnly="true" />
</MudItem>
<MudItem xs="12">
<MudTextField @bind-Value="_state.Site.SapServiceUrl"
Label="SAP Service URL Override"
HelperText="@T("Leer lassen = zentrale SAP OData URL aus Settings verwenden.", "Leave empty = use central SAP OData URL from settings.")" />
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField @bind-Value="_state.Site.UsernameOverride"
Label="Username Override"
HelperText="@T("Leer lassen = zentraler SAP User.", "Leave empty = central SAP user.")" />
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField @bind-Value="_state.Site.PasswordOverride"
Label="Password Override"
InputType="InputType.Password"
HelperText="@T("Leer lassen = zentrales SAP Passwort.", "Leave empty = central SAP password.")" />
</MudItem>
<MudItem xs="12">
<MudCheckBox @bind-Value="_state.Site.IsActive"
Label="@T("Einkaufsquelle fuer Import aktivieren", "Activate purchasing source for import")" />
</MudItem>
</MudGrid>
<MudStack Row="true" Spacing="2" Class="mt-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAsync" Disabled="_busy">
@T("Speichern", "Save")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestConnectionAsync" Disabled="_busy">
@T("Verbindung testen", "Test connection")
</MudButton>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Restore" OnClick="ResetDefaultsAsync" Disabled="_busy">
@T("Defaults wiederherstellen", "Restore defaults")
</MudButton>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="5">
<MudPaper Class="pa-3" Outlined="true">
<MudText Typo="Typo.h6" Class="mb-2">@T("Aktuelle Basis", "Current basis")</MudText>
<div class="purchasing-source-row">
<MudIcon Icon="@Icons.Material.Filled.CloudQueue" Size="Size.Small" />
<span>@T("Zentrale URL", "Central URL"): <code>@Display(_state.SourceSystem?.CentralServiceUrl)</code></span>
</div>
<div class="purchasing-source-row">
<MudIcon Icon="@Icons.Material.Filled.TableChart" Size="Size.Small" />
<span>@T("Quellen", "Sources"): @_state.Sources.Count(x => x.IsActive)</span>
</div>
<div class="purchasing-source-row">
<MudIcon Icon="@Icons.Material.Filled.AccountTree" Size="Size.Small" />
<span>@T("Joins", "Joins"): @_state.Joins.Count(x => x.IsActive)</span>
</div>
<div class="purchasing-source-row">
<MudIcon Icon="@Icons.Material.Filled.Schema" Size="Size.Small" />
<span>@T("Mappings", "Mappings"): @_state.Mappings.Count(x => x.IsActive)</span>
</div>
</MudPaper>
</MudItem>
</MudGrid>
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
<MudTabPanel Text="@T("Quellen", "Sources")" Icon="@Icons.Material.Filled.Hub">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">@T("OData Entity Sets", "OData entity sets")</MudText>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSource">@T("Quelle", "Source")</MudButton>
</MudStack>
<MudTable Items="_state.Sources" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Alias</MudTh>
<MudTh>Entity Set</MudTh>
<MudTh>@T("Primaer", "Primary")</MudTh>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudTextField @bind-Value="context.Alias" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.EntitySet" Dense="true" /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsPrimary" Dense="true" /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense="true" /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveSource(context)" /></MudTd>
</RowTemplate>
</MudTable>
</MudTabPanel>
<MudTabPanel Text="@T("Join-Fluss", "Join flow")" Icon="@Icons.Material.Filled.AccountTree">
<MudGrid Spacing="2" Class="mb-3">
@foreach (var join in _state.Joins.Where(x => x.IsActive))
{
<MudItem xs="12" sm="6" lg="3">
<MudPaper Class="pa-3 purchasing-flow" Outlined="true">
<MudText Typo="Typo.subtitle2">@join.LeftAlias -> @join.RightAlias</MudText>
<MudText Typo="Typo.caption">@join.LeftKeys = @join.RightKeys</MudText>
</MudPaper>
</MudItem>
}
</MudGrid>
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">@T("Joins", "Joins")</MudText>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddJoin">@T("Join", "Join")</MudButton>
</MudStack>
<MudTable Items="_state.Joins" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>@T("Links", "Left")</MudTh>
<MudTh>Left Keys</MudTh>
<MudTh>@T("Rechts", "Right")</MudTh>
<MudTh>Right Keys</MudTh>
<MudTh>@T("Typ", "Type")</MudTh>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudTextField @bind-Value="context.LeftAlias" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.LeftKeys" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.RightAlias" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.RightKeys" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.JoinType" Dense="true" /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense="true" /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveJoin(context)" /></MudTd>
</RowTemplate>
</MudTable>
</MudTabPanel>
<MudTabPanel Text="@T("Mapping", "Mapping")" Icon="@Icons.Material.Filled.Schema">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">@T("Zielfelder", "Target fields")</MudText>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddMapping">@T("Mapping", "Mapping")</MudButton>
</MudStack>
<MudTable Items="_state.Mappings" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>@T("Ziel", "Target")</MudTh>
<MudTh>@T("Quelle", "Source")</MudTh>
<MudTh>@T("Pflicht", "Required")</MudTh>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudTextField @bind-Value="context.TargetField" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.SourceExpression" Dense="true" /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsRequired" Dense="true" /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense="true" /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveMapping(context)" /></MudTd>
</RowTemplate>
</MudTable>
</MudTabPanel>
</MudTabs>
}
@code {
private PurchasingDataSourceState _state = new();
private bool _loading = true;
private bool _busy;
protected override async Task OnInitializedAsync()
{
_state = await DataSourceService.LoadAsync();
_loading = false;
}
private async Task SaveAsync()
{
await RunAsync(async () =>
{
_state = await DataSourceService.SaveAsync(_state);
Snackbar.Add(T("Einkaufsdatenquellen gespeichert.", "Purchasing data sources saved."), Severity.Success);
});
}
private async Task ResetDefaultsAsync()
{
await RunAsync(async () =>
{
_state = await DataSourceService.ResetDefaultsAsync();
Snackbar.Add(T("Einkaufsdatenquellen auf Defaults gesetzt.", "Purchasing data sources restored to defaults."), Severity.Info);
});
}
private async Task TestConnectionAsync()
{
await RunAsync(async () =>
{
var result = await DataSourceService.TestConnectionAsync(_state);
Snackbar.Add(TranslateConnectionMessage(result.Message), result.Success ? Severity.Success : result.Warning ? Severity.Warning : Severity.Error);
});
}
private async Task RunAsync(Func<Task> action)
{
if (_busy)
return;
_busy = true;
try
{
await action();
}
catch (Exception ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
finally
{
_busy = false;
}
}
private void AddSource()
=> _state.Sources.Add(new SapSourceDefinition { Alias = "NEW", EntitySet = "NewEntitySet", IsActive = true, SortOrder = (_state.Sources.Count + 1) * 10 });
private void AddJoin()
=> _state.Joins.Add(new SapJoinDefinition { LeftAlias = "EKKO", RightAlias = "NEW", LeftKeys = "Key", RightKeys = "Key", JoinType = "Left", IsActive = true, SortOrder = (_state.Joins.Count + 1) * 10 });
private void AddMapping()
=> _state.Mappings.Add(new SapFieldMapping { TargetField = "NewField", SourceExpression = "Alias.Field", IsActive = true, SortOrder = (_state.Mappings.Count + 1) * 10 });
private void RemoveSource(SapSourceDefinition source) => _state.Sources.Remove(source);
private void RemoveJoin(SapJoinDefinition join) => _state.Joins.Remove(join);
private void RemoveMapping(SapFieldMapping mapping) => _state.Mappings.Remove(mapping);
private string T(string german, string english) => UiText.Text(german, english);
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "-" : value;
private string TranslateConnectionMessage(string message)
{
if (string.IsNullOrWhiteSpace(message))
return string.Empty;
if (message.Contains("Keine SAP Service URL gepflegt", StringComparison.OrdinalIgnoreCase))
return T("Keine SAP Service URL gepflegt.", "No SAP service URL maintained.");
if (message.Contains("Keine SAP Gateway Zugangsdaten gepflegt", StringComparison.OrdinalIgnoreCase))
return T("Keine SAP Gateway Zugangsdaten gepflegt.", "No SAP Gateway credentials maintained.");
if (message.Contains("SAP OData Verbindung erfolgreich", StringComparison.OrdinalIgnoreCase))
return T("SAP OData Verbindung erfolgreich.", "SAP OData connection successful.");
if (message.StartsWith("SAP OData Verbindung fehlgeschlagen", StringComparison.OrdinalIgnoreCase))
return message.Replace("SAP OData Verbindung fehlgeschlagen", T("SAP OData Verbindung fehlgeschlagen", "SAP OData connection failed"), StringComparison.OrdinalIgnoreCase);
return message;
}
}
<style>
.purchasing-muted {
color: var(--mud-palette-text-secondary);
}
.purchasing-flow {
min-height: 86px;
}
.purchasing-source-row {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 7px 0;
border-bottom: 1px solid var(--mud-palette-lines-default);
}
.purchasing-source-row:last-child {
border-bottom: 0;
}
</style>
@@ -0,0 +1,223 @@
@inject TrafagSalesExporter.Services.IUiTextService UiText
@using TrafagSalesExporter.Models
<MudPaper Class="pa-3 purchasing-section-shell" Outlined="true">
<div class="purchasing-section-head">
<div>
<MudText Typo="Typo.h6">@T(TitleDe, TitleEn)</MudText>
<MudText Typo="Typo.body2" Class="purchasing-section-muted">@T(DescriptionDe, DescriptionEn)</MudText>
</div>
<MudChip T="string" Color="@ResolveSectionColor()" Variant="Variant.Outlined" Size="Size.Small">@ResolveSectionStatus()</MudChip>
</div>
<MudGrid Spacing="2" Class="mb-3">
@foreach (var kpi in Kpis)
{
<MudItem xs="12" sm="6" lg="3">
<MudPaper Class="pa-3 purchasing-section-kpi" Outlined="true">
<MudText Typo="Typo.caption" Class="purchasing-section-muted">@T(kpi.LabelDe, kpi.LabelEn)</MudText>
<MudText Typo="Typo.h6">@kpi.Value</MudText>
<MudText Typo="Typo.caption">@T(kpi.DetailDe, kpi.DetailEn)</MudText>
</MudPaper>
</MudItem>
}
</MudGrid>
<MudGrid Spacing="2" Class="mb-3">
<MudItem xs="12" lg="7">
<MudPaper Class="pa-3 purchasing-section-panel" Outlined="true">
<div class="purchasing-section-panel-head">
<MudText Typo="Typo.subtitle1">@T(ChartTitleDe, ChartTitleEn)</MudText>
<MudIcon Icon="@Icons.Material.Filled.StackedBarChart" Color="Color.Info" Size="Size.Small" />
</div>
<div class="purchasing-bars">
@foreach (var item in ChartRows)
{
<div class="purchasing-bar-row">
<div class="purchasing-bar-label">@item.Label</div>
<div class="purchasing-bar-track">
<div class="purchasing-bar-fill" style="@($"width:{BuildWidth(item.Percent)}%; background:{item.Color}")"></div>
</div>
<div class="purchasing-bar-value">@item.Value</div>
</div>
}
</div>
</MudPaper>
</MudItem>
<MudItem xs="12" lg="5">
<MudPaper Class="pa-3 purchasing-section-panel" Outlined="true">
<div class="purchasing-section-panel-head">
<MudText Typo="Typo.subtitle1">@T("Datenstatus", "Data status")</MudText>
<MudIcon Icon="@Icons.Material.Filled.Route" Color="Color.Info" Size="Size.Small" />
</div>
@foreach (var status in StatusRows)
{
<div class="purchasing-status-row">
<MudIcon Icon="@status.Icon" Color="@status.Color" Size="Size.Small" />
<span>@T(status.LabelDe, status.LabelEn)</span>
<strong>@status.Value</strong>
</div>
}
</MudPaper>
</MudItem>
</MudGrid>
<MudTable Items="@DetailRows" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>@T("Bereich", "Area")</MudTh>
<MudTh>@T("Wert", "Value")</MudTh>
<MudTh>@T("Dimension", "Dimension")</MudTh>
<MudTh>@T("Quelle", "Source")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@T(context.LabelDe, context.LabelEn)</MudTd>
<MudTd><strong>@context.Value</strong></MudTd>
<MudTd>@context.Dimension</MudTd>
<MudTd>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="@ResolveSourceColor(context.Source)">
@context.Source
</MudChip>
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
@code {
[Parameter, EditorRequired] public string TitleDe { get; set; } = string.Empty;
[Parameter, EditorRequired] public string TitleEn { get; set; } = string.Empty;
[Parameter, EditorRequired] public string DescriptionDe { get; set; } = string.Empty;
[Parameter, EditorRequired] public string DescriptionEn { get; set; } = string.Empty;
[Parameter, EditorRequired] public string ChartTitleDe { get; set; } = string.Empty;
[Parameter, EditorRequired] public string ChartTitleEn { get; set; } = string.Empty;
[Parameter, EditorRequired] public IReadOnlyList<PurchasingSectionKpi> Kpis { get; set; } = [];
[Parameter, EditorRequired] public IReadOnlyList<PurchasingSectionChartRow> ChartRows { get; set; } = [];
[Parameter, EditorRequired] public IReadOnlyList<PurchasingSectionStatusRow> StatusRows { get; set; } = [];
[Parameter, EditorRequired] public IReadOnlyList<PurchasingSectionDetailRow> DetailRows { get; set; } = [];
private string T(string german, string english) => UiText.Text(german, english);
private static string BuildWidth(double percent)
=> Math.Clamp(percent, 3d, 100d).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
private static Color ResolveSourceColor(string source)
=> source.Equals("SAP live", StringComparison.OrdinalIgnoreCase)
? Color.Success
: source.Equals("Wartet auf SAP", StringComparison.OrdinalIgnoreCase)
? Color.Warning
: Color.Primary;
private Color ResolveSectionColor()
=> DetailRows.Any(row => row.Source.Equals("SAP live", StringComparison.OrdinalIgnoreCase))
? Color.Success
: DetailRows.Any(row => row.Source.Equals("Simulation", StringComparison.OrdinalIgnoreCase))
? Color.Info
: Color.Warning;
private string ResolveSectionStatus()
=> DetailRows.Any(row => row.Source.Equals("SAP live", StringComparison.OrdinalIgnoreCase))
? T("Live + Analyse", "Live + analysis")
: DetailRows.Any(row => row.Source.Equals("Simulation", StringComparison.OrdinalIgnoreCase))
? T("Simulation aktiv", "Simulation active")
: T("Wartet auf SAP", "Waiting for SAP");
}
<style>
.purchasing-section-muted {
color: var(--mud-palette-text-secondary);
}
.purchasing-section-shell {
border-radius: 8px;
background:
linear-gradient(180deg, rgba(21,101,192,.045), rgba(21,101,192,0) 210px),
var(--mud-palette-surface);
}
.purchasing-section-head,
.purchasing-section-panel-head {
display: flex;
align-items: start;
justify-content: space-between;
gap: 14px;
}
.purchasing-section-head {
margin-bottom: 14px;
}
.purchasing-section-panel-head {
align-items: center;
margin-bottom: 12px;
}
.purchasing-section-kpi {
min-height: 104px;
border-top: 4px solid var(--mud-palette-primary);
background: var(--mud-palette-surface);
}
.purchasing-section-panel {
min-height: 240px;
background: rgba(255,255,255,.02);
}
.purchasing-bars {
display: grid;
gap: 10px;
}
.purchasing-bar-row,
.purchasing-status-row {
display: grid;
grid-template-columns: minmax(120px, 1fr) 2fr auto;
gap: 10px;
align-items: center;
}
.purchasing-status-row {
grid-template-columns: 28px minmax(120px, 1fr) auto;
padding: 9px 0;
border-bottom: 1px solid var(--mud-palette-lines-default);
}
.purchasing-status-row:last-child {
border-bottom: 0;
}
.purchasing-bar-track {
height: 24px;
background: rgba(0,0,0,.08);
border-radius: 4px;
overflow: hidden;
}
.purchasing-bar-fill {
height: 100%;
border-radius: 4px;
min-width: 26px;
box-shadow: inset 0 -8px 16px rgba(0,0,0,.12);
}
.purchasing-bar-value {
font-weight: 700;
text-align: right;
}
.purchasing-bar-label {
min-width: 0;
overflow-wrap: anywhere;
line-height: 1.2;
}
@@media (max-width: 760px) {
.purchasing-bar-row,
.purchasing-status-row,
.purchasing-section-head {
grid-template-columns: 1fr;
}
.purchasing-section-head,
.purchasing-section-panel-head {
display: grid;
}
}
</style>
@@ -1,18 +1,45 @@
@page "/settings"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@page "/settings"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject SharePointUploadService SpService
@inject TimerBackgroundService TimerService
@inject ISettingsPageService SettingsPageActions
@inject IJSRuntime JS
@inject ISnackbar Snackbar
@inject IUiTextService UiText
<PageTitle>Settings</PageTitle>
<PageTitle>@T("Einstellungen", "Settings")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Settings</MudText>
<MudText Typo="Typo.h4" Class="mb-4">@T("Einstellungen", "Settings")</MudText>
<MudText Typo="Typo.h5" Class="mb-2">@T("Konfiguration Import/Export", "Import/export configuration")</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="6">
<MudCheckBox @bind-Value="_includeSecretsInExport" Label="@T("Mit Secrets exportieren", "Export with secrets")" />
<MudText Typo="Typo.caption">
@T("Wenn deaktiviert, bleiben Passwoerter und Secrets beim Export leer. Beim Import ohne Secrets werden bestehende Secrets auf dem Zielsystem beibehalten.",
"If disabled, passwords and secrets remain empty during export. When importing without secrets, existing secrets on the target system are kept.")
</MudText>
</MudItem>
<MudItem xs="12" md="6">
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportConfiguration"
StartIcon="@Icons.Material.Filled.Download" Disabled="_exportingConfig">
@(_exportingConfig ? T("Exportiere...", "Exporting...") : T("Konfiguration exportieren", "Export configuration"))
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Warning" HtmlTag="label"
StartIcon="@Icons.Material.Filled.UploadFile" Disabled="_importingConfig">
@(_importingConfig ? T("Importiere...", "Importing...") : T("Konfiguration importieren", "Import configuration"))
<InputFile OnChange="ImportConfiguration" accept=".json,application/json" style="display:none" />
</MudButton>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
@* SharePoint Config *@
<MudText Typo="Typo.h5" Class="mb-2">SharePoint Konfiguration</MudText>
<MudText Typo="Typo.h5" Class="mb-2">@T("SharePoint Konfiguration", "SharePoint configuration")</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="6">
@@ -21,6 +48,11 @@
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_spConfig.ExportFolder" Label="Export Folder" />
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_spConfig.CentralExportFolder"
Label="Central Export Folder"
HelperText="@T("Optional. Wenn leer, wird weiterhin Export Folder/Alle verwendet.", "Optional. If empty, Export Folder/All is still used.")" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.TenantId" Label="Tenant ID" />
</MudItem>
@@ -34,93 +66,341 @@
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSharePoint"
StartIcon="@Icons.Material.Filled.Save">
Speichern
@T("Speichern", "Save")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="TestSharePoint"
StartIcon="@Icons.Material.Filled.NetworkCheck" Disabled="_testingSp">
@if (_testingSp)
{
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
@("Teste...")
@T("Teste...", "Testing...")
}
else
{
@("SharePoint Verbindung testen")
@T("SharePoint Verbindung testen", "Test SharePoint connection")
}
</MudButton>
</MudStack>
</MudItem>
@if (!string.IsNullOrWhiteSpace(_sharePointTestPreview))
{
<MudItem xs="12">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mt-3">
<div><b>@T("Testvorschau", "Test preview")</b></div>
<div style="white-space: pre-wrap">@_sharePointTestPreview</div>
</MudAlert>
</MudItem>
}
</MudGrid>
</MudPaper>
<MudText Typo="Typo.h5" Class="mb-2">@T("Quellsysteme", "Source systems")</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined">
@T("Diese Zugangsdaten werden pro Quellsystem als Standard verwendet. Ein Standort kann sie bei Bedarf mit eigenen Overrides ueberschreiben.",
"These credentials are used as defaults per source system. A site can override them if needed.")
</MudAlert>
</MudItem>
<MudItem xs="12">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="AddSourceSystem"
StartIcon="@Icons.Material.Filled.Add" Class="mb-3">
@T("Quellsystem hinzufuegen", "Add source system")
</MudButton>
<MudTable Items="_sourceSystems" Dense Hover Striped Breakpoint="Breakpoint.Md">
<HeaderContent>
<MudTh>Code</MudTh>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Anschlussart", "Connection type")</MudTh>
<MudTh>@T("Zentrale URL", "Central URL")</MudTh>
<MudTh>User</MudTh>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh>@T("Test", "Test")</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Code</MudTd>
<MudTd>@context.DisplayName</MudTd>
<MudTd>@GetConnectionKindLabel(context.ConnectionKind)</MudTd>
<MudTd>@GetServiceUrlSummary(context)</MudTd>
<MudTd>@GetUsernameSummary(context)</MudTd>
<MudTd>
@if (context.IsActive)
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else
{
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Default" Size="Size.Small" />
}
</MudTd>
<MudTd>
@if (!UsesManualImport(context))
{
<MudButton Variant="Variant.Outlined" Color="Color.Info" Size="Size.Small"
OnClick='@(() => TestCentralCredentials(context.Code))'
Disabled='@_testingSystems.Contains(context.Code)'>
@(_testingSystems.Contains(context.Code) ? T("Teste...", "Testing...") : T("Testen", "Test"))
</MudButton>
}
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Primary" Size="Size.Small"
OnClick="() => EditSourceSystem(context)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
OnClick="() => RemoveSourceSystem(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudItem>
<MudItem xs="12">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSourceSystems"
StartIcon="@Icons.Material.Filled.Save">
@T("Quellsysteme speichern", "Save source systems")
</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
<MudDialog @bind-Visible="_sourceSystemDialogVisible" Options="_sourceSystemDialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_editingSourceSystem.Id == 0 ? T("Quellsystem hinzufuegen", "Add source system") : T("Quellsystem bearbeiten", "Edit source system"))</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_editingSourceSystem.Code" Label="Code" Required />
<MudTextField @bind-Value="_editingSourceSystem.DisplayName" Label="@T("Name", "Name")" Required />
<MudSelect T="string" @bind-Value="_editingSourceSystem.ConnectionKind" Label="@T("Anschlussart", "Connection type")" Required>
@foreach (var kind in SourceSystemConnectionKinds.All)
{
<MudSelectItem Value="@kind">@GetConnectionKindLabel(kind)</MudSelectItem>
}
</MudSelect>
@if (UsesSapGateway(_editingSourceSystem))
{
<MudTextField @bind-Value="_editingSourceSystem.CentralServiceUrl" Label="Zentrale SAP Service URL"
HelperText="@T("Zentrale Standard-URL fuer SAP Gateway. Ein Standort darf sie nur bei Bedarf ueberschreiben.", "Central default URL for SAP Gateway. A site may override it only if needed.")" />
}
<MudTextField @bind-Value="_editingSourceSystem.CentralUsername" Label="@T("Zentraler Username", "Central username")" />
<MudTextField @bind-Value="_editingSourceSystem.CentralPassword" Label="@T("Zentrales Passwort", "Central password")" InputType="InputType.Password" />
<MudCheckBox @bind-Value="_editingSourceSystem.IsActive" Label="@T("Aktiv", "Active")" />
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseSourceSystemDialog">@T("Abbrechen", "Cancel")</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSourceSystemEdit">@T("Uebernehmen", "Apply")</MudButton>
</DialogActions>
</MudDialog>
<MudText Typo="Typo.h5" Class="mb-2">@T("Wechselkurse", "Exchange rates")</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudText Typo="Typo.body2" Class="mb-3">
@((MarkupString)T("Diese Kurstabelle wird von der Transformation <b>ConvertCurrency</b> verwendet. Gleiche Waehrung rechnet automatisch mit Faktor 1.",
"This rate table is used by the <b>ConvertCurrency</b> transformation. Same-currency conversion automatically uses factor 1."))
</MudText>
<MudStack Row Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="AddExchangeRate"
StartIcon="@Icons.Material.Filled.Add">
@T("Kurs hinzufuegen", "Add rate")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="RefreshEcbRates"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_refreshingExchangeRates">
@(_refreshingExchangeRates ? T("Aktualisiere ECB-Kurse...", "Refreshing ECB rates...") : T("Refresh Kurse", "Refresh rates"))
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExchangeRates"
StartIcon="@Icons.Material.Filled.Save">
@T("Kurse speichern", "Save rates")
</MudButton>
</MudStack>
<MudTable Items="_exchangeRates" Hover="true" Breakpoint="Breakpoint.Md">
<HeaderContent>
<MudTh>@T("Von", "From")</MudTh>
<MudTh>@T("Nach", "To")</MudTh>
<MudTh>@T("Kurs", "Rate")</MudTh>
<MudTh>@T("Gueltig ab", "Valid from")</MudTh>
<MudTh>@T("Gueltig bis", "Valid to")</MudTh>
<MudTh>@T("Notiz", "Note")</MudTh>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudTextField @bind-Value="context.FromCurrency" Immediate="true" />
</MudTd>
<MudTd>
<MudTextField @bind-Value="context.ToCurrency" Immediate="true" />
</MudTd>
<MudTd>
<MudNumericField T="decimal" @bind-Value="context.Rate" Immediate="true" />
</MudTd>
<MudTd>
<MudDatePicker Date="context.ValidFrom"
DateChanged="@(value => context.ValidFrom = value ?? context.ValidFrom)"
Editable="true" />
</MudTd>
<MudTd>
<MudDatePicker Date="context.ValidTo"
DateChanged="@(value => context.ValidTo = value)"
Editable="true"
Clearable="true" />
</MudTd>
<MudTd>
<MudTextField @bind-Value="context.Notes" Immediate="true" />
</MudTd>
<MudTd>
<MudCheckBox @bind-Value="context.IsActive" />
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@(() => RemoveExchangeRate(context))" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
@* Export Settings *@
<MudText Typo="Typo.h5" Class="mb-2">Export Einstellungen</MudText>
<MudText Typo="Typo.h5" Class="mb-2">@T("Export Einstellungen", "Export settings")</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_exportSettings.DateFilter" Label="Datum-Filter (ab)"
HelperText="Format: yyyy-MM-dd" />
<MudTextField @bind-Value="_exportSettings.DateFilter" Label="@T("Datum-Filter (ab)", "Date filter from")"
HelperText="@T("Format: yyyy-MM-dd", "Format: yyyy-MM-dd")" />
</MudItem>
<MudItem xs="12" md="2">
<MudNumericField @bind-Value="_exportSettings.TimerHour" Label="Timer Stunde" Min="0" Max="23" />
<MudNumericField @bind-Value="_exportSettings.TimerHour" Label="@T("Timer Stunde", "Timer hour")" Min="0" Max="23" />
</MudItem>
<MudItem xs="12" md="2">
<MudNumericField @bind-Value="_exportSettings.TimerMinute" Label="Timer Minute" Min="0" Max="59" />
<MudNumericField @bind-Value="_exportSettings.TimerMinute" Label="@T("Timer Minute", "Timer minute")" Min="0" Max="59" />
</MudItem>
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.TimerEnabled" Label="Timer aktiviert" Color="Color.Primary" />
<MudSwitch @bind-Value="_exportSettings.TimerEnabled" Label="@T("Timer aktiviert", "Timer enabled")" Color="Color.Primary" />
</MudItem>
<MudItem xs="12" md="4">
<MudSelect T="string" @bind-Value="_exportSettings.ExchangeRateDateField"
Label="@T("Wechselkurse anwenden auf", "Apply exchange rates to")"
HelperText="@T("Datumsfeld fuer Kursgueltigkeit in Management-Analysen.", "Date field for rate validity in management analyses.")">
<MudSelectItem Value="@ExchangeRateDateFields.PostingDate">@T("PostingDate / Buchungsdatum", "PostingDate / posting date")</MudSelectItem>
<MudSelectItem Value="@ExchangeRateDateFields.InvoiceDate">@T("InvoiceDate / Rechnungsdatum", "InvoiceDate / invoice date")</MudSelectItem>
<MudSelectItem Value="@ExchangeRateDateFields.ExtractionDate">@T("ExtractionDate / Extraktionsdatum", "ExtractionDate / extraction date")</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.DebugLoggingEnabled" Label="@T("Debug Live-Logging", "Debug live logging")" Color="Color.Warning" />
<MudText Typo="Typo.caption">
@T("Schreibt zusaetzliche technische Fortschrittsmeldungen fuer HANA- und SAP-Lesevorgaenge ins Dashboard und in die Logs.",
"Writes additional technical progress messages for HANA and SAP reads to the dashboard and logs.")
</MudText>
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_exportSettings.LocalSiteExportFolder" Label="@T("Lokaler Standardpfad Standort-Dateien", "Local default path for site files")"
HelperText="@T("Wenn leer, wird ./output unter dem Programmverzeichnis verwendet.", "If empty, ./output under the application directory is used.")" />
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_exportSettings.LocalConsolidatedExportFolder" Label="@T("Lokaler Pfad Zentrale Datei", "Local path for central file")"
HelperText="@T("Optional. Wenn leer, wird der Standardpfad der Standort-Dateien verwendet.", "Optional. If empty, the default path for site files is used.")" />
</MudItem>
<MudItem xs="12">
<div class="audit-csv-settings">
<div class="audit-csv-header">
<MudIcon Icon="@Icons.Material.Filled.RuleFolder" Color="Color.Info" Size="Size.Medium" />
<div>
<MudText Typo="Typo.h6">@T("Audit-CSV / nachvollziehbarer Datenfluss", "Audit CSV / traceable data flow")</MudText>
<MudText Typo="Typo.body2">
@T("Fuer Finance und Wirtschaftspruefung: lesbare Standort-CSV nach Mapping und Konvertierung, optional als Quelle fuer zentrale Auswertungen.",
"For finance and auditors: readable site CSV after mapping and conversion, optionally as the source for central analyses.")
</MudText>
</div>
</div>
<MudGrid Spacing="2">
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.AuditCsvEnabled" Label="@T("Audit-CSV je Standort schreiben", "Write audit CSV per site")" Color="Color.Primary" />
<MudText Typo="Typo.caption">
@T("Schreibt beim Laenderexport je Standort eine Sales_ProcessedMergeInput_*.csv mit den transformierten Daten.",
"Writes one Sales_ProcessedMergeInput_*.csv per site during country export with the transformed data.")
</MudText>
</MudItem>
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.UseAuditCsvAsCentralSource" Label="@T("Zentrale Auswertung aus Audit-CSV", "Central analysis from audit CSV")" Color="Color.Warning" />
<MudText Typo="Typo.caption">
@T("Dashboard, zentrale Excel-Datei und Finance-Auswertungen lesen die neuesten Standort-CSV-Dateien statt CentralSalesRecords.",
"Dashboard, central Excel file and finance analyses read the latest site CSV files instead of CentralSalesRecords.")
</MudText>
</MudItem>
<MudItem xs="12" md="4">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Filled">
@((MarkupString)T("Audit-CSV wird immer im gleichen Ordner wie die lokalen Standort-Dateien abgelegt. Der Pfad wird oben bei <b>Lokaler Standardpfad Standort-Dateien</b> gesetzt.",
"Audit CSV is always stored in the same folder as the local site files. The path is set above under <b>Local default path for site files</b>."))
</MudAlert>
</MudItem>
</MudGrid>
</div>
</MudItem>
<MudItem xs="12">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExportSettings"
StartIcon="@Icons.Material.Filled.Save">
Speichern
@T("Speichern", "Save")
</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
@* Filename Preview *@
<MudText Typo="Typo.h5" Class="mb-2">Dateiname Vorschau</MudText>
<MudText Typo="Typo.h5" Class="mb-2">@T("Dateiname Vorschau", "Filename preview")</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.body1">
<MudIcon Icon="@Icons.Material.Filled.InsertDriveFile" Size="Size.Small" Class="mr-1" />
Sales_{"{TSC}"}_{DateTime.Now:yyyy-MM-dd}.xlsx
</MudText>
<MudText Typo="Typo.caption" Class="mt-1">
Beispiel: Sales_TRFR_@(DateTime.Now.ToString("yyyy-MM-dd")).xlsx
@T("Beispiel:", "Example:") Sales_TRFR_@(DateTime.Now.ToString("yyyy-MM-dd")).xlsx
/ Sales_ProcessedMergeInput_TRFR_@(DateTime.Now.ToString("yyyy-MM-dd")).csv
</MudText>
</MudPaper>
<style>
.audit-csv-settings {
background: #e8f3ff;
border: 1px solid #90caf9;
border-left: 6px solid #1976d2;
border-radius: 8px;
padding: 18px 20px;
}
.audit-csv-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 14px;
}
</style>
@code {
private SharePointConfig _spConfig = new();
private ExportSettings _exportSettings = new();
private List<SourceSystemDefinition> _sourceSystems = [];
private SourceSystemDefinition _editingSourceSystem = new();
private bool _testingSp;
private bool _includeSecretsInExport;
private bool _exportingConfig;
private bool _importingConfig;
private bool _refreshingExchangeRates;
private string _sharePointTestPreview = string.Empty;
private List<CurrencyExchangeRate> _exchangeRates = [];
private readonly HashSet<string> _testingSystems = [];
private bool _sourceSystemDialogVisible;
private readonly DialogOptions _sourceSystemDialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
protected override async Task OnInitializedAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig();
_exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
var state = await SettingsPageActions.LoadAsync();
_spConfig = state.SharePointConfig;
_exportSettings = state.ExportSettings;
_sourceSystems = state.SourceSystems;
_exchangeRates = state.ExchangeRates;
}
private async Task SaveSharePoint()
{
using var db = await DbFactory.CreateDbContextAsync();
var existing = await db.SharePointConfigs.FirstOrDefaultAsync();
if (existing is null)
{
db.SharePointConfigs.Add(_spConfig);
}
else
{
existing.SiteUrl = _spConfig.SiteUrl;
existing.ExportFolder = _spConfig.ExportFolder;
existing.TenantId = _spConfig.TenantId;
existing.ClientId = _spConfig.ClientId;
existing.ClientSecret = _spConfig.ClientSecret;
}
await db.SaveChangesAsync();
Snackbar.Add("SharePoint Konfiguration gespeichert", Severity.Success);
await SettingsPageActions.SaveSharePointAsync(_spConfig);
Snackbar.Add(T("SharePoint Konfiguration gespeichert", "SharePoint configuration saved"), Severity.Success);
}
private async Task TestSharePoint()
@@ -128,13 +408,12 @@
_testingSp = true;
try
{
await SpService.TestConnectionAsync(
_spConfig.TenantId, _spConfig.ClientId, _spConfig.ClientSecret, _spConfig.SiteUrl);
Snackbar.Add("SharePoint Verbindung erfolgreich!", Severity.Success);
_sharePointTestPreview = await SettingsPageActions.BuildSharePointTestPreviewAsync(_spConfig);
Snackbar.Add(T("SharePoint Verbindung erfolgreich!", "SharePoint connection successful!"), Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Verbindung fehlgeschlagen: {ex.Message}", Severity.Error);
Snackbar.Add($"{T("Verbindung fehlgeschlagen", "Connection failed")}: {ex.Message}", Severity.Error);
}
finally
{
@@ -144,21 +423,252 @@
private async Task SaveExportSettings()
{
using var db = await DbFactory.CreateDbContextAsync();
var existing = await db.ExportSettings.FirstOrDefaultAsync();
if (existing is null)
await SettingsPageActions.SaveExportSettingsAsync(_exportSettings);
Snackbar.Add(T("Export Einstellungen gespeichert", "Export settings saved"), Severity.Success);
}
private void AddSourceSystem()
{
db.ExportSettings.Add(_exportSettings);
_editingSourceSystem = new SourceSystemDefinition
{
Code = string.Empty,
DisplayName = string.Empty,
ConnectionKind = SourceSystemConnectionKinds.Hana,
IsActive = true
};
_sourceSystemDialogVisible = true;
}
private void EditSourceSystem(SourceSystemDefinition definition)
{
_editingSourceSystem = new SourceSystemDefinition
{
Id = definition.Id,
Code = definition.Code,
DisplayName = definition.DisplayName,
ConnectionKind = definition.ConnectionKind,
IsActive = definition.IsActive,
CentralServiceUrl = definition.CentralServiceUrl,
CentralUsername = definition.CentralUsername,
CentralPassword = definition.CentralPassword
};
_sourceSystemDialogVisible = true;
}
private void SaveSourceSystemEdit()
{
_editingSourceSystem.Code = NormalizeSourceSystemCode(_editingSourceSystem.Code);
_editingSourceSystem.DisplayName = NormalizeConfigValue(_editingSourceSystem.DisplayName);
_editingSourceSystem.ConnectionKind = NormalizeConnectionKind(_editingSourceSystem.ConnectionKind);
_editingSourceSystem.CentralServiceUrl = NormalizeConfigValue(_editingSourceSystem.CentralServiceUrl);
_editingSourceSystem.CentralUsername = NormalizeConfigValue(_editingSourceSystem.CentralUsername);
_editingSourceSystem.CentralPassword = _editingSourceSystem.CentralPassword ?? string.Empty;
if (string.IsNullOrWhiteSpace(_editingSourceSystem.Code) || string.IsNullOrWhiteSpace(_editingSourceSystem.DisplayName))
{
Snackbar.Add(T("Code und Name fuer das Quellsystem sind Pflicht.", "Code and name are required for the source system."), Severity.Warning);
return;
}
if (_sourceSystems.Any(x => x.Id != _editingSourceSystem.Id && x.Code == _editingSourceSystem.Code))
{
Snackbar.Add($"{T("Quellsystem-Code doppelt vorhanden", "Duplicate source-system code")}: {_editingSourceSystem.Code}", Severity.Warning);
return;
}
if (_editingSourceSystem.Id == 0)
{
_sourceSystems.Add(_editingSourceSystem);
}
else
{
existing.DateFilter = _exportSettings.DateFilter;
existing.TimerHour = _exportSettings.TimerHour;
existing.TimerMinute = _exportSettings.TimerMinute;
existing.TimerEnabled = _exportSettings.TimerEnabled;
var existing = _sourceSystems.FirstOrDefault(x => x.Id == _editingSourceSystem.Id);
if (existing is not null)
{
existing.Code = _editingSourceSystem.Code;
existing.DisplayName = _editingSourceSystem.DisplayName;
existing.ConnectionKind = _editingSourceSystem.ConnectionKind;
existing.IsActive = _editingSourceSystem.IsActive;
existing.CentralServiceUrl = _editingSourceSystem.CentralServiceUrl;
existing.CentralUsername = _editingSourceSystem.CentralUsername;
existing.CentralPassword = _editingSourceSystem.CentralPassword;
}
await db.SaveChangesAsync();
TimerService.Recalculate();
Snackbar.Add("Export Einstellungen gespeichert", Severity.Success);
}
_sourceSystems = _sourceSystems.OrderBy(x => x.Code).ToList();
_sourceSystemDialogVisible = false;
}
private void CloseSourceSystemDialog()
{
_sourceSystemDialogVisible = false;
}
private void RemoveSourceSystem(SourceSystemDefinition definition)
{
_sourceSystems.Remove(definition);
}
private async Task SaveSourceSystems()
{
try
{
_sourceSystems = await SettingsPageActions.SaveSourceSystemsAsync(_sourceSystems);
Snackbar.Add(T("Quellsysteme gespeichert", "Source systems saved"), Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add(ex.Message, Severity.Warning);
}
}
private void AddExchangeRate()
{
_exchangeRates.Add(new CurrencyExchangeRate
{
FromCurrency = "USD",
ToCurrency = "EUR",
Rate = 1m,
ValidFrom = DateTime.Today,
IsActive = true
});
}
private void RemoveExchangeRate(CurrencyExchangeRate rate)
{
_exchangeRates.Remove(rate);
}
private async Task SaveExchangeRates()
{
_exchangeRates = await SettingsPageActions.SaveExchangeRatesAsync(_exchangeRates);
Snackbar.Add(T("Wechselkurse gespeichert", "Exchange rates saved"), Severity.Success);
}
private async Task RefreshEcbRates()
{
if (_refreshingExchangeRates)
return;
_refreshingExchangeRates = true;
try
{
var result = await SettingsPageActions.RefreshEcbRatesAsync();
_exchangeRates = result.ExchangeRates;
Snackbar.Add($"{T("ECB-Kurse aktualisiert", "ECB rates refreshed")}: {result.ImportedCount} {T("Kurse vom", "rates from")} {result.RateDate:yyyy-MM-dd}.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"{T("ECB-Kursimport fehlgeschlagen", "ECB rate import failed")}: {ex.Message}", Severity.Error);
}
finally
{
_refreshingExchangeRates = false;
}
}
private async Task ExportConfiguration()
{
if (_exportingConfig)
return;
_exportingConfig = true;
try
{
var json = await SettingsPageActions.ExportConfigurationAsync(_includeSecretsInExport);
var suffix = _includeSecretsInExport ? "with-secrets" : "without-secrets";
var fileName = $"trafag-config-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{suffix}.json";
await JS.InvokeVoidAsync("trafagDownload.saveTextFile", fileName, json, "application/json;charset=utf-8");
Snackbar.Add(T("Konfiguration exportiert", "Configuration exported"), Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"{T("Export fehlgeschlagen", "Export failed")}: {ex.Message}", Severity.Error);
}
finally
{
_exportingConfig = false;
}
}
private async Task ImportConfiguration(InputFileChangeEventArgs args)
{
if (_importingConfig)
return;
_importingConfig = true;
try
{
var file = args.File;
await using var stream = file.OpenReadStream(5 * 1024 * 1024);
using var reader = new StreamReader(stream);
var json = await reader.ReadToEndAsync();
var state = await SettingsPageActions.ImportConfigurationAsync(json);
_spConfig = state.SharePointConfig;
_exportSettings = state.ExportSettings;
_sourceSystems = state.SourceSystems;
_exchangeRates = state.ExchangeRates;
Snackbar.Add(T("Konfiguration importiert", "Configuration imported"), Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"{T("Import fehlgeschlagen", "Import failed")}: {ex.Message}", Severity.Error);
}
finally
{
_importingConfig = false;
}
}
private async Task TestCentralCredentials(string sourceSystem)
{
var definition = _sourceSystems.FirstOrDefault(x => string.Equals(x.Code, sourceSystem, StringComparison.OrdinalIgnoreCase));
if (definition is null)
{
Snackbar.Add($"{T("Quellsystem nicht gefunden", "Source system not found")}: {sourceSystem}", Severity.Warning);
return;
}
if (!_testingSystems.Add(sourceSystem))
return;
try
{
var result = await SettingsPageActions.TestCentralCredentialsAsync(definition);
Snackbar.Add(result.Message, result.Success ? Severity.Success : result.Warning ? Severity.Warning : Severity.Error);
}
finally
{
_testingSystems.Remove(sourceSystem);
}
}
private static string NormalizeSourceSystemCode(string? code) => Services.SettingsPageService.NormalizeSourceSystemCode(code);
private static string NormalizeConnectionKind(string? connectionKind) => Services.SettingsPageService.NormalizeConnectionKind(connectionKind);
private static string GetConnectionKindLabel(string connectionKind) => connectionKind switch
{
SourceSystemConnectionKinds.Hana => "HANA",
SourceSystemConnectionKinds.SapGateway => "SAP Gateway",
SourceSystemConnectionKinds.ManualExcel => "Manual Excel",
_ => connectionKind
};
private static bool UsesManualImport(SourceSystemDefinition definition)
=> string.Equals(definition.ConnectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase);
private static bool UsesSapGateway(SourceSystemDefinition definition)
=> string.Equals(definition.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase);
private static string GetServiceUrlSummary(SourceSystemDefinition definition)
=> string.IsNullOrWhiteSpace(definition.CentralServiceUrl) ? "-" : definition.CentralServiceUrl;
private static string GetUsernameSummary(SourceSystemDefinition definition)
=> string.IsNullOrWhiteSpace(definition.CentralUsername) ? "-" : definition.CentralUsername;
private static string NormalizeConfigValue(string? value) => Services.SettingsPageService.NormalizeConfigValue(value);
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,135 @@
@page "/source-viewer"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.WebUtilities
@inject IWebHostEnvironment Environment
@inject NavigationManager Navigation
@inject TrafagSalesExporter.Services.IUiTextService UiText
<PageTitle>@T("Source Viewer", "Source Viewer")</PageTitle>
<MudStack Spacing="2">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5">@T("Source Viewer", "Source Viewer")</MudText>
<MudButton Variant="Variant.Outlined" Href="/transformations">
@T("Zurueck zur Transformation", "Back to transformations")
</MudButton>
</MudStack>
@if (!string.IsNullOrWhiteSpace(_requestedPath))
{
<MudText Typo="Typo.body2">
@T("Datei:", "File:")
<MudText Inline="true" Typo="Typo.body2"><code>@_requestedPath</code></MudText>
</MudText>
}
@if (!string.IsNullOrWhiteSpace(_requestedType))
{
<MudText Typo="Typo.body2">
@T("Klasse:", "Class:")
<MudText Inline="true" Typo="Typo.body2"><code>@_requestedType</code></MudText>
@if (_highlightLineNumber is not null)
{
<span> @T("bei Zeile", "at line") @_highlightLineNumber</span>
}
</MudText>
}
@if (!string.IsNullOrWhiteSpace(_error))
{
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">@_error</MudAlert>
}
else if (string.IsNullOrWhiteSpace(_content))
{
<MudProgressCircular Indeterminate="true" />
}
else
{
<MudPaper Class="pa-4">
<div style="font-family: Consolas, monospace; font-size: 0.9rem;">
@foreach (var line in _lines)
{
<div id="@GetLineAnchor(line.Number)"
style="@GetLineStyle(line.Number)">
<span style="display:inline-block; width:4rem; color:#666;">@line.Number.ToString("0000")</span>
<span>@line.Text</span>
</div>
}
</div>
</MudPaper>
@if (_highlightLineNumber is not null)
{
<script>
location.hash = '@GetLineAnchor(_highlightLineNumber.Value)';
</script>
}
}
</MudStack>
@code {
private string? _requestedPath;
private string? _requestedType;
private string? _content;
private string? _error;
private List<SourceLine> _lines = [];
private int? _highlightLineNumber;
protected override void OnInitialized()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = QueryHelpers.ParseQuery(uri.Query);
_requestedPath = query.TryGetValue("path", out var value) ? value.ToString() : null;
_requestedType = query.TryGetValue("type", out var typeValue) ? typeValue.ToString() : null;
if (string.IsNullOrWhiteSpace(_requestedPath))
{
_error = T("Kein Dateipfad angegeben.", "No file path provided.");
return;
}
if (_requestedPath.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(_requestedPath))
{
_error = T("Ungueltiger Dateipfad.", "Invalid file path.");
return;
}
var fullPath = Path.Combine(Environment.ContentRootPath, _requestedPath.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(fullPath))
{
_error = string.Format(T("Datei nicht gefunden: {0}", "File not found: {0}"), _requestedPath);
return;
}
_content = File.ReadAllText(fullPath);
_lines = _content
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Split('\n')
.Select((text, index) => new SourceLine(index + 1, text))
.ToList();
if (!string.IsNullOrWhiteSpace(_requestedType))
{
_highlightLineNumber = _lines
.FirstOrDefault(x => x.Text.Contains($"class {_requestedType}", StringComparison.Ordinal) ||
x.Text.Contains($"sealed class {_requestedType}", StringComparison.Ordinal) ||
x.Text.Contains($"public class {_requestedType}", StringComparison.Ordinal) ||
x.Text.Contains($"public sealed class {_requestedType}", StringComparison.Ordinal))
?.Number;
}
}
private static string GetLineAnchor(int lineNumber) => $"line-{lineNumber}";
private string GetLineStyle(int lineNumber)
{
var highlight = _highlightLineNumber == lineNumber;
return highlight
? "background-color:#fff3cd; white-space:pre-wrap;"
: "white-space:pre-wrap;";
}
private sealed record SourceLine(int Number, string Text);
private string T(string german, string english) => UiText.Text(german, english);
}
File diff suppressed because it is too large Load Diff
@@ -1,23 +1,30 @@
@page "/transformations"
@using Microsoft.EntityFrameworkCore
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using System.Reflection
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Models
@inject IDbContextFactory<AppDbContext> DbFactory
@using TrafagSalesExporter.Services
@inject ITransformationsPageService TransformationsPageActions
@inject ITransformationCatalog TransformationCatalog
@inject ISnackbar Snackbar
@inject IUiTextService UiText
<PageTitle>Transformationen</PageTitle>
<PageTitle>@T("Transformationen", "Transformations")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Transformer Ansicht</MudText>
<MudText Typo="Typo.body1" Class="mb-4">Definiere pro Quellsystem (SAP, BI1, SAGE) Feld-Remapping und Transformationen.</MudText>
<MudText Typo="Typo.h4" Class="mb-4">@T("Transformer Ansicht", "Transformation view")</MudText>
<MudText Typo="Typo.body1" Class="mb-4">@T("Definiere pro Quellsystem einfache Feldregeln und komplexe record-basierte Strategien.", "Define simple field rules and complex record-based strategies per source system.")</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
`Value`-Regeln arbeiten feldweise. `Record`-Regeln rufen eine registrierte C#-Strategie auf und koennen mehrere Felder eines Datensatzes verwenden.
</MudAlert>
<MudStack Row="true" Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="AddRule">
Regel hinzufügen
@T("Regel hinzufuegen", "Add rule")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAllAsync">
Alle speichern
@T("Alle speichern", "Save all")
</MudButton>
</MudStack>
@@ -25,54 +32,99 @@
<HeaderContent>
<MudTh>Aktiv</MudTh>
<MudTh>System</MudTh>
<MudTh>Scope</MudTh>
<MudTh>Source</MudTh>
<MudTh>Target</MudTh>
<MudTh>Typ</MudTh>
<MudTh>Typ / Klasse</MudTh>
<MudTh>Argument</MudTh>
<MudTh>Sort</MudTh>
<MudTh>Info</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudCheckBox @bind-Value="context.IsActive" /></MudTd>
<MudTd>
<MudSelect T="string" Value="@context.SourceSystem" ValueChanged="@(v => context.SourceSystem = v)" Dense>
@foreach (var system in _systems)
@foreach (var system in _sourceSystems.Where(x => x.IsActive))
{
<MudSelectItem Value="system">@system</MudSelectItem>
<MudSelectItem Value="@system.Code">@system.DisplayName (@system.Code)</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.RuleScope" ValueChanged="@(v => ChangeRuleScope(context, v))" Dense>
@foreach (var scope in _ruleScopes)
{
<MudSelectItem Value="@scope">@scope</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
@if (IsRecordScope(context))
{
<MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small" Text="Record-Regel" />
}
else
{
<MudSelect T="string" Value="@context.SourceField" ValueChanged="@(v => context.SourceField = v)" Dense>
@foreach (var field in _recordFields)
{
<MudSelectItem Value="field">@field</MudSelectItem>
<MudSelectItem Value="@field">@field</MudSelectItem>
}
</MudSelect>
}
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.TargetField" ValueChanged="@(v => context.TargetField = v)" Dense>
@foreach (var field in _recordFields)
{
<MudSelectItem Value="field">@field</MudSelectItem>
<MudSelectItem Value="@field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.TransformationType" ValueChanged="@(v => context.TransformationType = v)" Dense>
@foreach (var type in _types)
@{
var availableTypes = GetTypesForScope(context.RuleScope);
}
<MudSelect T="string"
@key="@GetTypeSelectKey(context)"
Value="@context.TransformationType"
ValueChanged="@(v => context.TransformationType = v)"
Dense
HelperText="@GetTypeHelperText(context)">
@foreach (var type in availableTypes)
{
<MudSelectItem Value="type">@type</MudSelectItem>
<MudSelectItem Value="@type.Key">@(IsRecordScope(context) ? $"Klasse: {type.Key}" : type.Key)</MudSelectItem>
}
</MudSelect>
@if (IsRecordScope(context))
{
<MudText Typo="Typo.caption" Class="mt-1">
Hier waehlt man die registrierte C#-Strategie.
</MudText>
}
</MudTd>
<MudTd>
<MudTextField Value="@context.Argument" ValueChanged="@(v => context.Argument = v)" Dense
HelperText="Replace: alt=>neu" />
<MudTextField T="string" Value="@context.Argument" ValueChanged="@(v => context.Argument = v)"
HelperText="@GetArgumentHelperText(context)" />
</MudTd>
<MudTd>
<MudNumericField T="int" Value="@context.SortOrder" ValueChanged="@(v => context.SortOrder = v)" Dense />
</MudTd>
<MudTd>
@{
var catalogItem = GetCatalogItem(context);
}
<MudStack Spacing="1">
<MudText Typo="Typo.caption">@((catalogItem?.Description ?? T("Keine Beschreibung.", "No description.")) )</MudText>
<MudButton Variant="Variant.Text" Color="Color.Info" Size="Size.Small"
StartIcon="@Icons.Material.Filled.Code"
Disabled="@(catalogItem is null)"
OnClick="() => ShowCode(context)">
@T("Code anzeigen", "Show code")
</MudButton>
</MudStack>
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
OnClick="() => RemoveRule(context)" />
@@ -81,9 +133,50 @@
</MudTable>
</MudPaper>
<MudDialog @bind-Visible="_codeDialogVisible" Options="_codeDialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@T("Transformationscode", "Transformation code")</MudText>
</TitleContent>
<DialogContent>
@if (_selectedCatalogItem is not null)
{
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle2">@_selectedCatalogItem.Key (@_selectedCatalogItem.RuleScope)</MudText>
<MudText Typo="Typo.body2">@_selectedCatalogItem.Description</MudText>
<MudText Typo="Typo.caption">Klasse: @_selectedCatalogItem.TypeName</MudText>
<MudText Typo="Typo.caption">
Datei:
<MudLink Href="@GetSourceViewerUrl(_selectedCatalogItem.SourceFile, _selectedCatalogItem.TypeName)" Target="_blank">
@_selectedCatalogItem.SourceFile
</MudLink>
</MudText>
<MudPaper Class="pa-3">
<MudText Typo="Typo.caption">Snippet</MudText>
<pre style="margin:0; white-space:pre-wrap;">@_selectedCatalogItem.CodeSnippet</pre>
</MudPaper>
@if (_selectedRule is not null)
{
<MudPaper Class="pa-3">
<MudText Typo="Typo.caption">Aktuelle Regel</MudText>
<MudText Typo="Typo.body2">System: @_selectedRule.SourceSystem</MudText>
<MudText Typo="Typo.body2">Target: @_selectedRule.TargetField</MudText>
@if (!string.IsNullOrWhiteSpace(_selectedRule.SourceField))
{
<MudText Typo="Typo.body2">Source: @_selectedRule.SourceField</MudText>
}
<MudText Typo="Typo.body2">Argument: @(string.IsNullOrWhiteSpace(_selectedRule.Argument) ? "-" : _selectedRule.Argument)</MudText>
</MudPaper>
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" OnClick="CloseCodeDialog">@T("Schliessen", "Close")</MudButton>
</DialogActions>
</MudDialog>
@code {
private readonly string[] _systems = ["SAP", "BI1", "SAGE"];
private readonly string[] _types = ["Copy", "Uppercase", "Lowercase", "Prefix", "Suffix", "Replace", "Constant"];
private readonly string[] _ruleScopes = ["Value", "Record"];
private readonly string[] _recordFields = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name)
@@ -91,16 +184,33 @@
.ToArray();
private List<FieldTransformationRule> _rules = new();
private List<SourceSystemDefinition> _sourceSystems = [];
private IReadOnlyList<TransformationCatalogItem> _catalogItems = [];
private bool _codeDialogVisible;
private FieldTransformationRule? _selectedRule;
private TransformationCatalogItem? _selectedCatalogItem;
private readonly DialogOptions _codeDialogOptions = new() { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
protected override async Task OnInitializedAsync()
{
_catalogItems = TransformationCatalog.GetAll();
await LoadAsync();
}
private async Task LoadAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_rules = await db.FieldTransformationRules.OrderBy(r => r.SortOrder).ThenBy(r => r.Id).ToListAsync();
var state = await TransformationsPageActions.LoadAsync();
_sourceSystems = state.SourceSystems;
_rules = state.Rules;
foreach (var rule in _rules)
{
rule.RuleScope = string.IsNullOrWhiteSpace(rule.RuleScope) ? "Value" : rule.RuleScope;
if (!GetTypesForScope(rule.RuleScope).Any(x => string.Equals(x.Key, rule.TransformationType, StringComparison.OrdinalIgnoreCase)))
{
rule.TransformationType = GetTypesForScope(rule.RuleScope).FirstOrDefault()?.Key ?? "Copy";
}
}
}
private void AddRule()
@@ -108,7 +218,8 @@
var nextSort = _rules.Count == 0 ? 10 : _rules.Max(r => r.SortOrder) + 10;
_rules.Add(new FieldTransformationRule
{
SourceSystem = "SAP",
SourceSystem = _sourceSystems.FirstOrDefault(x => x.IsActive)?.Code ?? "SAP",
RuleScope = "Value",
SourceField = nameof(SalesRecord.Material),
TargetField = nameof(SalesRecord.Material),
TransformationType = "Copy",
@@ -124,14 +235,79 @@
private async Task SaveAllAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
db.FieldTransformationRules.RemoveRange(db.FieldTransformationRules);
await db.SaveChangesAsync();
_rules = await TransformationsPageActions.SaveAllAsync(_rules);
db.FieldTransformationRules.AddRange(_rules);
await db.SaveChangesAsync();
Snackbar.Add("Transformationsregeln gespeichert.", Severity.Success);
Snackbar.Add(T("Transformationsregeln gespeichert.", "Transformation rules saved."), Severity.Success);
await LoadAsync();
}
private IReadOnlyList<TransformationCatalogItem> GetTypesForScope(string? ruleScope)
{
var scope = string.IsNullOrWhiteSpace(ruleScope) ? "Value" : ruleScope;
return TransformationCatalog.GetByScope(scope);
}
private static bool IsRecordScope(FieldTransformationRule rule)
=> string.Equals(rule.RuleScope, "Record", StringComparison.OrdinalIgnoreCase);
private void ChangeRuleScope(FieldTransformationRule rule, string scope)
{
rule.RuleScope = scope;
var firstType = GetTypesForScope(scope).FirstOrDefault()?.Key;
if (!string.IsNullOrWhiteSpace(firstType))
rule.TransformationType = firstType;
if (IsRecordScope(rule))
rule.SourceField = string.Empty;
else if (string.IsNullOrWhiteSpace(rule.SourceField))
rule.SourceField = nameof(SalesRecord.Material);
}
private string GetArgumentHelperText(FieldTransformationRule rule)
{
var item = _catalogItems.FirstOrDefault(x =>
string.Equals(x.RuleScope, rule.RuleScope, StringComparison.OrdinalIgnoreCase) &&
string.Equals(x.Key, rule.TransformationType, StringComparison.OrdinalIgnoreCase));
return item?.Description ?? T("Optionales Argument.", "Optional argument.");
}
private TransformationCatalogItem? GetCatalogItem(FieldTransformationRule rule)
=> _catalogItems.FirstOrDefault(x =>
string.Equals(x.RuleScope, rule.RuleScope, StringComparison.OrdinalIgnoreCase) &&
string.Equals(x.Key, rule.TransformationType, StringComparison.OrdinalIgnoreCase));
private void ShowCode(FieldTransformationRule rule)
{
_selectedRule = rule;
_selectedCatalogItem = GetCatalogItem(rule);
_codeDialogVisible = _selectedCatalogItem is not null;
}
private void CloseCodeDialog()
{
_codeDialogVisible = false;
_selectedRule = null;
_selectedCatalogItem = null;
}
private static string GetSourceViewerUrl(string sourceFile, string typeName)
=> $"/source-viewer?path={Uri.EscapeDataString(sourceFile)}&type={Uri.EscapeDataString(typeName)}";
private static string GetTypeSelectKey(FieldTransformationRule rule)
=> $"{rule.Id}:{rule.RuleScope}:{rule.TransformationType}";
private string GetTypeHelperText(FieldTransformationRule rule)
{
var types = GetTypesForScope(rule.RuleScope);
return types.Count == 0
? T("Keine Typen fuer diesen Scope registriert.", "No types registered for this scope.")
: IsRecordScope(rule)
? string.Format(T("Verfuegbare Klassen: {0}", "Available classes: {0}"), string.Join(", ", types.Select(x => x.Key)))
: string.Format(T("Verfuegbare Typen: {0}", "Available types: {0}"), string.Join(", ", types.Select(x => x.Key)));
}
}
@code {
private string T(string german, string english) => UiText.Text(german, english);
}
+53 -3
View File
@@ -1,6 +1,56 @@
<Router AppAssembly="typeof(Program).Assembly">
@using Microsoft.AspNetCore.Components.Authorization
@inject NavigationManager Navigation
@inject TrafagSalesExporter.Services.IFinanceCockpitAccessService FinanceAccess
<CascadingAuthenticationState>
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
@if (RequiresFinanceUnlock() && FinanceAccess.IsEnabled && !FinanceAccess.IsUnlocked)
{
<LayoutView Layout="typeof(Layout.MainLayout)">
<FinanceCockpitUnlockPanel />
</LayoutView>
}
else
{
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
<LayoutView Layout="typeof(Layout.MainLayout)">
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">
Zugriff verweigert. Bitte mit einem berechtigten Windows-/Domain-Benutzer anmelden.
</MudAlert>
</LayoutView>
</NotAuthorized>
<Authorizing>
<LayoutView Layout="typeof(Layout.MainLayout)">
<MudProgressCircular Indeterminate="true" />
</LayoutView>
</Authorizing>
</AuthorizeRouteView>
}
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
</Router>
</CascadingAuthenticationState>
@code {
private bool RequiresFinanceUnlock()
{
var path = Navigation.ToBaseRelativePath(Navigation.Uri)
.Split('?', '#')[0]
.Trim('/')
.ToLowerInvariant();
return path is
"export-dashboard" or
"management-cockpit" or
"finance-cockpit/vergleich" or
"finance-cockpit/schulung" or
"standorte" or
"transformations" or
"finance-rules" or
"settings" or
"logs" or
"source-viewer";
}
}
@@ -0,0 +1,12 @@
<section class="training-section">
<MudText Typo="Typo.h5" Class="mb-2">@Title</MudText>
@ChildContent
</section>
@code {
[Parameter]
public string Title { get; set; } = string.Empty;
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -1,9 +1,12 @@
@using System.Net.Http
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Authorization
@using Microsoft.JSInterop
@using MudBlazor
@using TrafagSalesExporter.Components
@using TrafagSalesExporter.Components.FinanceCockpit
@using TrafagSalesExporter.Components.Layout
@using TrafagSalesExporter.Models
Binary file not shown.
+12 -99
View File
@@ -1,4 +1,3 @@
using System.Data;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Models;
@@ -9,107 +8,21 @@ public class AppDbContext : DbContext
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<HanaServer> HanaServers => Set<HanaServer>();
public DbSet<SourceSystemDefinition> SourceSystemDefinitions => Set<SourceSystemDefinition>();
public DbSet<Site> Sites => Set<Site>();
public DbSet<SharePointConfig> SharePointConfigs => Set<SharePointConfig>();
public DbSet<ExportSettings> ExportSettings => Set<ExportSettings>();
public DbSet<ExportLog> ExportLogs => Set<ExportLog>();
public DbSet<AppEventLog> AppEventLogs => Set<AppEventLog>();
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
/// <summary>
/// Fügt Spalten zu existierenden Tabellen hinzu, die bei neueren Versionen
/// hinzugekommen sind. EnsureCreated aktualisiert das Schema nicht automatisch.
/// </summary>
public static void EnsureSchema(AppDbContext db)
{
AddColumnIfMissing(db, "HanaServers", "DatabaseName", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "HanaServers", "UseSsl", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "ValidateCertificate", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "AdditionalParams", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SourceSystem", "TEXT NOT NULL DEFAULT 'SAP'");
EnsureTransformationTable(db);
}
private static void AddColumnIfMissing(AppDbContext db, string table, string column, string type)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) conn.Open();
bool exists = false;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"PRAGMA table_info({table})";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader["name"]?.ToString(), column, StringComparison.OrdinalIgnoreCase))
{
exists = true;
break;
}
}
}
if (!exists)
{
using var alter = conn.CreateCommand();
alter.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {type}";
alter.ExecuteNonQuery();
}
}
private static void EnsureTransformationTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS FieldTransformationRules (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
SourceField TEXT NOT NULL,
TargetField TEXT NOT NULL,
TransformationType TEXT NOT NULL,
Argument TEXT NOT NULL DEFAULT '',
SortOrder INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1
);";
cmd.ExecuteNonQuery();
}
public static void SeedIfEmpty(AppDbContext db)
{
if (db.HanaServers.Any()) return;
var serverInternal = new HanaServer { Name = "Internal", Host = "travtrp0", Port = 30015, Username = "", Password = "" };
var serverIndia = new HanaServer { Name = "India", Host = "20.197.20.60", Port = 30015, Username = "", Password = "" };
db.HanaServers.AddRange(serverInternal, serverIndia);
db.SaveChanges();
db.Sites.AddRange(
new Site { HanaServerId = serverInternal.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", IsActive = true },
new Site { HanaServerId = serverInternal.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", IsActive = true },
new Site { HanaServerId = serverInternal.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", IsActive = true },
new Site { HanaServerId = serverIndia.Id, Schema = "TRAFAG_LIVE", TSC = "TRIN", Land = "Indien", IsActive = true }
);
db.SharePointConfigs.Add(new SharePointConfig
{
SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform",
ExportFolder = "/Shared Documents/Exports/",
TenantId = "",
ClientId = "",
ClientSecret = ""
});
db.ExportSettings.Add(new ExportSettings
{
DateFilter = "2025-01-01",
TimerHour = 3,
TimerMinute = 0,
TimerEnabled = true
});
db.SaveChanges();
}
public DbSet<CurrencyExchangeRate> CurrencyExchangeRates => Set<CurrencyExchangeRate>();
public DbSet<FinanceReference> FinanceReferences => Set<FinanceReference>();
public DbSet<FinanceIntercompanyRule> FinanceIntercompanyRules => Set<FinanceIntercompanyRule>();
public DbSet<FinanceRule> FinanceRules => Set<FinanceRule>();
public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>();
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>();
public DbSet<ManualExcelColumnMapping> ManualExcelColumnMappings => Set<ManualExcelColumnMapping>();
public DbSet<CentralSalesRecord> CentralSalesRecords => Set<CentralSalesRecord>();
public DbSet<NavigationMenuItem> NavigationMenuItems => Set<NavigationMenuItem>();
}
+36
View File
@@ -0,0 +1,36 @@
# TrafagSalesExporter Handoff
Stand: 2026-05-27
Diese Datei ist fuer tokenarme RAG-Nutzung komprimiert.
## Aktueller Kurzstand
- Fuehrender Kurzkontext: `docs/rag/PROJECT.md`.
- Architektur-Kurzkontext: `docs/rag/ARCHITECTURE.md`.
- Themenrouter: `docs/RAG_ROUTER.md`.
- Fuer normale Weiterarbeit zuerst Router und passende `docs/rag/*.md` laden.
## Wichtige Bereiche
- Finance-Regeln und Finance Summary: `docs/rag/FINANCE.md`
- Manual-Importe und Deltas: `docs/rag/MANUAL_IMPORT.md`
- HR KPI: `docs/rag/HR_KPI.md`
- IIS/Deployment: `docs/rag/DEPLOYMENT.md`
- Admin/Startseite: `docs/rag/ADMIN.md`
## Volltext Bei Bedarf
Die kanonische Detailhistorie liegt hier:
```text
docs/raw_md_archive/HISTORY_CANONICAL.md.raw
```
Die frueheren Original-Volltexte liegen als Wiederherstellungs-Backup hier:
```text
docs/raw_md_archive/original_history_raws.zip
```
Nur laden, wenn tiefer technischer Handoff, alte Codepfade, Architekturhistorie oder Fehleranalyse aus frueheren Zwischenstaenden benoetigt werden.
+19
View File
@@ -0,0 +1,19 @@
# LLM System Guide
Stand: 2026-05-27
Diese Datei ist fuer tokenarme RAG-Nutzung komprimiert.
## Kontext-Regel
1. Zuerst `docs/RAG_ROUTER.md` laden.
2. Danach genau eine passende Kurzdatei aus `docs/rag/` laden.
3. Original-/Rohdokus nur bei Detail-, Audit- oder Fehleranalysebedarf laden.
## Volltext Bei Bedarf
Die Detailhistorie liegt hier:
```text
docs/raw_md_archive/HISTORY_CANONICAL.md.raw
```
+13
View File
@@ -0,0 +1,13 @@
namespace TrafagSalesExporter.Models;
public class AppEventLog
{
public int Id { get; set; }
public DateTime Timestamp { get; set; }
public string Level { get; set; } = "Info";
public string Category { get; set; } = string.Empty;
public int? SiteId { get; set; }
public string Land { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string Details { get; set; } = string.Empty;
}
@@ -0,0 +1,57 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class CentralSalesRecord
{
public int Id { get; set; }
public DateTime StoredAtUtc { get; set; }
public int SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; }
public string SourceSystem { get; set; } = string.Empty;
public DateTime ExtractionDate { get; set; }
public string Tsc { get; set; } = string.Empty;
public int DocumentEntry { get; set; }
public string InvoiceNumber { get; set; } = string.Empty;
public int PositionOnInvoice { get; set; }
public string Material { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ProductGroup { get; set; } = string.Empty;
public string ProductHierarchyCode { get; set; } = string.Empty;
public string ProductHierarchyText { get; set; } = string.Empty;
public string ProductFamilyCode { get; set; } = string.Empty;
public string ProductFamilyText { get; set; } = string.Empty;
public string ProductDivisionCode { get; set; } = string.Empty;
public string ProductDivisionText { get; set; } = string.Empty;
public string ProductMappingAssigned { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public string SupplierNumber { get; set; } = string.Empty;
public string SupplierName { get; set; } = string.Empty;
public string SupplierCountry { get; set; } = string.Empty;
public string CustomerNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CustomerCountry { get; set; } = string.Empty;
public string CustomerIndustry { get; set; } = string.Empty;
public decimal StandardCost { get; set; }
public string StandardCostCurrency { get; set; } = string.Empty;
public string PurchaseOrderNumber { get; set; } = string.Empty;
public decimal SalesPriceValue { get; set; }
public string SalesCurrency { get; set; } = string.Empty;
public string DocumentCurrency { get; set; } = string.Empty;
public decimal DocumentTotalForeignCurrency { get; set; }
public decimal DocumentTotalLocalCurrency { get; set; }
public decimal VatSumForeignCurrency { get; set; }
public decimal VatSumLocalCurrency { get; set; }
public decimal DocumentRate { get; set; }
public string CompanyCurrency { get; set; } = string.Empty;
public string Incoterms2020 { get; set; } = string.Empty;
public string SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? PostingDate { get; set; }
public DateTime? InvoiceDate { get; set; }
public DateTime? OrderDate { get; set; }
public string Land { get; set; } = string.Empty;
public string DocumentType { get; set; } = string.Empty;
}
@@ -0,0 +1,164 @@
namespace TrafagSalesExporter.Models;
public class ConfigTransferPackage
{
public int Version { get; set; } = 1;
public DateTime ExportedAtUtc { get; set; } = DateTime.UtcNow;
public bool IncludesSecrets { get; set; }
public ConfigTransferSharePoint? SharePointConfig { get; set; }
public ConfigTransferExportSettings? ExportSettings { get; set; }
public List<ConfigTransferSourceSystemDefinition> SourceSystemDefinitions { get; set; } = [];
public List<ConfigTransferCurrencyExchangeRate> CurrencyExchangeRates { get; set; } = [];
public List<ConfigTransferFinanceReference> FinanceReferences { get; set; } = [];
public List<ConfigTransferFinanceIntercompanyRule> FinanceIntercompanyRules { get; set; } = [];
public List<FinanceRule> FinanceRules { get; set; } = [];
public List<ConfigTransferHanaServer> HanaServers { get; set; } = [];
public List<ConfigTransferSite> Sites { get; set; } = [];
public List<FieldTransformationRule> FieldTransformationRules { get; set; } = [];
public List<ConfigTransferSapSourceDefinition> SapSourceDefinitions { get; set; } = [];
public List<ConfigTransferSapJoinDefinition> SapJoinDefinitions { get; set; } = [];
public List<ConfigTransferSapFieldMapping> SapFieldMappings { get; set; } = [];
public List<ConfigTransferManualExcelColumnMapping> ManualExcelColumnMappings { get; set; } = [];
}
public class ConfigTransferSourceSystemDefinition
{
public string Code { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string ConnectionKind { get; set; } = SourceSystemConnectionKinds.Hana;
public bool IsActive { get; set; } = true;
public string CentralServiceUrl { get; set; } = string.Empty;
public string? CentralUsername { get; set; }
public string? CentralPassword { get; set; }
}
public class ConfigTransferSharePoint
{
public string SiteUrl { get; set; } = string.Empty;
public string ExportFolder { get; set; } = string.Empty;
public string CentralExportFolder { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string? ClientSecret { get; set; }
}
public class ConfigTransferExportSettings
{
public string DateFilter { get; set; } = "2025-01-01";
public int TimerHour { get; set; } = 3;
public int TimerMinute { get; set; }
public bool TimerEnabled { get; set; } = true;
public bool DebugLoggingEnabled { get; set; }
public string LocalSiteExportFolder { get; set; } = string.Empty;
public string LocalConsolidatedExportFolder { get; set; } = string.Empty;
public bool AuditCsvEnabled { get; set; } = true;
public bool UseAuditCsvAsCentralSource { get; set; }
public string LocalAuditCsvFolder { get; set; } = string.Empty;
public string ExchangeRateDateField { get; set; } = ExchangeRateDateFields.PostingDate;
}
public class ConfigTransferCurrencyExchangeRate
{
public string FromCurrency { get; set; } = string.Empty;
public string ToCurrency { get; set; } = string.Empty;
public decimal Rate { get; set; }
public DateTime ValidFrom { get; set; }
public DateTime? ValidTo { get; set; }
public string Notes { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
}
public class ConfigTransferFinanceReference
{
public string Key { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public int Year { get; set; } = 2025;
public decimal? LocalCurrencyValue { get; set; }
public decimal? CheckValue { get; set; }
public string Notes { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
}
public class ConfigTransferFinanceIntercompanyRule
{
public string ScopeKey { get; set; } = string.Empty;
public string CustomerNumber { get; set; } = string.Empty;
public string CustomerNameContains { get; set; } = string.Empty;
public string Notes { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
}
public class ConfigTransferHanaServer
{
public string Key { get; set; } = Guid.NewGuid().ToString("N");
public string SourceSystem { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 30015;
public string DatabaseName { get; set; } = string.Empty;
public bool UseSsl { get; set; }
public bool ValidateCertificate { get; set; }
public string AdditionalParams { get; set; } = string.Empty;
}
public class ConfigTransferSite
{
public string Key { get; set; } = Guid.NewGuid().ToString("N");
public string? HanaServerKey { get; set; }
public string Schema { get; set; } = string.Empty;
public string TSC { get; set; } = string.Empty;
public string Land { get; set; } = string.Empty;
public string SourceSystem { get; set; } = string.Empty;
public string? UsernameOverride { get; set; }
public string? PasswordOverride { get; set; }
public string LocalExportFolderOverride { get; set; } = string.Empty;
public string ManualImportFilePath { get; set; } = string.Empty;
public DateTime? ManualImportLastUploadedAtUtc { get; set; }
public string SapServiceUrl { get; set; } = string.Empty;
public string SapEntitySet { get; set; } = string.Empty;
public string SapEntitySetsCache { get; set; } = string.Empty;
public DateTime? SapEntitySetsRefreshedAtUtc { get; set; }
public bool IsActive { get; set; } = true;
}
public class ConfigTransferSapSourceDefinition
{
public string SiteKey { get; set; } = string.Empty;
public string Alias { get; set; } = string.Empty;
public string EntitySet { get; set; } = string.Empty;
public bool IsPrimary { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
public class ConfigTransferSapJoinDefinition
{
public string SiteKey { get; set; } = string.Empty;
public string LeftAlias { get; set; } = string.Empty;
public string RightAlias { get; set; } = string.Empty;
public string LeftKeys { get; set; } = string.Empty;
public string RightKeys { get; set; } = string.Empty;
public string JoinType { get; set; } = "Left";
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
public class ConfigTransferSapFieldMapping
{
public string SiteKey { get; set; } = string.Empty;
public string TargetField { get; set; } = string.Empty;
public string SourceExpression { get; set; } = string.Empty;
public bool IsRequired { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
public class ConfigTransferManualExcelColumnMapping
{
public string SiteKey { get; set; } = string.Empty;
public string TargetField { get; set; } = string.Empty;
public string SourceHeader { get; set; } = string.Empty;
public bool IsRequired { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -0,0 +1,13 @@
namespace TrafagSalesExporter.Models;
public class CurrencyExchangeRate
{
public int Id { get; set; }
public string FromCurrency { get; set; } = string.Empty;
public string ToCurrency { get; set; } = string.Empty;
public decimal Rate { get; set; }
public DateTime ValidFrom { get; set; } = DateTime.UtcNow.Date;
public DateTime? ValidTo { get; set; }
public string Notes { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
}
+1
View File
@@ -17,5 +17,6 @@ public class ExportLog
public int RowCount { get; set; }
public string? ErrorMessage { get; set; }
public string FileName { get; set; } = string.Empty;
public string FilePath { get; set; } = string.Empty;
public double DurationSeconds { get; set; }
}
@@ -7,4 +7,18 @@ public class ExportSettings
public int TimerHour { get; set; } = 3;
public int TimerMinute { get; set; }
public bool TimerEnabled { get; set; } = true;
public bool DebugLoggingEnabled { get; set; }
public string LocalSiteExportFolder { get; set; } = string.Empty;
public string LocalConsolidatedExportFolder { get; set; } = string.Empty;
public bool AuditCsvEnabled { get; set; } = true;
public bool UseAuditCsvAsCentralSource { get; set; }
public string LocalAuditCsvFolder { get; set; } = string.Empty;
public string ExchangeRateDateField { get; set; } = ExchangeRateDateFields.PostingDate;
}
public static class ExchangeRateDateFields
{
public const string PostingDate = nameof(PostingDate);
public const string InvoiceDate = nameof(InvoiceDate);
public const string ExtractionDate = nameof(ExtractionDate);
}
@@ -7,7 +7,7 @@ public class FieldTransformationRule
public int Id { get; set; }
[Required]
public string SourceSystem { get; set; } = "SAP";
public string SourceSystem { get; set; } = string.Empty;
[Required]
public string SourceField { get; set; } = nameof(SalesRecord.Material);
@@ -18,6 +18,9 @@ public class FieldTransformationRule
[Required]
public string TransformationType { get; set; } = "Copy";
[Required]
public string RuleScope { get; set; } = "Value";
public string Argument { get; set; } = string.Empty;
public int SortOrder { get; set; }
@@ -0,0 +1,11 @@
namespace TrafagSalesExporter.Models;
public class FinanceIntercompanyRule
{
public int Id { get; set; }
public string ScopeKey { get; set; } = string.Empty;
public string CustomerNumber { get; set; } = string.Empty;
public string CustomerNameContains { get; set; } = string.Empty;
public string Notes { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
}
@@ -0,0 +1,13 @@
namespace TrafagSalesExporter.Models;
public class FinanceReference
{
public int Id { get; set; }
public string Key { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public int Year { get; set; } = 2025;
public decimal? LocalCurrencyValue { get; set; }
public decimal? CheckValue { get; set; }
public string Notes { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
}
+50
View File
@@ -0,0 +1,50 @@
namespace TrafagSalesExporter.Models;
public class FinanceRule
{
public int Id { get; set; }
public string ScopeKey { get; set; } = string.Empty;
public int? Year { get; set; }
public string RuleType { get; set; } = FinanceRuleTypes.Exclude;
public string FieldName { get; set; } = string.Empty;
public string MatchType { get; set; } = FinanceRuleMatchTypes.Contains;
public string MatchValue { get; set; } = string.Empty;
public decimal? NumericValue { get; set; }
public string Notes { get; set; } = string.Empty;
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
public static class FinanceRuleTypes
{
public const string Exclude = "Exclude";
public const string NegateAmount = "NegateAmount";
public const string ForceYear = "ForceYear";
public const string DeduplicateBlankSupplierCountry = "DeduplicateBlankSupplierCountry";
public static readonly string[] All =
[
Exclude,
NegateAmount,
ForceYear,
DeduplicateBlankSupplierCountry
];
}
public static class FinanceRuleMatchTypes
{
public const string Always = "Always";
public const string Equal = "Equals";
public const string Contains = "Contains";
public const string StartsWith = "StartsWith";
public const string IsBlank = "IsBlank";
public static readonly string[] All =
[
Always,
Equal,
Contains,
StartsWith,
IsBlank
];
}
+82 -12
View File
@@ -1,4 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Data.Common;
namespace TrafagSalesExporter.Models;
@@ -6,6 +8,9 @@ public class HanaServer
{
public int Id { get; set; }
[Required]
public string SourceSystem { get; set; } = string.Empty;
[Required]
public string Name { get; set; } = string.Empty;
@@ -14,8 +19,10 @@ public class HanaServer
public int Port { get; set; } = 30015;
[NotMapped]
public string Username { get; set; } = string.Empty;
[NotMapped]
public string Password { get; set; } = string.Empty;
/// <summary>
@@ -41,26 +48,23 @@ public class HanaServer
public string BuildConnectionString()
{
var parts = new List<string>
{
$"ServerNode={Host}:{Port}",
$"UserName={Username}",
$"Password={Password}"
};
var builder = new DbConnectionStringBuilder();
builder["ServerNode"] = BuildServerNode();
builder["UserName"] = Username.Trim();
builder["Password"] = Password;
if (!string.IsNullOrWhiteSpace(DatabaseName))
parts.Add($"DatabaseName={DatabaseName}");
builder["DatabaseName"] = DatabaseName.Trim();
if (UseSsl)
{
parts.Add("encrypt=true");
parts.Add($"sslValidateCertificate={(ValidateCertificate ? "true" : "false")}");
builder["encrypt"] = true;
builder["sslValidateCertificate"] = ValidateCertificate;
}
if (!string.IsNullOrWhiteSpace(AdditionalParams))
parts.Add(AdditionalParams.Trim().Trim(';'));
AppendAdditionalParams(builder);
return string.Join(";", parts);
return builder.ConnectionString;
}
public string GetConnectionStringPreview()
@@ -68,6 +72,7 @@ public class HanaServer
var pwdMasked = string.IsNullOrEmpty(Password) ? "" : "***";
var copy = new HanaServer
{
SourceSystem = SourceSystem,
Host = Host,
Port = Port,
Username = Username,
@@ -80,5 +85,70 @@ public class HanaServer
return copy.BuildConnectionString();
}
private string BuildServerNode()
{
var normalizedHost = NormalizeHost(Host);
if (string.IsNullOrWhiteSpace(normalizedHost))
throw new InvalidOperationException("HANA Host darf nicht leer sein.");
if (HasExplicitPort(normalizedHost))
return normalizedHost;
return $"{normalizedHost}:{Port}";
}
private static string NormalizeHost(string host)
{
var value = host.Trim();
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
// Treat plain "host:port" values as HANA ServerNode, not as a URI scheme.
// Only parse as URI when an explicit scheme is present.
if (value.Contains("://", StringComparison.Ordinal) &&
Uri.TryCreate(value, UriKind.Absolute, out var uri))
{
return uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}";
}
var schemeIndex = value.IndexOf("://", StringComparison.Ordinal);
if (schemeIndex >= 0)
value = value[(schemeIndex + 3)..];
var slashIndex = value.IndexOf('/');
if (slashIndex >= 0)
value = value[..slashIndex];
return value.Trim();
}
private static bool HasExplicitPort(string host)
{
if (host.StartsWith('['))
return host.Contains("]:", StringComparison.Ordinal);
return host.Count(c => c == ':') == 1;
}
private void AppendAdditionalParams(DbConnectionStringBuilder builder)
{
if (string.IsNullOrWhiteSpace(AdditionalParams))
return;
foreach (var rawPart in AdditionalParams.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var separatorIndex = rawPart.IndexOf('=');
if (separatorIndex <= 0 || separatorIndex == rawPart.Length - 1)
continue;
var key = rawPart[..separatorIndex].Trim();
var value = rawPart[(separatorIndex + 1)..].Trim();
if (key.Length == 0)
continue;
builder[key] = value;
}
}
}
+219
View File
@@ -0,0 +1,219 @@
namespace TrafagSalesExporter.Models;
public sealed class HrKpiOptions
{
public string DataFolder { get; set; } = HrKpiDataSourceOptions.DefaultFolder;
public int? Year { get; set; }
public DateTime? FromDate { get; set; }
public DateTime? ToDate { get; set; }
public int? EntryYear { get; set; }
public string? Organisationseinheit { get; set; }
public string? KostenstelleText { get; set; }
public string? Mitarbeitertyp { get; set; }
public string? FluktuationFilter { get; set; }
public string? GlzAmpel { get; set; }
public string? RestferienAmpel { get; set; }
public string? SearchText { get; set; }
public bool ManagementView { get; set; }
}
public sealed class HrKpiDataSourceOptions
{
public const string SectionName = "HrKpi";
public const string DefaultFolder = @"C:\temp";
public string DataFolder { get; set; } = DefaultFolder;
public string MainFile { get; set; } = "Saldiperstichdatum.xlsx";
public string TimeFile { get; set; } = "Exportkommengehen.xlsx";
public string SapFile { get; set; } = "HR_KPI_Export.xlsx";
public string AbsenceFile { get; set; } = "Abwesenheitinstunden.xlsx";
public string LeaverFile { get; set; } = "Personalausgeschieden.xlsx";
public HrKpiDataSourceOptions Normalize()
=> new()
{
DataFolder = NormalizeText(DataFolder, DefaultFolder),
MainFile = NormalizeText(MainFile, "Saldiperstichdatum.xlsx"),
TimeFile = NormalizeText(TimeFile, "Exportkommengehen.xlsx"),
SapFile = NormalizeText(SapFile, "HR_KPI_Export.xlsx"),
AbsenceFile = NormalizeText(AbsenceFile, "Abwesenheitinstunden.xlsx"),
LeaverFile = NormalizeText(LeaverFile, "Personalausgeschieden.xlsx")
};
private static string NormalizeText(string? value, string fallback)
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
public sealed class HrKpiResult
{
public HrKpiOptions Options { get; set; } = new();
public List<HrKpiFileStatus> FileStatuses { get; set; } = [];
public List<string> Notices { get; set; } = [];
public List<string> OrganisationOptions { get; set; } = [];
public List<string> KostenstelleOptions { get; set; } = [];
public List<int> ExitYearOptions { get; set; } = [];
public List<int> EntryYearOptions { get; set; } = [];
public List<string> MitarbeitertypOptions { get; set; } = [];
public List<HrKpiMetric> Metrics { get; set; } = [];
public List<HrKpiMetric> TurnoverMetrics { get; set; } = [];
public List<HrKpiMetric> AbsenceMetrics { get; set; } = [];
public List<HrKpiMetric> TimeVacationMetrics { get; set; } = [];
public List<HrKpiMetric> PeriodComparisonMetrics { get; set; } = [];
public List<HrKpiTrafficLight> TrafficLights { get; set; } = [];
public List<HrKpiDataQualityIssue> DataQualityIssues { get; set; } = [];
public List<HrKpiGroupValue> LeaversByType { get; set; } = [];
public List<HrKpiGroupValue> LeaversByOrganisation { get; set; } = [];
public List<HrKpiGroupValue> AbsenceByOrganisation { get; set; } = [];
public List<HrKpiEmployeeRow> CriticalAbsences { get; set; } = [];
public List<HrKpiEmployeeRow> Employees { get; set; } = [];
public List<HrAbsenceRow> Absences { get; set; } = [];
public List<HrLeaverRow> Leavers { get; set; } = [];
public List<HrKpiGroupValue> HeadcountByOrganisation { get; set; } = [];
public List<HrKpiEmployeeRow> CriticalTimeBalances { get; set; } = [];
public List<HrLeaverRow> FluctuationRelevantLeavers { get; set; } = [];
public HrTurnoverVisuals TurnoverVisuals { get; set; } = new();
}
public sealed class HrKpiFileStatus
{
public string Label { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public bool Exists { get; set; }
public int RowCount { get; set; }
public string? Message { get; set; }
public DateTime? LastModified { get; set; }
public int? AgeDays { get; set; }
public string FreshnessStatus { get; set; } = "Unbekannt";
}
public sealed class HrKpiTrafficLight
{
public string Area { get; set; } = string.Empty;
public string Status { get; set; } = "Gruen";
public string Value { get; set; } = string.Empty;
public string Detail { get; set; } = string.Empty;
}
public sealed class HrKpiDataQualityIssue
{
public string Severity { get; set; } = "Info";
public string Area { get; set; } = string.Empty;
public string Issue { get; set; } = string.Empty;
public int Count { get; set; }
public string Detail { get; set; } = string.Empty;
}
public sealed class HrKpiMetric
{
public string Label { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public string Detail { get; set; } = string.Empty;
public string Severity { get; set; } = "Normal";
}
public sealed class HrKpiGroupValue
{
public string Label { get; set; } = string.Empty;
public decimal Value { get; set; }
public int Count { get; set; }
public string Color { get; set; } = "#607d8b";
public decimal Percent { get; set; }
}
public sealed class HrTurnoverVisuals
{
public string RateTitle { get; set; } = "Fluktuation Auswahl";
public decimal YearRatePercent { get; set; }
public string YearRateLabel { get; set; } = "0.0%";
public string GaugeColor { get; set; } = "#2e7d32";
public decimal GaugeRotationDegrees { get; set; }
public string TimelineTitle { get; set; } = "Relevante Austritte";
public List<HrKpiGroupValue> FunnelSteps { get; set; } = [];
public List<HrKpiGroupValue> ExclusionReasons { get; set; } = [];
public List<HrKpiGroupValue> RelevantByOrganisation { get; set; } = [];
public List<HrKpiGroupValue> MonthlyRelevantLeavers { get; set; } = [];
}
public sealed class HrKpiEmployeeRow
{
public int? Personalnummer { get; set; }
public string NameVoll { get; set; } = string.Empty;
public string Vorname { get; set; } = string.Empty;
public string Nachname { get; set; } = string.Empty;
public string Organisationseinheit { get; set; } = string.Empty;
public string KostenstelleText { get; set; } = string.Empty;
public int? Kostenstelle { get; set; }
public string Stelle { get; set; } = string.Empty;
public string Leitung { get; set; } = string.Empty;
public DateTime? Eintrittsdatum { get; set; }
public DateTime? Geburtsdatum { get; set; }
public int? AlterJahre { get; set; }
public string Altersgruppe { get; set; } = "Unbekannt";
public string GeschlechtText { get; set; } = "Unbekannt";
public decimal? BeschaeftigungsgradProzent { get; set; }
public decimal Fte { get; set; }
public bool IstTeilzeit { get; set; }
public int? Dienstjahre { get; set; }
public bool IstAktiv { get; set; }
public string Mitarbeitertyp { get; set; } = "Festangestellt";
public decimal StundenSaldo { get; set; }
public string GlzAmpel { get; set; } = "Gruen";
public decimal UrlaubRest { get; set; }
public decimal Urlaubsanspruch { get; set; }
public decimal FerienAusstehend { get; set; }
public decimal Ferientage { get; set; }
public string RestferienAmpel { get; set; } = "Gruen";
public decimal Bruttolohn { get; set; }
public string LohnWaehrung { get; set; } = string.Empty;
public decimal BuTage { get; set; }
public decimal NbuTage { get; set; }
public string Buchungskreis { get; set; } = string.Empty;
public string Personalbereich { get; set; } = string.Empty;
public string Personalteilbereich { get; set; } = string.Empty;
public string Mitarbeitergruppe { get; set; } = string.Empty;
public string Mitarbeiterkreis { get; set; } = string.Empty;
public string Planstelle { get; set; } = string.Empty;
public string SollStelle { get; set; } = string.Empty;
public DateTime Periode { get; set; } = new(DateTime.Today.Year, DateTime.Today.Month, 1);
}
public sealed class HrAbsenceRow
{
public int? Personalnummer { get; set; }
public string Name { get; set; } = string.Empty;
public string Organisationseinheit { get; set; } = string.Empty;
public string Stelle { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTime? VonDatum { get; set; }
public DateTime? BisDatum { get; set; }
public decimal KrankheitKurzStd { get; set; }
public decimal KrankheitLangStd { get; set; }
public decimal KrankheitGesamtStd { get; set; }
public decimal KrankheitstageGesamt { get; set; }
public decimal KrankheitstageKurz { get; set; }
public decimal KrankheitstageLang { get; set; }
public decimal KrankenquoteMa { get; set; }
}
public sealed class HrLeaverRow
{
public int? Personalnummer { get; set; }
public string NameVoll { get; set; } = string.Empty;
public string Vorname { get; set; } = string.Empty;
public string Nachname { get; set; } = string.Empty;
public string Organisationseinheit { get; set; } = string.Empty;
public string Stelle { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTime? Austrittsdatum { get; set; }
public DateTime? Eintrittsdatum { get; set; }
public decimal? VerweildauerMonate { get; set; }
public string Austrittsart { get; set; } = string.Empty;
public string AustrittsartNormalisiert { get; set; } = string.Empty;
public string Mitarbeitertyp { get; set; } = "Festangestellt";
public bool IstArbeitnehmerkuendigung { get; set; }
public bool IstFluktuationAusgeschlossen { get; set; }
public bool IstFluktuationsrelevant { get; set; }
public string? FluktuationAusschlussgrund { get; set; }
public DateTime? Austrittsmonat { get; set; }
public int? Austrittsjahr { get; set; }
}
@@ -0,0 +1,342 @@
namespace TrafagSalesExporter.Models;
public class ManagementCockpitFileOption
{
public string Path { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public DateTime LastModified { get; set; }
}
public static class ManagementCockpitValueFieldKeys
{
public const string SalesPriceValue = nameof(SalesPriceValue);
public const string Quantity = nameof(Quantity);
public const string StandardCost = nameof(StandardCost);
public const string StandardCostTotal = nameof(StandardCostTotal);
}
public static class ManagementCockpitCurrencyOptions
{
public const string Native = "NATIVE";
public const string Chf = "CHF";
public const string Eur = "EUR";
public const string Usd = "USD";
}
public class ManagementCockpitValueFieldOption
{
public string Key { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public bool IsCurrencyAmount { get; set; }
}
public class ManagementCockpitAnalysisOptions
{
public string ValueField { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue;
public List<string> AdditionalValueFields { get; set; } = [];
public string TargetCurrency { get; set; } = ManagementCockpitCurrencyOptions.Native;
public string? LandFilter { get; set; }
public string? TscFilter { get; set; }
}
public class ManagementCockpitSummary
{
public string Land { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public DateTime? ExtractionDate { get; set; }
public int RowCount { get; set; }
public int InvoiceCount { get; set; }
public int CustomerCount { get; set; }
public string ValueFieldKey { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue;
public string ValueFieldLabel { get; set; } = "Sales Price/Value";
public string DisplayCurrency { get; set; } = string.Empty;
public int MissingExchangeRateCount { get; set; }
public decimal AggregatedValueTotal { get; set; }
public decimal SalesValueTotal { get; set; }
public decimal EstimatedCostTotal { get; set; }
public decimal EstimatedMarginTotal { get; set; }
public decimal EstimatedMarginPercent { get; set; }
public decimal ServiceSharePercent { get; set; }
public decimal MissingOrderDatePercent { get; set; }
public decimal MissingSupplierPercent { get; set; }
}
public class ManagementCockpitFinding
{
public string Severity { get; set; } = "Info";
public string Title { get; set; } = string.Empty;
public string Detail { get; set; } = string.Empty;
}
public class ManagementCockpitTopItem
{
public string Label { get; set; } = string.Empty;
public decimal Value { get; set; }
public decimal SharePercent { get; set; }
}
public class ManagementCockpitResult
{
public string FilePath { get; set; } = string.Empty;
public ManagementCockpitSummary Summary { get; set; } = new();
public List<ManagementCockpitFinding> Findings { get; set; } = [];
public List<ManagementCockpitTopItem> TopCustomers { get; set; } = [];
public List<ManagementCockpitTopItem> TopProductGroups { get; set; } = [];
public List<ManagementCockpitTopItem> TopSalesEmployees { get; set; } = [];
public Dictionary<string, int> DataQualityCounts { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
public class ManagementCockpitCentralFilter
{
public int Year { get; set; }
public int? Month { get; set; }
public string ValueField { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue;
public string TargetCurrency { get; set; } = ManagementCockpitCurrencyOptions.Native;
public string? Land { get; set; }
public string? Tsc { get; set; }
}
public class ManagementCockpitCentralSummary
{
public int RowCount { get; set; }
public int InvoiceCount { get; set; }
public int SiteCount { get; set; }
public int CountryCount { get; set; }
public int CurrencyCount { get; set; }
public string ValueFieldKey { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue;
public string ValueFieldLabel { get; set; } = "Sales Price/Value";
public string DisplayCurrency { get; set; } = string.Empty;
public decimal ValueTotal { get; set; }
public int MissingExchangeRateCount { get; set; }
public string ExchangeRateDateField { get; set; } = ExchangeRateDateFields.PostingDate;
public string ExchangeRateDateLabel { get; set; } = "PostingDate / Buchungsdatum";
public DateTime? PeriodStart { get; set; }
public DateTime? PeriodEnd { get; set; }
}
public class ManagementCockpitTimeValueRow
{
public string Label { get; set; } = string.Empty;
public int? Year { get; set; }
public int? Month { get; set; }
public int? Day { get; set; }
public string Currency { get; set; } = string.Empty;
public decimal SalesValue { get; set; }
public Dictionary<string, ManagementCockpitAggregatedFieldValue> AdditionalValues { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public int RowCount { get; set; }
}
public class ManagementCockpitAggregatedFieldValue
{
public string FieldKey { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string Currency { get; set; } = string.Empty;
public decimal Value { get; set; }
public int MissingExchangeRateCount { get; set; }
}
public class ManagementCockpitDimensionValueRow
{
public string Label { get; set; } = string.Empty;
public string Currency { get; set; } = string.Empty;
public decimal SalesValue { get; set; }
public int RowCount { get; set; }
public int InvoiceCount { get; set; }
}
public class ManagementCockpitCentralResult
{
public ManagementCockpitCentralFilter Filter { get; set; } = new();
public ManagementCockpitCentralSummary Summary { get; set; } = new();
public List<string> Notices { get; set; } = [];
public List<ManagementCockpitValueFieldOption> AdditionalValueFields { get; set; } = [];
public List<ManagementCockpitTimeValueRow> YearlyTotals { get; set; } = [];
public List<ManagementCockpitTimeValueRow> MonthlyTotals { get; set; } = [];
public List<ManagementCockpitTimeValueRow> DailyTotals { get; set; } = [];
public List<ManagementCockpitDimensionValueRow> SourceSystemTotals { get; set; } = [];
public List<ManagementCockpitDimensionValueRow> CountryTotals { get; set; } = [];
}
public class ManagementFinanceSummaryFilter
{
public int Year { get; set; }
public string? CountryKey { get; set; }
public string? Currency { get; set; }
}
public class ManagementFinanceSummaryRow
{
public int Year { get; set; }
public string CountryKey { get; set; } = string.Empty;
public string Currency { get; set; } = string.Empty;
public int IncludedRows { get; set; }
public int ExcludedRows { get; set; }
public int TotalRows { get; set; }
public decimal NetSalesActual { get; set; }
public decimal NetSalesActualExcludingIntercompany { get; set; }
public decimal IntercompanyValue { get; set; }
public decimal IntercompanySharePercent { get; set; }
public decimal Quantity { get; set; }
public decimal CreditValue { get; set; }
public int CreditRows { get; set; }
public decimal IncludeRatePercent { get; set; }
public decimal ExcludeRatePercent { get; set; }
}
public class ManagementFinanceCountryStatusRow : ManagementFinanceSummaryRow
{
public string SourceSystems { get; set; } = string.Empty;
public string Tscs { get; set; } = string.Empty;
public decimal? ReferenceValue { get; set; }
public decimal? Difference { get; set; }
public decimal? DifferencePercent { get; set; }
public string Status { get; set; } = string.Empty;
}
public class ManagementFinanceDataStatusRow
{
public string Land { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public string SourceSystem { get; set; } = string.Empty;
public bool IsActive { get; set; }
public int RowCount { get; set; }
public DateTime? LatestStoredAtUtc { get; set; }
public DateTime? LatestExtractionDate { get; set; }
public DateTime? LatestExportAt { get; set; }
public string LatestExportStatus { get; set; } = string.Empty;
public string ManualImportFilePath { get; set; } = string.Empty;
public DateTime? ManualImportLastUploadedAtUtc { get; set; }
}
public class ManagementFinanceCreditCandidateRow
{
public string CountryKey { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public string InvoiceNumber { get; set; } = string.Empty;
public string DocumentType { get; set; } = string.Empty;
public string Currency { get; set; } = string.Empty;
public decimal NetSalesActual { get; set; }
public decimal Quantity { get; set; }
public string Reason { get; set; } = string.Empty;
}
public class ManagementFinanceDataQualityRow
{
public string Issue { get; set; } = string.Empty;
public int Count { get; set; }
public string Severity { get; set; } = "Info";
}
public class ManagementProductAssignmentSummary
{
public int DistinctMaterialCount { get; set; }
public int MatchedMaterialCount { get; set; }
public int UnassignedMaterialCount { get; set; }
public int MissingReferenceMaterialCount { get; set; }
public int MissingMaterialNumberCount { get; set; }
public int ReferenceMaterialCount { get; set; }
}
public class ManagementProductFinanceSummary
{
public decimal TotalValue { get; set; }
public decimal AssignedValue { get; set; }
public decimal UnassignedValue { get; set; }
public decimal MissingReferenceValue { get; set; }
public decimal MissingMaterialValue { get; set; }
public decimal AssignedValuePercent { get; set; }
public decimal UnassignedValuePercent { get; set; }
public decimal MissingReferenceValuePercent { get; set; }
public string DisplayCurrency { get; set; } = string.Empty;
}
public class ManagementProductDivisionFinanceRow
{
public string ProductDivisionCode { get; set; } = string.Empty;
public string ProductDivisionText { get; set; } = string.Empty;
public string ProductFamilyCode { get; set; } = string.Empty;
public string ProductFamilyText { get; set; } = string.Empty;
public string ProductHierarchyCode { get; set; } = string.Empty;
public string ProductHierarchyText { get; set; } = string.Empty;
public string Currency { get; set; } = string.Empty;
public decimal NetSalesActual { get; set; }
public decimal SharePercent { get; set; }
public int MaterialCount { get; set; }
public int RowCount { get; set; }
public string Countries { get; set; } = string.Empty;
}
public class ManagementProductFinanceCountryRow
{
public string CountryKey { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public string Currency { get; set; } = string.Empty;
public decimal TotalValue { get; set; }
public decimal AssignedValue { get; set; }
public decimal UnassignedValue { get; set; }
public decimal MissingReferenceValue { get; set; }
public decimal MissingMaterialValue { get; set; }
public decimal AssignedValuePercent { get; set; }
}
public class ManagementProductAssignmentCountryRow
{
public string CountryKey { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public int DistinctMaterialCount { get; set; }
public int MatchedMaterialCount { get; set; }
public int UnassignedMaterialCount { get; set; }
public int MissingReferenceMaterialCount { get; set; }
public int MissingMaterialNumberCount { get; set; }
public decimal MatchPercent { get; set; }
}
public class ManagementProductAssignmentRow
{
public string Status { get; set; } = string.Empty;
public string CountryKey { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public string SourceSystem { get; set; } = string.Empty;
public string Material { get; set; } = string.Empty;
public string ArticleName { get; set; } = string.Empty;
public string ReferenceMaterial { get; set; } = string.Empty;
public string ProductHierarchyCode { get; set; } = string.Empty;
public string ProductHierarchyText { get; set; } = string.Empty;
public string ProductFamilyCode { get; set; } = string.Empty;
public string ProductFamilyText { get; set; } = string.Empty;
public string ProductDivisionCode { get; set; } = string.Empty;
public string ProductDivisionText { get; set; } = string.Empty;
public string ProductMappingAssigned { get; set; } = string.Empty;
public int RowCount { get; set; }
public decimal NetSalesActual { get; set; }
public string Currency { get; set; } = string.Empty;
}
public class ManagementFinanceSummaryResult
{
public ManagementFinanceSummaryFilter Filter { get; set; } = new();
public List<string> Notices { get; set; } = [];
public List<int> YearOptions { get; set; } = [];
public List<string> CountryOptions { get; set; } = [];
public List<string> CurrencyOptions { get; set; } = [];
public List<ManagementFinanceSummaryRow> Rows { get; set; } = [];
public List<ManagementFinanceSummaryRow> YearRows { get; set; } = [];
public List<ManagementFinanceSummaryRow> YearCountryRows { get; set; } = [];
public int IncludedRows { get; set; }
public int ExcludedRows { get; set; }
public int CountryCount { get; set; }
public int CurrencyCount { get; set; }
public decimal NetSalesActual { get; set; }
public string DisplayCurrency { get; set; } = string.Empty;
public List<ManagementFinanceCountryStatusRow> CountryRows { get; set; } = [];
public List<ManagementFinanceCountryStatusRow> DeviationRows { get; set; } = [];
public List<ManagementFinanceDataStatusRow> DataStatusRows { get; set; } = [];
public List<ManagementFinanceCreditCandidateRow> CreditCandidates { get; set; } = [];
public List<ManagementFinanceDataQualityRow> DataQualityRows { get; set; } = [];
public ManagementProductFinanceSummary ProductFinanceSummary { get; set; } = new();
public List<ManagementProductDivisionFinanceRow> ProductDivisionFinanceRows { get; set; } = [];
public List<ManagementProductFinanceCountryRow> ProductFinanceCountryRows { get; set; } = [];
public ManagementProductAssignmentSummary ProductAssignmentSummary { get; set; } = new();
public List<ManagementProductAssignmentCountryRow> ProductAssignmentCountryRows { get; set; } = [];
public List<ManagementProductAssignmentRow> ProductAssignmentRows { get; set; } = [];
}
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class ManualExcelColumnMapping
{
public int Id { get; set; }
public int SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; }
[Required]
public string TargetField { get; set; } = nameof(SalesRecord.Material);
[Required]
public string SourceHeader { get; set; } = string.Empty;
public bool IsRequired { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -0,0 +1,26 @@
namespace TrafagSalesExporter.Models;
public class NavigationMenuItem
{
public int Id { get; set; }
public string Key { get; set; } = string.Empty;
public string? ParentKey { get; set; }
public string TitleDe { get; set; } = string.Empty;
public string TitleEn { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
public string Href { get; set; } = string.Empty;
public string ItemType { get; set; } = NavigationMenuItemTypes.Link;
public string Match { get; set; } = "Prefix";
public string RequiredPolicy { get; set; } = string.Empty;
public bool IsVisible { get; set; } = true;
public bool IsExpanded { get; set; }
public bool IsSystem { get; set; } = true;
public int SortOrder { get; set; }
}
public static class NavigationMenuItemTypes
{
public const string Group = "Group";
public const string Link = "Link";
public const string Action = "Action";
}
@@ -0,0 +1,3 @@
namespace TrafagSalesExporter.Models;
public sealed record PurchasingAnalysisRow(string TitleDe, string TitleEn, string Measure, string Dimension, string Source);
@@ -0,0 +1,11 @@
using MudBlazor;
namespace TrafagSalesExporter.Models;
public sealed record PurchasingSectionKpi(string LabelDe, string LabelEn, string Value, string DetailDe, string DetailEn);
public sealed record PurchasingSectionChartRow(string Label, string Value, double Percent, string Color);
public sealed record PurchasingSectionStatusRow(string LabelDe, string LabelEn, string Value, string Icon, Color Color);
public sealed record PurchasingSectionDetailRow(string LabelDe, string LabelEn, string Value, string Dimension, string Source);
+18
View File
@@ -3,12 +3,22 @@ namespace TrafagSalesExporter.Models;
public class SalesRecord
{
public DateTime ExtractionDate { get; set; }
public string SourceSystem { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public string SourceLineId { get; set; } = string.Empty;
public int DocumentEntry { get; set; }
public string InvoiceNumber { get; set; } = string.Empty;
public int PositionOnInvoice { get; set; }
public string Material { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ProductGroup { get; set; } = string.Empty;
public string ProductHierarchyCode { get; set; } = string.Empty;
public string ProductHierarchyText { get; set; } = string.Empty;
public string ProductFamilyCode { get; set; } = string.Empty;
public string ProductFamilyText { get; set; } = string.Empty;
public string ProductDivisionCode { get; set; } = string.Empty;
public string ProductDivisionText { get; set; } = string.Empty;
public string ProductMappingAssigned { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public string SupplierNumber { get; set; } = string.Empty;
public string SupplierName { get; set; } = string.Empty;
@@ -22,8 +32,16 @@ public class SalesRecord
public string PurchaseOrderNumber { get; set; } = string.Empty;
public decimal SalesPriceValue { get; set; }
public string SalesCurrency { get; set; } = string.Empty;
public string DocumentCurrency { get; set; } = string.Empty;
public decimal DocumentTotalForeignCurrency { get; set; }
public decimal DocumentTotalLocalCurrency { get; set; }
public decimal VatSumForeignCurrency { get; set; }
public decimal VatSumLocalCurrency { get; set; }
public decimal DocumentRate { get; set; }
public string CompanyCurrency { get; set; } = string.Empty;
public string Incoterms2020 { get; set; } = string.Empty;
public string SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? PostingDate { get; set; }
public DateTime? InvoiceDate { get; set; }
public DateTime? OrderDate { get; set; }
public string Land { get; set; } = string.Empty;
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class SapFieldMapping
{
public int Id { get; set; }
public int SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; }
[Required]
public string TargetField { get; set; } = nameof(SalesRecord.Material);
[Required]
public string SourceExpression { get; set; } = string.Empty;
public bool IsRequired { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class SapJoinDefinition
{
public int Id { get; set; }
public int SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; }
[Required]
public string LeftAlias { get; set; } = string.Empty;
[Required]
public string RightAlias { get; set; } = string.Empty;
[Required]
public string LeftKeys { get; set; } = string.Empty;
[Required]
public string RightKeys { get; set; } = string.Empty;
public string JoinType { get; set; } = "Left";
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class SapSourceDefinition
{
public int Id { get; set; }
public int SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; }
[Required]
public string Alias { get; set; } = string.Empty;
[Required]
public string EntitySet { get; set; } = string.Empty;
public bool IsPrimary { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -5,6 +5,7 @@ public class SharePointConfig
public int Id { get; set; }
public string SiteUrl { get; set; } = string.Empty;
public string ExportFolder { get; set; } = string.Empty;
public string CentralExportFolder { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
+17 -2
View File
@@ -7,7 +7,7 @@ public class Site
{
public int Id { get; set; }
public int HanaServerId { get; set; }
public int? HanaServerId { get; set; }
[ForeignKey(nameof(HanaServerId))]
public HanaServer? HanaServer { get; set; }
@@ -22,7 +22,22 @@ public class Site
public string Land { get; set; } = string.Empty;
[Required]
public string SourceSystem { get; set; } = "SAP";
public string SourceSystem { get; set; } = string.Empty;
public string UsernameOverride { get; set; } = string.Empty;
public string PasswordOverride { get; set; } = string.Empty;
public string LocalExportFolderOverride { get; set; } = string.Empty;
public string ManualImportFilePath { get; set; } = string.Empty;
public DateTime? ManualImportLastUploadedAtUtc { get; set; }
public string SapServiceUrl { get; set; } = string.Empty;
public string SapEntitySet { get; set; } = string.Empty;
public string SapEntitySetsCache { get; set; } = string.Empty;
public DateTime? SapEntitySetsRefreshedAtUtc { get; set; }
public bool IsActive { get; set; } = true;
}
@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
namespace TrafagSalesExporter.Models;
public class SourceSystemDefinition
{
public int Id { get; set; }
[Required]
public string Code { get; set; } = string.Empty;
[Required]
public string DisplayName { get; set; } = string.Empty;
[Required]
public string ConnectionKind { get; set; } = SourceSystemConnectionKinds.Hana;
public bool IsActive { get; set; } = true;
public string CentralServiceUrl { get; set; } = string.Empty;
public string CentralUsername { get; set; } = string.Empty;
public string CentralPassword { get; set; } = string.Empty;
}
public static class SourceSystemConnectionKinds
{
public const string Hana = "HANA";
public const string SapGateway = "SAP_GATEWAY";
public const string ManualExcel = "MANUAL_EXCEL";
public static readonly string[] All = [Hana, SapGateway, ManualExcel];
}
@@ -0,0 +1,38 @@
# Next Steps
Stand: 2026-05-27
Diese Datei ist fuer tokenarme RAG-Nutzung komprimiert.
## Aktueller Kurzstand
- Fuehrender Kurzkontext: `docs/rag/PROJECT.md`.
- Themenrouter: `docs/RAG_ROUTER.md`.
- Offene aktive Fachpunkte stehen in den jeweiligen Kurzdateien:
- Finance: `docs/rag/FINANCE.md`
- Manual Import: `docs/rag/MANUAL_IMPORT.md`
- HR KPI: `docs/rag/HR_KPI.md`
- Deployment: `docs/rag/DEPLOYMENT.md`
- Admin: `docs/rag/ADMIN.md`
## Offene Hauptpunkte
- DE Alphaplan-Fachabgrenzung muss durch Finance/Munir bestaetigt werden.
- IIS/TLS Serverkonfiguration muss durch IT/Serveradmin korrigiert werden.
- IT/ES/UK Finance-Spezialfaelle nur bei konkreter Nachfrage im Detail laden.
## Volltext Bei Bedarf
Die kanonische Detailhistorie liegt hier:
```text
docs/raw_md_archive/HISTORY_CANONICAL.md.raw
```
Die frueheren Original-Volltexte liegen als Wiederherstellungs-Backup hier:
```text
docs/raw_md_archive/original_history_raws.zip
```
Nur laden, wenn alte Review-Historie, genaue erledigte Punkte oder fruehere Priorisierung benoetigt werden.
+183 -10
View File
@@ -1,35 +1,139 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Server.IISIntegration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using MudBlazor.Services;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
using TrafagSalesExporter.Security;
using TrafagSalesExporter.Services;
using TrafagSalesExporter.Services.DataSources;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Warning);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();
var securitySettings = builder.Configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>() ?? new SecurityOptions();
var useDevelopmentAuthentication = builder.Environment.IsDevelopment() && securitySettings.DevelopmentBypass;
if (useDevelopmentAuthentication)
{
builder.Services
.AddAuthentication(DevelopmentAuthenticationHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, DevelopmentAuthenticationHandler>(
DevelopmentAuthenticationHandler.SchemeName,
options => { });
}
else
{
builder.Services.AddAuthentication(IISDefaults.AuthenticationScheme);
}
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = SecurityPolicyFactory.BuildAccessPolicy(securitySettings, useDevelopmentAuthentication);
options.AddPolicy(SecurityPolicies.AdminOnly, SecurityPolicyFactory.BuildAdminPolicy(securitySettings, useDevelopmentAuthentication));
});
builder.Services.AddMudServices();
builder.Services.AddHttpClient(nameof(ExchangeRateImportService));
builder.Services.Configure<HrKpiDataSourceOptions>(builder.Configuration.GetSection(HrKpiDataSourceOptions.SectionName));
builder.Services.Configure<HrKpiAccessOptions>(builder.Configuration.GetSection(HrKpiAccessOptions.SectionName));
builder.Services.Configure<FinanceCockpitAccessOptions>(builder.Configuration.GetSection(FinanceCockpitAccessOptions.SectionName));
builder.Services.Configure<AdminAccessOptions>(builder.Configuration.GetSection(AdminAccessOptions.SectionName));
builder.Services.Configure<LandingPageOptions>(builder.Configuration.GetSection(LandingPageOptions.SectionName));
builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db"));
options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60"));
builder.Services.AddSingleton<HanaQueryService>();
builder.Services.AddSingleton<ExcelExportService>();
builder.Services.AddSingleton<SharePointUploadService>();
builder.Services.AddSingleton<RecordTransformationService>();
// Stateless Infrastruktur- und Connector-Services: Singleton.
builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>();
builder.Services.AddSingleton<IExcelExportService, ExcelExportService>();
builder.Services.AddSingleton<ISharePointUploadService, SharePointUploadService>();
builder.Services.AddSingleton<ISapGatewayService, SapGatewayService>();
builder.Services.AddSingleton<IMappedSalesRecordComposer, MappedSalesRecordComposer>();
builder.Services.AddSingleton<ISapCompositionService, SapCompositionService>();
builder.Services.AddSingleton<ITransformationStrategy, CopyTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, UppercaseTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, LowercaseTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, PrefixTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, SuffixTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, ReplaceTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, ConstantTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, NormalizeCurrencyCodeTransformationStrategy>();
builder.Services.AddSingleton<ICurrencyExchangeRateService, CurrencyExchangeRateService>();
builder.Services.AddSingleton<IExchangeRateImportService, ExchangeRateImportService>();
builder.Services.AddSingleton<IRecordTransformationStrategy, FirstNonEmptyRecordTransformationStrategy>();
builder.Services.AddSingleton<IRecordTransformationStrategy, ConvertCurrencyRecordTransformationStrategy>();
builder.Services.AddSingleton<ITransformationCatalog, TransformationCatalog>();
builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>();
builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>();
builder.Services.AddSingleton<IHrKpiService, HrKpiService>();
builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>();
builder.Services.AddSingleton<IExportAuditCsvService, ExportAuditCsvService>();
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
builder.Services.AddSingleton<ICentralSalesDataProvider, CentralSalesDataProvider>();
builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>();
builder.Services.AddSingleton<IFinanceReconciliationService, FinanceReconciliationService>();
builder.Services.AddSingleton<IDatabaseSchemaMaintenanceService, DatabaseSchemaMaintenanceService>();
builder.Services.AddSingleton<IDatabaseSeedService, DatabaseSeedService>();
builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>();
builder.Services.AddSingleton<IUiTextService, UiTextService>();
builder.Services.AddSingleton<IAccessSessionTracker, AccessSessionTracker>();
builder.Services.AddSingleton<ILandingPageSettingsService, LandingPageSettingsService>();
builder.Services.AddSingleton<INavigationMenuService, NavigationMenuService>();
// Datenquellen-Adapter (Strategy per ConnectionKind).
builder.Services.AddSingleton<IDataSourceAdapter, HanaDataSourceAdapter>();
builder.Services.AddSingleton<IDataSourceAdapter, SapGatewayDataSourceAdapter>();
builder.Services.AddSingleton<IDataSourceAdapter, ManualExcelDataSourceAdapter>();
builder.Services.AddSingleton<IDataSourceAdapterResolver, DataSourceAdapterResolver>();
builder.Services.AddSingleton<ISiteExportService, SiteExportService>();
// Orchestrator mit gemeinsamem Status ueber alle Circuits.
builder.Services.AddSingleton<ExportOrchestrationService>();
builder.Services.AddSingleton<TimerBackgroundService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<TimerBackgroundService>());
// UI-/Page-Services: Scoped = pro Blazor-Circuit.
builder.Services.AddScoped<ISettingsPageService, SettingsPageService>();
builder.Services.AddScoped<IStandortePageService, StandortePageService>();
builder.Services.AddScoped<IStandorteSapEditorService, StandorteSapEditorService>();
builder.Services.AddScoped<IManagementCockpitPageService, ManagementCockpitPageService>();
builder.Services.AddScoped<IDashboardPageService, DashboardPageService>();
builder.Services.AddScoped<ILogsPageService, LogsPageService>();
builder.Services.AddScoped<ITransformationsPageService, TransformationsPageService>();
builder.Services.AddScoped<IFinanceRulesPageService, FinanceRulesPageService>();
builder.Services.AddScoped<IPurchasingDataSourcePageService, PurchasingDataSourcePageService>();
builder.Services.AddScoped<IPurchasingDashboardService, PurchasingDashboardService>();
builder.Services.AddScoped<IPurchasingDataRefreshService, PurchasingDataRefreshService>();
builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>();
builder.Services.AddScoped<IFinanceCockpitAccessService, FinanceCockpitAccessService>();
builder.Services.AddScoped<IAdminAccessService, AdminAccessService>();
var app = builder.Build();
var pathBase = app.Configuration["ASPNETCORE_PATHBASE"];
if (!string.IsNullOrWhiteSpace(pathBase))
{
app.UsePathBase(pathBase.Trim());
}
using (var scope = app.Services.CreateScope())
{
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
using var db = await dbFactory.CreateDbContextAsync();
await db.Database.EnsureCreatedAsync();
AppDbContext.EnsureSchema(db);
AppDbContext.SeedIfEmpty(db);
var databaseInitialization = scope.ServiceProvider.GetRequiredService<IDatabaseInitializationService>();
await databaseInitialization.InitializeAsync();
}
if (!app.Environment.IsDevelopment())
@@ -38,9 +142,78 @@ if (!app.Environment.IsDevelopment())
}
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapPost("/access/finance", async (HttpContext httpContext, IOptions<FinanceCockpitAccessOptions> options) =>
{
var form = await httpContext.Request.ReadFormAsync();
var settings = options.Value;
var username = form["username"].ToString();
var password = form["password"].ToString();
if (MatchesAccess(settings.Enabled, settings.Username, settings.PasswordHash, settings.Password, username, password))
AccessUnlockCookie.SetUnlocked(httpContext, AccessUnlockCookie.FinanceCookieName, settings.PasswordHash);
return Results.Redirect(ResolveReturnUrl(httpContext, form["returnUrl"].ToString()));
}).DisableAntiforgery();
app.MapPost("/access/admin", async (HttpContext httpContext, IOptions<AdminAccessOptions> options) =>
{
var form = await httpContext.Request.ReadFormAsync();
var settings = options.Value;
var username = form["username"].ToString();
var password = form["password"].ToString();
if (MatchesAccess(settings.Enabled, settings.Username, settings.PasswordHash, settings.Password, username, password))
AccessUnlockCookie.SetUnlocked(httpContext, AccessUnlockCookie.AdminCookieName, settings.PasswordHash);
return Results.Redirect(ResolveReturnUrl(httpContext, form["returnUrl"].ToString()));
}).DisableAntiforgery();
app.MapPost("/access/hr", async (HttpContext httpContext, IOptions<HrKpiAccessOptions> options) =>
{
var form = await httpContext.Request.ReadFormAsync();
var settings = options.Value;
var username = form["username"].ToString();
var password = form["password"].ToString();
if (MatchesAccess(settings.Enabled, settings.Username, settings.PasswordHash, settings.Password, username, password))
AccessUnlockCookie.SetUnlocked(httpContext, AccessUnlockCookie.HrCookieName, settings.PasswordHash);
return Results.Redirect(ResolveReturnUrl(httpContext, form["returnUrl"].ToString()));
}).DisableAntiforgery();
app.MapRazorComponents<TrafagSalesExporter.Components.App>()
.AddInteractiveServerRenderMode();
app.Run();
static bool MatchesAccess(bool enabled, string configuredUsername, string configuredHash, string configuredPassword, string username, string password)
{
if (!enabled)
return true;
if (string.IsNullOrWhiteSpace(username) ||
string.IsNullOrEmpty(password) ||
!string.Equals(username.Trim(), configuredUsername.Trim(), StringComparison.Ordinal))
{
return false;
}
return !string.IsNullOrWhiteSpace(configuredHash)
? string.Equals(AccessPasswordSettingsWriter.HashPassword(password), configuredHash.Trim(), StringComparison.Ordinal)
: string.Equals(password, configuredPassword, StringComparison.Ordinal);
}
static string ResolveReturnUrl(HttpContext httpContext, string returnUrl)
{
if (Uri.TryCreate(returnUrl, UriKind.Absolute, out var absolute) &&
string.Equals(absolute.Host, httpContext.Request.Host.Host, StringComparison.OrdinalIgnoreCase))
{
return absolute.PathAndQuery;
}
if (Uri.TryCreate(returnUrl, UriKind.Relative, out _))
return returnUrl;
return $"{httpContext.Request.PathBase}/";
}
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<DeleteExistingFiles>true</DeleteExistingFiles>
<ExcludeApp_Data>false</ExcludeApp_Data>
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<PublishProvider>FileSystem</PublishProvider>
<PublishProtocol>FileSystem</PublishProtocol>
<WebPublishMethod>FileSystem</WebPublishMethod>
<PublishUrl>\\trch-webapp-bidashboard.trafagch.local\BiDashboard$\</PublishUrl>
<PublishDir>\\trch-webapp-bidashboard.trafagch.local\BiDashboard$\</PublishDir>
<_TargetId>Folder</_TargetId>
<SiteUrlToLaunchAfterPublish />
<TargetFramework>net8.0</TargetFramework>
<ProjectGuid>19995fb6-e1d1-45af-8fe3-b46bb3c80732</ProjectGuid>
<SelfContained>false</SelfContained>
<UseAppHost>false</UseAppHost>
<PublishSingleFile>false</PublishSingleFile>
</PropertyGroup>
</Project>
@@ -0,0 +1,12 @@
{
"profiles": {
"TrafagSalesExporter": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:55415;http://localhost:55416;http://0.0.0.0:5000"
}
}
}
+39
View File
@@ -0,0 +1,39 @@
Sage SQL CSV export
===================
Server instance: localhost
Database filter: (all accessible user databases)
From date: 2025-01-01
To date: 2026-01-01
Files:
- candidate_objects.csv: SQL tables/views that look relevant for sales/invoices.
- export_summary.csv: export status and row counts.
- *.csv: exported samples or selected full exports.
Recommended workflow:
1. Run discovery first:
.\Export-SageSqlCsv.ps1 -DiscoverOnly
2. Send candidate_objects.csv to Trafag/IT for selection.
3. Export selected objects:
.\Export-SageSqlCsv.ps1 -Database "DATABASE_NAME" -ObjectName "schema.table_or_view"
4. If the selected object is very large, add:
-FromDate "2025-01-01" -ToDate "2026-01-01" -MaxRowsPerObject 100000
The script only reads data. It does not change SQL Server or Sage.
@@ -0,0 +1,241 @@
# Sage Spain Export
Stand: 2026-06-01
Nachtrag 2026-06-01:
- Finance/Andreas bestaetigt: Spanien hat keine echte Ist-Abweichung.
- Der Wert `3'082'320.18 EUR` ist fachlich plausibel und wird als ES-Referenz 2025 verwendet.
- Der alte Sollwert `3'102'333.61 EUR` war ein Referenz-/Excel-Fehler.
- Die historischen Abschnitte unten dokumentieren den frueheren Analysepfad und sind nicht mehr als aktueller ES-Status zu lesen.
## Aktueller Kurzstatus
- Spanien-v2-Export ist technisch lauffaehig und im Testprogramm sichtbar.
- Datei: `sagespain/v2/Spain_Sales_2025.csv`
- Ist 2025: `3'082'320.18` EUR
- Korrigierte Referenz: `3'082'320.18`
- Differenz: `0.00`
- Status FinanceProbe: OK, sofern die korrigierte Referenz geladen ist
- Finale Aussage: technisch importierbar und laut Sitzung fachlich plausibel; alter Sollwert war falsch.
FinanceProbe lokal:
```text
http://localhost:55417/finance
```
Relevante Abschnitte:
- `Meeting Ampel 2025`
- `Detail alle Laender`
- `Spain CSV direct check`
Wichtig:
- Spanien wird in der Detailtabelle nicht mehr als `Keine Daten` gezeigt, wenn `Spain_Sales_2025.csv` vorhanden ist.
- Stattdessen wird der v2-CSV-Wert mit Status `Pruefen` angezeigt.
- Die CSV-Datei kann spaeter als `MANUAL_EXCEL`-Quelle importiert werden.
## Ziel
Spanien soll Verkaufsdaten aus `Sage 200c` liefern koennen, damit der Standort in `TrafagSalesExporter` wie die anderen Laender in die zentrale Auswertung und Finance-Abgrenzung aufgenommen werden kann.
## Systemstand Spanien
Ermittelt mit `scripts/Get-SageSqlEnvironment.ps1`.
- Windows Server: `Microsoft Windows Server 2019 Standard`, Build `17763`
- Server: `WIN-4BJQJ9S1PVJ`
- Sage: `Sage 200c`
- Sage-Version: `2026.56.000`
- SQL Server: `Microsoft SQL Server 2019 Standard Edition (64-bit)`
- SQL Build: `15.0.2155.2`
- SQL Full Version: `Microsoft SQL Server 2019 (RTM-GDR) (KB5068405) - 15.0.2155.2 (X64)`
- SQL Instance: Default Instance `MSSQLSERVER`, erreichbar als `localhost`
- Datenbank: `Sage`
- Collation: `Latin1_General_CI_AI`
## Discovery
Ermittelt mit `scripts/Export-SageSqlCsv.ps1`.
Relevante Kandidaten:
- `dbo.CabeceraAlbaranCliente`
- `dbo.LineasAlbaranCliente`
- `dbo.EstadisVenta`
- `dbo.EstadisVentaTallas`
- `dbo.FacturasTB`
- `dbo.MovimientosFacturas`
- `dbo.Vis_RTDV_EfectosFactura`
Beobachtung:
- `CabeceraAlbaranCliente` ist der Verkaufs-/Albaran-Belegkopf.
- `LineasAlbaranCliente` enthaelt die Verkaufspositionen.
- `EstadisVenta` enthaelt Statistikdaten, aber im gelieferten Export keine 2025-Zeilen.
- `FacturasTB` und `MovimientosFacturas` wirken eher Finanz-/Steuer-/Buchungsdaten und enthalten gemischte Bewegungen.
## Export v2
Finaler Export-Kandidat wurde mit `SageSpainFinalExportPackage.zip` bzw. danach `v2.zip` erstellt; aktueller Paketordner im Repo: `SageSpainExportPackage/SageSpainFinalExportPackage/`.
Script:
- `scripts/Export-SageSpainSalesCsv.ps1`
Output von Spanien:
- `sagespain/v2/Spain_Sales_2025.csv`
- `sagespain/v2/Spain_Sales_2025_summary.txt`
Quelle:
- Header: `dbo.CabeceraAlbaranCliente`
- Lines: `dbo.LineasAlbaranCliente`
- Join:
- `CodigoEmpresa`
- `EjercicioAlbaran`
- `SerieAlbaran`
- `NumeroAlbaran`
Filter:
- `CabeceraAlbaranCliente.FechaFactura >= 2025-01-01`
- `CabeceraAlbaranCliente.FechaFactura < 2026-01-01`
Export-Spalten sind bereits auf das Zielmodell der App ausgerichtet, u. a.:
- `TSC`
- `Land`
- `InvoiceNumber`
- `PositionOnInvoice`
- `Material`
- `Name`
- `ProductGroup`
- `Quantity`
- `CustomerNumber`
- `CustomerName`
- `CustomerCountry`
- `StandardCost`
- `StandardCostCurrency`
- `PurchaseOrderNumber`
- `SalesPriceValue`
- `SalesCurrency`
- `DocumentCurrency`
- `CompanyCurrency`
- `InvoiceDate`
- `DocumentType`
## Ergebnis Export v2
Aus `Spain_Sales_2025_summary.txt`:
- Zeilen: `4'341`
- `SalesPriceValue` Summe: `3'082'320.18`
- `SalesPriceValue` = `LineasAlbaranCliente.ImporteNeto`
- Waehrung: `EUR`
Aufteilung:
- Invoices: `3'140'921.50`
- Credit Notes / REC: `-58'601.32`
- Total: `3'082'320.18`
Nach Serie:
- `REG`: `2'407'451.30`
- `LAT`: `480'199.20`
- `PRO`: `253'271.00`
- `REC`: `-58'601.32`
## Abgleich gegen check.xlsx
Sollwert fuer Spanien aus `check.xlsx`:
- `3'102'333.61`
Aktueller Export v2:
- `3'082'320.18`
Differenz:
- `-20'013.43`
Fruehere breite Positionssumme aus `LineasAlbaranCliente.ImporteNeto` ohne Join-/Rechnungsdatumsfilter lag bei:
- `3'094'474.32`
- Differenz zur Sollzahl: `-7'859.29`
## Offene fachliche Klaerung
Spanien / Finance muss noch klaeren, woher die Differenz kommt.
Zu pruefen:
1. Ist `FechaFactura` das korrekte Periodendatum?
2. Oder muss `FechaAlbaran` bzw. `FechaRegistro` verwendet werden?
3. Muessen Zeilen ohne `EjercicioFactura = 2025` in die Sollzahl?
4. Sind alle Serien `REG`, `LAT`, `PRO`, `REC` enthalten?
5. Muessen `REC`-Abos negativ abgezogen werden?
6. Gibt es weitere Serien oder Dokumenttypen ausserhalb `CabeceraAlbaranCliente` / `LineasAlbaranCliente`?
7. Gibt es eine offizielle Sage-Auswertung, die `3'102'333.61` erzeugt und deren Filter genannt werden koennen?
## Einbau ins Hauptprogramm
Umgesetzt:
- `ManualExcelImportService` kann jetzt neben `.xlsx` auch semikolongetrennte `.csv`-Dateien lesen.
- Der CSV-Reader unterstuetzt quotierte Felder und mehrzeilige Texte.
- Das Spanien-v2-CSV ist damit als `MANUAL_EXCEL`-Quelle importierbar.
- `Tools/FinanceProbe` hat einen direkten `Spain CSV direct check`.
- Die Probe sucht automatisch nach `Spain_Sales_2025.csv`, bevorzugt unter `sagespain/v2`.
- Angezeigt werden Zeilen, `SalesPriceValue`, Sollwert `3'102'333.61`, Differenz, Aufteilung nach `DocumentType` und `InvoiceSeries`.
- Spanien wird in der FinanceProbe-Detailtabelle mit dem v2-CSV-Wert angezeigt, nicht mehr als `Keine Daten`.
- In der Management-Ampel bleibt Spanien gelb, bis die Differenz fachlich geklaert ist.
- `DatabaseSeedService` stellt einen deaktivierten Spanien-Standort bereit, falls noch kein Spanien-Standort existiert:
- `TSC = TRES`
- `Land = Spanien`
- `SourceSystem = MANUAL_EXCEL`
- `IsActive = false`
Wichtig:
- Das Programm setzt den Dateipfad nicht automatisch, weil der Pfad pro Umgebung unterschiedlich ist.
- In der UI muss beim Standort Spanien die Datei `Spain_Sales_2025.csv` hinterlegt werden.
- Danach kann Spanien wie ein manueller Standort exportiert werden; die Daten landen in `CentralSalesRecords`.
## Naechster Schritt
1. App starten.
2. `Standorte` oeffnen.
3. Spanien pruefen bzw. aktivieren.
4. `SourceSystem = MANUAL_EXCEL`.
5. `Spain_Sales_2025.csv` als manuelle Datei hinterlegen.
6. Standort Spanien exportieren.
7. Finance-Probe / Dashboard erneut pruefen.
8. Differenz zu `check.xlsx` fachlich mit Spanien/Finance klaeren.
## Abgrenzung Deutschland
Am selben Tag wurde auch ein Deutschland-Beispielfile gefunden:
```text
DE_Beispiel_Export_Daten.xlsx
```
Dieses File ist nicht Teil des Spanien-Exports, aber im FinanceProbe als separater `Germany Excel sample check` sichtbar.
Deutschland-Sample:
- relevante Spalte: `NettoPreisGesamtX`
- Summe: `8'290.70` EUR
- Betragszeilen: `2`
- Bewertung: technisch lesbar, aber kein finaler DE-Jahresfile
Fuer die Gesamtampel heisst das:
- Spanien: technische v2-Datei vorhanden, Differenz offen
- Deutschland: Format verstanden, aber finale Jahresdatei fehlt
Binary file not shown.
@@ -0,0 +1,16 @@
$scriptPath = Join-Path $PSScriptRoot "Export-SageSqlCsv.ps1"
& $scriptPath `
-Database "Sage" `
-ObjectName @(
"dbo.CabeceraAlbaranCliente",
"dbo.LineasAlbaranCliente",
"dbo.EstadisVenta",
"dbo.EstadisVentaTallas",
"dbo.FacturasTB",
"dbo.MovimientosFacturas",
"dbo.Vis_RTDV_EfectosFactura"
) `
-FromDate "2025-01-01" `
-ToDate "2026-01-01" `
-MaxRowsPerObject 10000
@@ -0,0 +1,15 @@
$scriptPath = Join-Path $PSScriptRoot "Export-SageSqlCsv.ps1"
& $scriptPath `
-Database "Sage" `
-ObjectName @(
"dbo.CabeceraAlbaranCliente",
"dbo.LineasAlbaranCliente",
"dbo.EstadisVenta",
"dbo.EstadisVentaTallas",
"dbo.FacturasTB",
"dbo.MovimientosFacturas",
"dbo.Vis_RTDV_EfectosFactura"
) `
-FromDate "2025-01-01" `
-ToDate "2026-01-01"
@@ -0,0 +1,410 @@
param(
[string]$ServerInstance = "localhost",
[string]$Database = "",
[string[]]$ObjectName = @(),
[datetime]$FromDate = "2025-01-01",
[datetime]$ToDate = "2026-01-01",
[string]$OutputDirectory = (Join-Path $env:USERPROFILE "Desktop"),
[int]$SampleRows = 500,
[int]$MaxRowsPerObject = 0,
[switch]$DiscoverOnly,
[switch]$ExportCandidates,
[switch]$IncludeSystemDatabases
)
$ErrorActionPreference = "Stop"
function New-Connection {
param([string]$DbName)
$builder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
$builder["Data Source"] = $ServerInstance
$builder["Initial Catalog"] = $DbName
$builder["Integrated Security"] = $true
$builder["TrustServerCertificate"] = $true
$builder["Connect Timeout"] = 15
return New-Object System.Data.SqlClient.SqlConnection($builder.ConnectionString)
}
function Invoke-DataTable {
param(
[string]$DbName,
[string]$Sql,
[hashtable]$Parameters = @{}
)
$conn = New-Connection $DbName
$cmd = $conn.CreateCommand()
$cmd.CommandText = $Sql
$cmd.CommandTimeout = 300
foreach ($key in $Parameters.Keys) {
$param = $cmd.Parameters.Add("@$key", [System.Data.SqlDbType]::NVarChar, 4000)
$param.Value = [string]$Parameters[$key]
}
$table = New-Object System.Data.DataTable
try {
$conn.Open()
$reader = $cmd.ExecuteReader()
$table.Load($reader)
}
finally {
$conn.Dispose()
}
return $table
}
function Convert-ToCsvValue {
param($Value)
if ($null -eq $Value -or $Value -is [System.DBNull]) {
return ""
}
if ($Value -is [datetime]) {
$text = $Value.ToString("yyyy-MM-dd HH:mm:ss")
}
else {
$text = [string]$Value
}
$text = $text.Replace('"', '""')
return '"' + $text + '"'
}
function Export-QueryToCsv {
param(
[string]$DbName,
[string]$Sql,
[string]$Path
)
$conn = New-Connection $DbName
$cmd = $conn.CreateCommand()
$cmd.CommandText = $Sql
$cmd.CommandTimeout = 0
$writer = New-Object System.IO.StreamWriter($Path, $false, [System.Text.Encoding]::UTF8)
$rowCount = 0
try {
$conn.Open()
$reader = $cmd.ExecuteReader()
$headers = for ($i = 0; $i -lt $reader.FieldCount; $i++) {
Convert-ToCsvValue $reader.GetName($i)
}
$writer.WriteLine(($headers -join ";"))
while ($reader.Read()) {
$values = for ($i = 0; $i -lt $reader.FieldCount; $i++) {
Convert-ToCsvValue $reader.GetValue($i)
}
$writer.WriteLine(($values -join ";"))
$rowCount++
}
}
finally {
$writer.Dispose()
$conn.Dispose()
}
return $rowCount
}
function Quote-NamePart {
param([string]$Name)
return "[" + $Name.Replace("]", "]]") + "]"
}
function Split-SqlObjectName {
param([string]$Name)
$parts = $Name.Split(".", 2)
if ($parts.Count -eq 1) {
return [pscustomobject]@{ SchemaName = "dbo"; ObjectName = $parts[0] }
}
return [pscustomobject]@{ SchemaName = $parts[0].Trim("[", "]"); ObjectName = $parts[1].Trim("[", "]") }
}
function Get-UserDatabases {
$sql = @"
SELECT name
FROM sys.databases
WHERE state_desc = 'ONLINE'
AND HAS_DBACCESS(name) = 1
$(if ($IncludeSystemDatabases) { "" } else { "AND database_id > 4" })
ORDER BY name;
"@
Invoke-DataTable "master" $sql | ForEach-Object { $_.name }
}
function Get-CandidateObjects {
param([string]$DbName)
$sql = @"
WITH object_columns AS (
SELECT
s.name AS SchemaName,
o.name AS ObjectName,
o.type_desc AS ObjectType,
c.name AS ColumnName,
t.name AS TypeName,
c.max_length,
c.precision,
c.scale
FROM sys.objects o
JOIN sys.schemas s ON s.schema_id = o.schema_id
JOIN sys.columns c ON c.object_id = o.object_id
JOIN sys.types t ON t.user_type_id = c.user_type_id
WHERE o.type IN ('U', 'V')
AND o.is_ms_shipped = 0
),
scored AS (
SELECT
SchemaName,
ObjectName,
ObjectType,
SUM(CASE WHEN LOWER(ObjectName) LIKE '%fact%' OR LOWER(ObjectName) LIKE '%invoice%' OR LOWER(ObjectName) LIKE '%venta%' OR LOWER(ObjectName) LIKE '%sales%' OR LOWER(ObjectName) LIKE '%albar%' OR LOWER(ObjectName) LIKE '%pedido%' THEN 5 ELSE 0 END) +
SUM(CASE WHEN LOWER(ColumnName) LIKE '%fecha%' OR LOWER(ColumnName) LIKE '%date%' THEN 2 ELSE 0 END) +
SUM(CASE WHEN LOWER(ColumnName) LIKE '%cliente%' OR LOWER(ColumnName) LIKE '%customer%' THEN 2 ELSE 0 END) +
SUM(CASE WHEN LOWER(ColumnName) LIKE '%articulo%' OR LOWER(ColumnName) LIKE '%item%' OR LOWER(ColumnName) LIKE '%producto%' THEN 2 ELSE 0 END) +
SUM(CASE WHEN LOWER(ColumnName) LIKE '%importe%' OR LOWER(ColumnName) LIKE '%neto%' OR LOWER(ColumnName) LIKE '%total%' OR LOWER(ColumnName) LIKE '%amount%' THEN 3 ELSE 0 END) +
SUM(CASE WHEN LOWER(ColumnName) LIKE '%cantidad%' OR LOWER(ColumnName) LIKE '%quantity%' OR LOWER(ColumnName) LIKE '%unidades%' THEN 2 ELSE 0 END) AS Score,
COUNT(*) AS ColumnCount,
STRING_AGG(CONVERT(nvarchar(max), ColumnName), ', ') WITHIN GROUP (ORDER BY ColumnName) AS Columns
FROM object_columns
GROUP BY SchemaName, ObjectName, ObjectType
)
SELECT TOP (80)
DB_NAME() AS DatabaseName,
SchemaName,
ObjectName,
ObjectType,
Score,
ColumnCount,
Columns
FROM scored
WHERE Score > 0
ORDER BY Score DESC, ObjectName;
"@
Invoke-DataTable $DbName $sql
}
function Get-DateColumns {
param(
[string]$DbName,
[string]$SchemaName,
[string]$ObjectNameValue
)
$sql = @"
SELECT c.name AS ColumnName
FROM sys.objects o
JOIN sys.schemas s ON s.schema_id = o.schema_id
JOIN sys.columns c ON c.object_id = o.object_id
JOIN sys.types t ON t.user_type_id = c.user_type_id
WHERE s.name = @schema
AND o.name = @object
AND (
t.name IN ('date', 'datetime', 'datetime2', 'smalldatetime')
OR LOWER(c.name) LIKE '%fecha%'
OR LOWER(c.name) LIKE '%date%'
)
ORDER BY
CASE
WHEN LOWER(c.name) LIKE '%fact%' OR LOWER(c.name) LIKE '%invoice%' THEN 0
WHEN LOWER(c.name) LIKE '%fecha%' OR LOWER(c.name) LIKE '%date%' THEN 1
ELSE 2
END,
c.column_id;
"@
Invoke-DataTable $DbName $sql @{ schema = $SchemaName; object = $ObjectNameValue } |
ForEach-Object { $_.ColumnName }
}
function Build-SelectSql {
param(
[string]$SchemaName,
[string]$ObjectNameValue,
[string]$DateColumn,
[int]$TopRows
)
$topClause = if ($TopRows -gt 0) { "TOP ($TopRows)" } else { "" }
$qualified = "$(Quote-NamePart $SchemaName).$(Quote-NamePart $ObjectNameValue)"
if ([string]::IsNullOrWhiteSpace($DateColumn)) {
return "SELECT $topClause * FROM $qualified;"
}
$from = $FromDate.ToString("yyyy-MM-dd")
$to = $ToDate.ToString("yyyy-MM-dd")
$dateColumnSql = Quote-NamePart $DateColumn
return @"
SELECT $topClause *
FROM $qualified
WHERE TRY_CONVERT(date, $dateColumnSql) >= CONVERT(date, '$from')
AND TRY_CONVERT(date, $dateColumnSql) < CONVERT(date, '$to')
ORDER BY TRY_CONVERT(date, $dateColumnSql);
"@
}
function Normalize-FileName {
param([string]$Value)
return ($Value -replace '[\\/:*?"<>|]', '_')
}
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$runDirectory = Join-Path $OutputDirectory "Sage_SQL_CSV_Export_$timestamp"
New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null
$databases = if ([string]::IsNullOrWhiteSpace($Database)) {
@(Get-UserDatabases)
}
else {
@($Database)
}
$summary = New-Object System.Collections.Generic.List[object]
$allCandidates = New-Object System.Collections.Generic.List[object]
foreach ($db in $databases) {
Write-Host "Scanning database: $db"
try {
$candidates = @(Get-CandidateObjects $db)
foreach ($candidate in $candidates) {
$allCandidates.Add($candidate)
}
}
catch {
$summary.Add([pscustomobject]@{
Database = $db
Object = ""
Action = "Discovery failed"
Rows = 0
File = ""
Error = $_.Exception.Message
})
}
}
$candidatePath = Join-Path $runDirectory "candidate_objects.csv"
if ($allCandidates.Count -gt 0) {
$allCandidates | Export-Csv -LiteralPath $candidatePath -NoTypeInformation -Encoding UTF8 -Delimiter ";"
}
if (-not $DiscoverOnly) {
$objectsToExport = New-Object System.Collections.Generic.List[object]
foreach ($name in $ObjectName) {
if ([string]::IsNullOrWhiteSpace($name)) {
continue
}
if ([string]::IsNullOrWhiteSpace($Database)) {
throw "When -ObjectName is used, pass -Database as well."
}
$parsed = Split-SqlObjectName $name
$objectsToExport.Add([pscustomobject]@{
DatabaseName = $Database
SchemaName = $parsed.SchemaName
ObjectName = $parsed.ObjectName
})
}
if ($ExportCandidates) {
foreach ($candidate in ($allCandidates | Sort-Object DatabaseName, @{Expression="Score"; Descending=$true} | Select-Object -First 25)) {
$objectsToExport.Add([pscustomobject]@{
DatabaseName = $candidate.DatabaseName
SchemaName = $candidate.SchemaName
ObjectName = $candidate.ObjectName
})
}
}
foreach ($object in $objectsToExport) {
$db = $object.DatabaseName
$schema = $object.SchemaName
$objectNameValue = $object.ObjectName
try {
$dateColumn = @(Get-DateColumns $db $schema $objectNameValue | Select-Object -First 1)[0]
$limit = if ($MaxRowsPerObject -gt 0) { $MaxRowsPerObject } elseif ($ObjectName.Count -gt 0) { 0 } else { $SampleRows }
$sql = Build-SelectSql $schema $objectNameValue $dateColumn $limit
$fileName = Normalize-FileName "$db.$schema.$objectNameValue.csv"
$path = Join-Path $runDirectory $fileName
Write-Host "Exporting $db.$schema.$objectNameValue -> $path"
$rows = Export-QueryToCsv $db $sql $path
$summary.Add([pscustomobject]@{
Database = $db
Object = "$schema.$objectNameValue"
Action = "Exported"
Rows = $rows
File = $path
DateColumn = $dateColumn
Error = ""
})
}
catch {
$summary.Add([pscustomobject]@{
Database = $db
Object = "$schema.$objectNameValue"
Action = "Export failed"
Rows = 0
File = ""
DateColumn = ""
Error = $_.Exception.Message
})
}
}
}
$summaryPath = Join-Path $runDirectory "export_summary.csv"
$summary | Export-Csv -LiteralPath $summaryPath -NoTypeInformation -Encoding UTF8 -Delimiter ";"
$readmePath = Join-Path $runDirectory "README.txt"
@"
Sage SQL CSV export
===================
Server instance: $ServerInstance
Database filter: $(if ($Database) { $Database } else { "(all accessible user databases)" })
From date: $($FromDate.ToString("yyyy-MM-dd"))
To date: $($ToDate.ToString("yyyy-MM-dd"))
Files:
- candidate_objects.csv: SQL tables/views that look relevant for sales/invoices.
- export_summary.csv: export status and row counts.
- *.csv: exported samples or selected full exports.
Recommended workflow:
1. Run discovery first:
.\Export-SageSqlCsv.ps1 -DiscoverOnly
2. Send candidate_objects.csv to Trafag/IT for selection.
3. Export selected objects:
.\Export-SageSqlCsv.ps1 -Database "DATABASE_NAME" -ObjectName "schema.table_or_view"
4. If the selected object is very large, add:
-FromDate "2025-01-01" -ToDate "2026-01-01" -MaxRowsPerObject 100000
The script only reads data. It does not change SQL Server or Sage.
"@ | Set-Content -LiteralPath $readmePath -Encoding UTF8
Write-Host ""
Write-Host "Created folder:"
Write-Host " $runDirectory"
Write-Host ""
Write-Host "Main files:"
Write-Host " $candidatePath"
Write-Host " $summaryPath"
@@ -0,0 +1,258 @@
param(
[string]$ServerInstance = "localhost",
[string]$Database = "Sage",
[ValidateSet("Full", "Range")]
[string]$ExportMode = "Full",
[ValidateSet("InvoiceDate", "LineRegistrationDate")]
[string]$DateFilter = "InvoiceDate",
[int]$Year = 2025,
[datetime]$FromDate = "2025-01-01",
[datetime]$ToDate = "2026-01-01",
[string]$OutputDirectory = (Join-Path $env:USERPROFILE "Desktop"),
[string]$OutputFileName = ""
)
$ErrorActionPreference = "Stop"
function New-Connection {
$builder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
$builder["Data Source"] = $ServerInstance
$builder["Initial Catalog"] = $Database
$builder["Integrated Security"] = $true
$builder["TrustServerCertificate"] = $true
$builder["Connect Timeout"] = 15
return New-Object System.Data.SqlClient.SqlConnection($builder.ConnectionString)
}
function Convert-ToCsvValue {
param($Value)
if ($null -eq $Value -or $Value -is [System.DBNull]) {
return ""
}
if ($Value -is [datetime]) {
$text = $Value.ToString("yyyy-MM-dd HH:mm:ss")
}
else {
$text = [string]$Value
}
$text = $text.Replace('"', '""')
return '"' + $text + '"'
}
function Export-QueryToCsv {
param(
[string]$Sql,
[string]$Path
)
$conn = New-Connection
$cmd = $conn.CreateCommand()
$cmd.CommandText = $Sql
$cmd.CommandTimeout = 0
$fromParameter = $cmd.Parameters.Add("@FromDate", [System.Data.SqlDbType]::Date)
$fromParameter.Value = $FromDate.Date
$toParameter = $cmd.Parameters.Add("@ToDate", [System.Data.SqlDbType]::Date)
$toParameter.Value = $ToDate.Date
$writer = New-Object System.IO.StreamWriter($Path, $false, [System.Text.Encoding]::UTF8)
$rowCount = 0
$salesSum = [decimal]0
try {
$conn.Open()
$reader = $cmd.ExecuteReader()
$headers = for ($i = 0; $i -lt $reader.FieldCount; $i++) {
Convert-ToCsvValue $reader.GetName($i)
}
$writer.WriteLine(($headers -join ";"))
$salesIndex = -1
for ($i = 0; $i -lt $reader.FieldCount; $i++) {
if ($reader.GetName($i) -eq "SalesPriceValue") {
$salesIndex = $i
break
}
}
while ($reader.Read()) {
$values = for ($i = 0; $i -lt $reader.FieldCount; $i++) {
Convert-ToCsvValue $reader.GetValue($i)
}
$writer.WriteLine(($values -join ";"))
$rowCount++
if ($salesIndex -ge 0 -and -not $reader.IsDBNull($salesIndex)) {
$salesSum += [decimal]$reader.GetValue($salesIndex)
}
}
}
finally {
$writer.Dispose()
$conn.Dispose()
}
return [pscustomobject]@{
Rows = $rowCount
SalesPriceValueSum = $salesSum
}
}
if ($ExportMode -eq "Full") {
$FromDate = [datetime]::new($Year, 1, 1)
$ToDate = $FromDate.AddYears(1)
}
else {
if (-not $PSBoundParameters.ContainsKey("ToDate")) {
throw "Range export requires -ToDate. Example: -ExportMode Range -FromDate '2025-05-01' -ToDate '2025-06-01'"
}
}
if ($ToDate.Date -le $FromDate.Date) {
throw "ToDate must be later than FromDate. FromDate=$($FromDate.ToString("yyyy-MM-dd")), ToDate=$($ToDate.ToString("yyyy-MM-dd"))"
}
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$runDirectory = Join-Path $OutputDirectory "Sage_Spain_Sales_Export_$timestamp"
New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null
if ([string]::IsNullOrWhiteSpace($OutputFileName)) {
$fromToken = $FromDate.ToString("yyyyMMdd")
$toToken = $ToDate.Date.AddDays(-1).ToString("yyyyMMdd")
$kindToken = $ExportMode.ToLowerInvariant()
$OutputFileName = "Spain_Sales_${kindToken}_${fromToken}_to_${toToken}.csv"
}
$csvPath = Join-Path $runDirectory $OutputFileName
$summaryPath = Join-Path $runDirectory ([System.IO.Path]::GetFileNameWithoutExtension($OutputFileName) + "_summary.txt")
$datePredicate = if ($DateFilter -eq "LineRegistrationDate") {
"COALESCE(l.FechaRegistro, c.FechaFactura) >= @FromDate
AND COALESCE(l.FechaRegistro, c.FechaFactura) < @ToDate"
} else {
"c.FechaFactura >= @FromDate
AND c.FechaFactura < @ToDate"
}
$sql = @"
SELECT
'TRES' AS TSC,
'Spanien' AS Land,
'Sage' AS SourceSystem,
c.CodigoEmpresa AS CompanyCode,
c.EjercicioAlbaran AS DeliveryYear,
c.SerieAlbaran AS DeliverySeries,
c.NumeroAlbaran AS DeliveryNumber,
c.EjercicioFactura AS InvoiceYear,
c.SerieFactura AS InvoiceSeries,
c.NumeroFactura AS InvoiceNumber,
l.Orden AS PositionOnInvoice,
l.LineasPosicion AS SourceLineId,
l.CodigoArticulo AS Material,
l.DescripcionArticulo AS Name,
l.Descripcion2Articulo AS Description2,
l.DescripcionLinea AS DescriptionLine,
l.CodigoFamilia AS ProductGroup,
l.CodigoSubfamilia AS ProductSubGroup,
CAST(l.Unidades AS decimal(19, 6)) AS Quantity,
c.CodigoCliente AS CustomerNumber,
c.Nombre AS CustomerName,
c.CodigoNacion AS CustomerCountryCode,
c.Nacion AS CustomerCountry,
CAST(l.PrecioCoste AS decimal(19, 6)) AS StandardCost,
CAST(l.ImporteCoste AS decimal(19, 6)) AS StandardCostValue,
'EUR' AS StandardCostCurrency,
CAST(CASE
WHEN c.TipoNuevaFra = 2 OR c.SerieFactura = 'REC' OR c.StatusAbono <> 0 THEN -ABS(l.ImporteNeto)
ELSE l.ImporteNeto
END AS decimal(19, 6)) AS SalesPriceValue,
'EUR' AS SalesCurrency,
'EUR' AS DocumentCurrency,
'EUR' AS CompanyCurrency,
c.CodigoDivisa AS SageCurrencyCode,
CAST(CASE
WHEN c.TipoNuevaFra = 2 OR c.SerieFactura = 'REC' OR c.StatusAbono <> 0 THEN -ABS(c.BaseImponible)
ELSE c.BaseImponible
END AS decimal(19, 6)) AS DocumentNetAmount,
CAST(c.TotalIva AS decimal(19, 6)) AS DocumentVatAmount,
CAST(c.ImporteFactura AS decimal(19, 6)) AS DocumentGrossAmount,
c.FechaFactura AS InvoiceDate,
c.FechaAlbaran AS DeliveryDate,
l.FechaRegistro AS LineRegistrationDate,
c.EjercicioPedido AS OrderYear,
c.SeriePedido AS OrderSeries,
c.NumeroPedido AS OrderNumber,
c.SuPedido AS PurchaseOrderNumber,
c.CodigoExportacion_ AS Incoterms2020,
c.CondicionExportacion_ AS IncotermsText,
c.CodigoComisionista AS SalesResponsibleEmployee,
c.StatusAbono AS CreditStatus,
c.NoFacturable AS NonBillable,
c.TipoNuevaFra AS InvoiceType,
c.StatusFacturado AS BillingStatus,
CASE
WHEN c.TipoNuevaFra = 2 OR c.SerieFactura = 'REC' OR c.StatusAbono <> 0 THEN 'Credit Note'
ELSE 'Invoice'
END AS DocumentType
FROM dbo.CabeceraAlbaranCliente c
JOIN dbo.LineasAlbaranCliente l
ON l.CodigoEmpresa = c.CodigoEmpresa
AND l.EjercicioAlbaran = c.EjercicioAlbaran
AND l.SerieAlbaran = c.SerieAlbaran
AND l.NumeroAlbaran = c.NumeroAlbaran
WHERE $datePredicate
ORDER BY
c.FechaFactura,
c.SerieFactura,
c.NumeroFactura,
l.Orden;
"@
$result = Export-QueryToCsv -Sql $sql -Path $csvPath
@"
Sage Spain Sales CSV export
===========================
Created: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")
Server instance: $ServerInstance
Database: $Database
Export mode: $ExportMode
Date filter mode: $DateFilter
From date: $($FromDate.ToString("yyyy-MM-dd"))
To date: $($ToDate.ToString("yyyy-MM-dd"))
Output:
$csvPath
Rows:
$($result.Rows)
SalesPriceValue sum:
$($result.SalesPriceValueSum)
Source:
dbo.CabeceraAlbaranCliente joined with dbo.LineasAlbaranCliente
Filter:
$datePredicate
Notes:
- Currency is set to EUR because Sage exports EnEuros_=-1 and CodigoDivisa is empty in the analysed rows.
- SalesPriceValue uses LineasAlbaranCliente.ImporteNeto; credit notes are forced negative.
- DocumentNetAmount uses CabeceraAlbaranCliente.BaseImponible; credit notes are forced negative.
- Credit notes are marked when TipoNuevaFra=2, SerieFactura='REC', or StatusAbono is non-zero.
- Full exports use the complete selected year.
- Range exports use the explicit FromDate/ToDate window.
"@ | Set-Content -LiteralPath $summaryPath -Encoding UTF8
Write-Host "Created:"
Write-Host " $csvPath"
Write-Host " $summaryPath"
Write-Host "Rows: $($result.Rows)"
Write-Host "SalesPriceValue sum: $($result.SalesPriceValueSum)"
@@ -0,0 +1,164 @@
Sage Spain final sales export candidate
======================================
Run on the Spain Sage SQL Server machine.
PowerShell commands:
Set-ExecutionPolicy -Scope Process Bypass
.\Export-SageSpainSalesCsv.ps1
Full export, default year 2025:
.\Export-SageSpainSalesCsv.ps1 -ExportMode Full -Year 2025
Range export, explicit window:
.\Export-SageSpainSalesCsv.ps1 -ExportMode Range -FromDate "2025-05-01" -ToDate "2025-06-01"
Range export by registration date, useful for new/changed records registered in a period:
.\Export-SageSpainSalesCsv.ps1 -ExportMode Range -DateFilter LineRegistrationDate -FromDate "2025-05-01" -ToDate "2025-06-01"
Output folder on Desktop:
Sage_Spain_Sales_Export_YYYYMMDD_HHMMSS
Files created:
- Spain_Sales_full_YYYY0101_to_YYYY1231.csv for full export
- Spain_Sales_range_YYYYMMDD_to_YYYYMMDD.csv for range export
- Matching *_summary.txt file
The script only reads SQL Server data. It does not change Sage or SQL Server.
Default source:
- Database: Sage
- Header: dbo.CabeceraAlbaranCliente
- Lines: dbo.LineasAlbaranCliente
- Full export date filter: CabeceraAlbaranCliente.FechaFactura from YYYY-01-01 to next YYYY-01-01
- Range export date filter: explicit FromDate/ToDate
- DateFilter InvoiceDate uses CabeceraAlbaranCliente.FechaFactura
- DateFilter LineRegistrationDate uses LineasAlbaranCliente.FechaRegistro, with fallback to FechaFactura
- Sales value: LineasAlbaranCliente.ImporteNeto
If the SQL instance or database name differs:
.\Export-SageSpainSalesCsv.ps1 -ServerInstance "localhost" -Database "Sage" -ExportMode Full -Year 2025
Automatic upload to SharePoint with rclone
==========================================
Target SharePoint folder:
https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Shared%20Documents/Import/Finance/Spanien
Decoded folder path:
Shared Documents/Import/Finance/Spanien
Recommended rclone setup on the Spain Sage server:
1. Install rclone.
2. Run:
rclone config
3. Create a new remote for the SharePoint document library.
Recommended remote name:
trafag-bi
The remote should point to the document library root "Shared Documents" of:
https://trafagag.sharepoint.com/sites/WorldwideBIPlatform
Then this target path is used by the wrapper script:
trafag-bi:Import/Finance/Spanien
Test rclone:
rclone lsd trafag-bi:
rclone lsd trafag-bi:"Import/Finance"
rclone lsd trafag-bi:"Import/Finance/Spanien"
Run range export and upload with default window last 7 days until today:
.\Run-SpainExportAndUpload.ps1
Explicit range:
.\Run-SpainExportAndUpload.ps1 -ExportMode Range -DateFilter LineRegistrationDate -FromDate "2026-06-02" -ToDate "2026-06-03"
Simple starter script with default window last 7 days until today:
.\Start-SpainRangeExportAndUpload.ps1
Same starter script with another range:
.\Start-SpainRangeExportAndUpload.ps1 -FromDate "2026-06-01" -ToDate "2026-06-04"
Single-file all-in-one range export and upload.
This file does not require Export-SageSpainSalesCsv.ps1 or Run-SpainExportAndUpload.ps1:
.\Run-SpainRangeExportAndUpload-AllInOne.ps1
Default date window:
- FromDate = today - 7 days
- ToDate = today
- ToDate is exclusive
Override the all-in-one default date window:
.\Run-SpainRangeExportAndUpload-AllInOne.ps1 -FromDate "2026-06-01" -ToDate "2026-06-04"
The all-in-one script checks/creates the SharePoint folder before export, uploads the generated CSV and summary, and verifies that the uploaded files are listed in SharePoint.
rclone.exe lookup for the all-in-one script:
- explicit -RcloneExe parameter
- rclone.exe in the same folder as the script
- C:\Tools\rclone.exe
- C:\Tools\rclone\rclone.exe
- C:\Tools\rclone\rclone\rclone.exe
- rclone from PATH
Known rclone upload issue:
If the log says:
CRITICAL: Can't set -v and --log-level
then the server is running an old script copy that still contains:
--verbose `
Remove that line from the rclone copy block or replace the file with the current Run-SpainRangeExportAndUpload-AllInOne.ps1.
Full export and upload:
.\Run-SpainExportAndUpload.ps1 -ExportMode Full -Year 2025
If the rclone remote has another name:
.\Run-SpainExportAndUpload.ps1 -RcloneRemote "YOUR_REMOTE_NAME"
If rclone.exe is not in PATH:
.\Run-SpainExportAndUpload.ps1 -RcloneExe "C:\Tools\rclone\rclone.exe"
Suggested Windows Task Scheduler command:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Trafag\SageSpain\Run-SpainExportAndUpload.ps1
Important:
- The export script only reads SQL Server data.
- rclone only uploads the generated CSV and matching summary file.
- For daily deltas use ExportMode Range with DateFilter LineRegistrationDate.
- ToDate is exclusive.
@@ -0,0 +1,107 @@
param(
[string]$ServerInstance = "localhost",
[string]$Database = "Sage",
[ValidateSet("Full", "Range")]
[string]$ExportMode = "Range",
[ValidateSet("InvoiceDate", "LineRegistrationDate")]
[string]$DateFilter = "LineRegistrationDate",
[int]$Year = 2025,
[datetime]$FromDate = (Get-Date).Date.AddDays(-7),
[datetime]$ToDate = (Get-Date).Date,
[string]$BaseDirectory = "C:\Trafag\SageSpain",
[string]$RcloneExe = "rclone",
[string]$RcloneRemote = "trafag-bi",
[string]$RcloneTarget = "Import/Finance/Spanien"
)
$ErrorActionPreference = "Stop"
$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
$exportScript = Join-Path $scriptDirectory "Export-SageSpainSalesCsv.ps1"
if (-not (Test-Path -LiteralPath $exportScript)) {
throw "Export script not found: $exportScript"
}
$outputDirectory = Join-Path $BaseDirectory "out"
$logDirectory = Join-Path $BaseDirectory "logs"
New-Item -ItemType Directory -Force -Path $outputDirectory, $logDirectory | Out-Null
if (-not (Get-Command $RcloneExe -ErrorAction SilentlyContinue)) {
throw "rclone executable not found: $RcloneExe"
}
$target = "${RcloneRemote}:$RcloneTarget"
$rcloneLog = Join-Path $logDirectory ("rclone-spain-" + (Get-Date -Format "yyyyMMdd") + ".log")
Write-Host "Checking SharePoint target with rclone: $target"
& $RcloneExe mkdir $target --log-file $rcloneLog --log-level INFO
if ($LASTEXITCODE -ne 0) {
throw "Could not create/check SharePoint target '$target'. rclone exit code $LASTEXITCODE. Log: $rcloneLog"
}
& $RcloneExe lsf $target --max-depth 1 --log-file $rcloneLog --log-level INFO | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "SharePoint target '$target' is not reachable. rclone exit code $LASTEXITCODE. Log: $rcloneLog"
}
$exportArgs = @(
"-ServerInstance", $ServerInstance,
"-Database", $Database,
"-ExportMode", $ExportMode,
"-DateFilter", $DateFilter,
"-Year", $Year,
"-OutputDirectory", $outputDirectory
)
if ($ExportMode -eq "Range") {
$exportArgs += @(
"-FromDate", $FromDate.ToString("yyyy-MM-dd"),
"-ToDate", $ToDate.ToString("yyyy-MM-dd")
)
}
& $exportScript @exportArgs
if ($LASTEXITCODE -ne 0) {
throw "Spain Sage export failed with exit code $LASTEXITCODE"
}
$latestRun = Get-ChildItem -LiteralPath $outputDirectory -Directory |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($null -eq $latestRun) {
throw "No export run directory found in $outputDirectory"
}
$filesToUpload = Get-ChildItem -LiteralPath $latestRun.FullName -File |
Where-Object { $_.Name -like "*.csv" -or $_.Name -like "*_summary.txt" }
if ($filesToUpload.Count -eq 0) {
throw "No CSV or summary files found for upload in $($latestRun.FullName)"
}
Write-Host "Uploading $($filesToUpload.Count) file(s) to SharePoint target: $target"
& $RcloneExe copy $latestRun.FullName $target `
--include "*.csv" `
--include "*_summary.txt" `
--verbose `
--log-file $rcloneLog `
--log-level INFO
if ($LASTEXITCODE -ne 0) {
throw "rclone upload failed with exit code $LASTEXITCODE"
}
foreach ($file in $filesToUpload) {
$uploadedMatch = & $RcloneExe lsf $target --files-only --include $file.Name --log-file $rcloneLog --log-level INFO
if ($LASTEXITCODE -ne 0) {
throw "Could not verify uploaded file '$($file.Name)' in '$target'. rclone exit code $LASTEXITCODE. Log: $rcloneLog"
}
if (-not ($uploadedMatch | Where-Object { $_ -eq $file.Name })) {
throw "Upload verification failed. File '$($file.Name)' was not listed in '$target'. Log: $rcloneLog"
}
}
Write-Host "Spain export and upload finished."
Write-Host "Local export: $($latestRun.FullName)"
Write-Host "SharePoint target: $target"
Write-Host "rclone log: $rcloneLog"
@@ -0,0 +1,343 @@
param(
[string]$ServerInstance = "localhost",
[string]$Database = "Sage",
[ValidateSet("InvoiceDate", "LineRegistrationDate")]
[string]$DateFilter = "LineRegistrationDate",
[datetime]$FromDate = (Get-Date).Date.AddDays(-7),
[datetime]$ToDate = (Get-Date).Date,
[string]$BaseDirectory = "C:\Trafag\SageSpain",
[string]$RcloneExe = "C:\Tools\rclone.exe",
[string]$RcloneRemote = "trafag-bi",
[string]$RcloneTarget = "Import/Finance/Spanien"
)
$ErrorActionPreference = "Stop"
function New-Connection {
$builder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
$builder["Data Source"] = $ServerInstance
$builder["Initial Catalog"] = $Database
$builder["Integrated Security"] = $true
$builder["TrustServerCertificate"] = $true
$builder["Connect Timeout"] = 15
return New-Object System.Data.SqlClient.SqlConnection($builder.ConnectionString)
}
function Convert-ToCsvValue {
param($Value)
if ($null -eq $Value -or $Value -is [System.DBNull]) {
return ""
}
if ($Value -is [datetime]) {
$text = $Value.ToString("yyyy-MM-dd HH:mm:ss")
}
else {
$text = [string]$Value
}
$text = $text.Replace('"', '""')
return '"' + $text + '"'
}
function Export-QueryToCsv {
param(
[string]$Sql,
[string]$Path
)
$conn = New-Connection
$cmd = $conn.CreateCommand()
$cmd.CommandText = $Sql
$cmd.CommandTimeout = 0
$fromParameter = $cmd.Parameters.Add("@FromDate", [System.Data.SqlDbType]::Date)
$fromParameter.Value = $FromDate.Date
$toParameter = $cmd.Parameters.Add("@ToDate", [System.Data.SqlDbType]::Date)
$toParameter.Value = $ToDate.Date
$writer = New-Object System.IO.StreamWriter($Path, $false, [System.Text.Encoding]::UTF8)
$rowCount = 0
$salesSum = [decimal]0
try {
$conn.Open()
$reader = $cmd.ExecuteReader()
$headers = for ($i = 0; $i -lt $reader.FieldCount; $i++) {
Convert-ToCsvValue $reader.GetName($i)
}
$writer.WriteLine(($headers -join ";"))
$salesIndex = -1
for ($i = 0; $i -lt $reader.FieldCount; $i++) {
if ($reader.GetName($i) -eq "SalesPriceValue") {
$salesIndex = $i
break
}
}
while ($reader.Read()) {
$values = for ($i = 0; $i -lt $reader.FieldCount; $i++) {
Convert-ToCsvValue $reader.GetValue($i)
}
$writer.WriteLine(($values -join ";"))
$rowCount++
if ($salesIndex -ge 0 -and -not $reader.IsDBNull($salesIndex)) {
$salesSum += [decimal]$reader.GetValue($salesIndex)
}
}
}
finally {
$writer.Dispose()
$conn.Dispose()
}
return [pscustomobject]@{
Rows = $rowCount
SalesPriceValueSum = $salesSum
}
}
function Resolve-RcloneExecutable {
param([string]$ConfiguredPath)
$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
$candidates = @(
$ConfiguredPath,
(Join-Path $scriptDirectory "rclone.exe"),
"C:\Tools\rclone.exe",
"C:\Tools\rclone\rclone.exe",
"C:\Tools\rclone\rclone\rclone.exe",
"rclone"
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath $candidate) {
return (Resolve-Path -LiteralPath $candidate).Path
}
$command = Get-Command $candidate -ErrorAction SilentlyContinue
if ($null -ne $command) {
return $command.Source
}
}
throw "rclone executable not found. Checked: $($candidates -join ', ')"
}
function Throw-RcloneError {
param(
[string]$Message,
[string]$LogPath
)
if (Test-Path -LiteralPath $LogPath) {
Write-Host ""
Write-Host "Last rclone log lines:"
Get-Content -LiteralPath $LogPath -Tail 80 | ForEach-Object { Write-Host $_ }
}
throw "$Message Log: $LogPath"
}
if ($ToDate.Date -le $FromDate.Date) {
throw "ToDate must be later than FromDate. FromDate=$($FromDate.ToString("yyyy-MM-dd")), ToDate=$($ToDate.ToString("yyyy-MM-dd"))"
}
$RcloneExe = Resolve-RcloneExecutable -ConfiguredPath $RcloneExe
Write-Host "Using rclone: $RcloneExe"
$outputDirectory = Join-Path $BaseDirectory "out"
$logDirectory = Join-Path $BaseDirectory "logs"
New-Item -ItemType Directory -Force -Path $outputDirectory, $logDirectory | Out-Null
$target = "${RcloneRemote}:$RcloneTarget"
$rcloneLog = Join-Path $logDirectory ("rclone-spain-" + (Get-Date -Format "yyyyMMdd") + ".log")
Write-Host "Checking SharePoint target with rclone: $target"
& $RcloneExe mkdir $target --log-file $rcloneLog --log-level INFO
if ($LASTEXITCODE -ne 0) {
Throw-RcloneError -Message "Could not create/check SharePoint target '$target'. rclone exit code $LASTEXITCODE." -LogPath $rcloneLog
}
$targetListing = & $RcloneExe lsf $target --max-depth 1 --log-file $rcloneLog --log-level INFO
if ($LASTEXITCODE -ne 0) {
Throw-RcloneError -Message "SharePoint target '$target' is not reachable. rclone exit code $LASTEXITCODE." -LogPath $rcloneLog
}
Write-Host "SharePoint target reachable. Existing items: $(@($targetListing).Count)"
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$runDirectory = Join-Path $outputDirectory "Sage_Spain_Sales_Export_$timestamp"
New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null
$fromToken = $FromDate.ToString("yyyyMMdd")
$toToken = $ToDate.Date.AddDays(-1).ToString("yyyyMMdd")
$outputFileName = "Spain_Sales_range_${fromToken}_to_${toToken}.csv"
$csvPath = Join-Path $runDirectory $outputFileName
$summaryPath = Join-Path $runDirectory ([System.IO.Path]::GetFileNameWithoutExtension($outputFileName) + "_summary.txt")
$datePredicate = if ($DateFilter -eq "LineRegistrationDate") {
"COALESCE(l.FechaRegistro, c.FechaFactura) >= @FromDate
AND COALESCE(l.FechaRegistro, c.FechaFactura) < @ToDate"
} else {
"c.FechaFactura >= @FromDate
AND c.FechaFactura < @ToDate"
}
$sql = @"
SELECT
'TRES' AS TSC,
'Spanien' AS Land,
'Sage' AS SourceSystem,
c.CodigoEmpresa AS CompanyCode,
c.EjercicioAlbaran AS DeliveryYear,
c.SerieAlbaran AS DeliverySeries,
c.NumeroAlbaran AS DeliveryNumber,
c.EjercicioFactura AS InvoiceYear,
c.SerieFactura AS InvoiceSeries,
c.NumeroFactura AS InvoiceNumber,
l.Orden AS PositionOnInvoice,
l.LineasPosicion AS SourceLineId,
l.CodigoArticulo AS Material,
l.DescripcionArticulo AS Name,
l.Descripcion2Articulo AS Description2,
l.DescripcionLinea AS DescriptionLine,
l.CodigoFamilia AS ProductGroup,
l.CodigoSubfamilia AS ProductSubGroup,
CAST(l.Unidades AS decimal(19, 6)) AS Quantity,
c.CodigoCliente AS CustomerNumber,
c.Nombre AS CustomerName,
c.CodigoNacion AS CustomerCountryCode,
c.Nacion AS CustomerCountry,
CAST(l.PrecioCoste AS decimal(19, 6)) AS StandardCost,
CAST(l.ImporteCoste AS decimal(19, 6)) AS StandardCostValue,
'EUR' AS StandardCostCurrency,
CAST(CASE
WHEN c.TipoNuevaFra = 2 OR c.SerieFactura = 'REC' OR c.StatusAbono <> 0 THEN -ABS(l.ImporteNeto)
ELSE l.ImporteNeto
END AS decimal(19, 6)) AS SalesPriceValue,
'EUR' AS SalesCurrency,
'EUR' AS DocumentCurrency,
'EUR' AS CompanyCurrency,
c.CodigoDivisa AS SageCurrencyCode,
CAST(CASE
WHEN c.TipoNuevaFra = 2 OR c.SerieFactura = 'REC' OR c.StatusAbono <> 0 THEN -ABS(c.BaseImponible)
ELSE c.BaseImponible
END AS decimal(19, 6)) AS DocumentNetAmount,
CAST(c.TotalIva AS decimal(19, 6)) AS DocumentVatAmount,
CAST(c.ImporteFactura AS decimal(19, 6)) AS DocumentGrossAmount,
c.FechaFactura AS InvoiceDate,
c.FechaAlbaran AS DeliveryDate,
l.FechaRegistro AS LineRegistrationDate,
c.EjercicioPedido AS OrderYear,
c.SeriePedido AS OrderSeries,
c.NumeroPedido AS OrderNumber,
c.SuPedido AS PurchaseOrderNumber,
c.CodigoExportacion_ AS Incoterms2020,
c.CondicionExportacion_ AS IncotermsText,
c.CodigoComisionista AS SalesResponsibleEmployee,
c.StatusAbono AS CreditStatus,
c.NoFacturable AS NonBillable,
c.TipoNuevaFra AS InvoiceType,
c.StatusFacturado AS BillingStatus,
CASE
WHEN c.TipoNuevaFra = 2 OR c.SerieFactura = 'REC' OR c.StatusAbono <> 0 THEN 'Credit Note'
ELSE 'Invoice'
END AS DocumentType
FROM dbo.CabeceraAlbaranCliente c
JOIN dbo.LineasAlbaranCliente l
ON l.CodigoEmpresa = c.CodigoEmpresa
AND l.EjercicioAlbaran = c.EjercicioAlbaran
AND l.SerieAlbaran = c.SerieAlbaran
AND l.NumeroAlbaran = c.NumeroAlbaran
WHERE $datePredicate
ORDER BY
c.FechaFactura,
c.SerieFactura,
c.NumeroFactura,
l.Orden;
"@
Write-Host "Exporting Sage Spain range..."
Write-Host "FromDate: $($FromDate.ToString("yyyy-MM-dd"))"
Write-Host "ToDate: $($ToDate.ToString("yyyy-MM-dd"))"
Write-Host "DateFilter: $DateFilter"
$result = Export-QueryToCsv -Sql $sql -Path $csvPath
@"
Sage Spain Sales CSV export
===========================
Created: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")
Server instance: $ServerInstance
Database: $Database
Export mode: Range
Date filter mode: $DateFilter
From date: $($FromDate.ToString("yyyy-MM-dd"))
To date: $($ToDate.ToString("yyyy-MM-dd"))
Output:
$csvPath
Rows:
$($result.Rows)
SalesPriceValue sum:
$($result.SalesPriceValueSum)
SharePoint target:
$target
Source:
dbo.CabeceraAlbaranCliente joined with dbo.LineasAlbaranCliente
Filter:
$datePredicate
Notes:
- ToDate is exclusive.
- Currency is set to EUR.
- SalesPriceValue uses LineasAlbaranCliente.ImporteNeto; credit notes are forced negative.
- Credit notes are marked when TipoNuevaFra=2, SerieFactura='REC', or StatusAbono is non-zero.
"@ | Set-Content -LiteralPath $summaryPath -Encoding UTF8
$filesToUpload = Get-ChildItem -LiteralPath $runDirectory -File |
Where-Object { $_.Name -like "*.csv" -or $_.Name -like "*_summary.txt" }
if ($filesToUpload.Count -eq 0) {
throw "No CSV or summary files found for upload in $runDirectory"
}
Write-Host "Uploading $($filesToUpload.Count) file(s) to SharePoint target: $target"
& $RcloneExe copy $runDirectory $target `
--include "*.csv" `
--include "*_summary.txt" `
--log-file $rcloneLog `
--log-level INFO
if ($LASTEXITCODE -ne 0) {
Throw-RcloneError -Message "rclone upload failed with exit code $LASTEXITCODE." -LogPath $rcloneLog
}
foreach ($file in $filesToUpload) {
$uploadedMatch = & $RcloneExe lsf $target --files-only --include $file.Name --log-file $rcloneLog --log-level INFO
if ($LASTEXITCODE -ne 0) {
Throw-RcloneError -Message "Could not verify uploaded file '$($file.Name)' in '$target'. rclone exit code $LASTEXITCODE." -LogPath $rcloneLog
}
if (-not ($uploadedMatch | Where-Object { $_ -eq $file.Name })) {
Throw-RcloneError -Message "Upload verification failed. File '$($file.Name)' was not listed in '$target'." -LogPath $rcloneLog
}
}
Write-Host "Spain range export and SharePoint upload finished."
Write-Host "Local export: $runDirectory"
Write-Host "CSV: $csvPath"
Write-Host "Summary: $summaryPath"
Write-Host "Rows: $($result.Rows)"
Write-Host "SalesPriceValue sum: $($result.SalesPriceValueSum)"
Write-Host "SharePoint target: $target"
Write-Host "rclone log: $rcloneLog"
@@ -0,0 +1,46 @@
param(
[datetime]$FromDate = (Get-Date).Date.AddDays(-7),
[datetime]$ToDate = (Get-Date).Date,
[string]$ServerInstance = "localhost",
[string]$Database = "Sage",
[string]$BaseDirectory = "C:\Trafag\SageSpain",
[string]$RcloneExe = "C:\Tools\rclone.exe",
[string]$RcloneRemote = "trafag-bi",
[string]$RcloneTarget = "Import/Finance/Spanien"
)
$ErrorActionPreference = "Stop"
$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
$workflowScript = Join-Path $scriptDirectory "Run-SpainExportAndUpload.ps1"
if (-not (Test-Path -LiteralPath $workflowScript)) {
throw "Workflow script not found: $workflowScript"
}
if (-not (Test-Path -LiteralPath $RcloneExe)) {
throw "rclone not found: $RcloneExe"
}
Write-Host "Starting Spain Sage range export and SharePoint upload..."
Write-Host "FromDate: $($FromDate.ToString("yyyy-MM-dd"))"
Write-Host "ToDate: $($ToDate.ToString("yyyy-MM-dd"))"
Write-Host "Target: ${RcloneRemote}:$RcloneTarget"
& $workflowScript `
-ServerInstance $ServerInstance `
-Database $Database `
-ExportMode Range `
-DateFilter LineRegistrationDate `
-FromDate $FromDate `
-ToDate $ToDate `
-BaseDirectory $BaseDirectory `
-RcloneExe $RcloneExe `
-RcloneRemote $RcloneRemote `
-RcloneTarget $RcloneTarget
if ($LASTEXITCODE -ne 0) {
throw "Spain range export and upload failed with exit code $LASTEXITCODE"
}
Write-Host "Finished Spain Sage range export and SharePoint upload."
@@ -0,0 +1,23 @@
Sage SQL CSV export
===================
Server instance: localhost
Database filter: Sage
From date: 2025-01-01
To date: 2026-01-01
Files:
- candidate_objects.csv: SQL tables/views that look relevant for sales/invoices.
- export_summary.csv: export status and row counts.
- *.csv: exported samples or selected full exports.
Recommended workflow:
1. Run discovery first:
.\Export-SageSqlCsv.ps1 -DiscoverOnly
2. Send candidate_objects.csv to Trafag/IT for selection.
3. Export selected objects:
.\Export-SageSqlCsv.ps1 -Database "DATABASE_NAME" -ObjectName "schema.table_or_view"
4. If the selected object is very large, add:
-FromDate "2025-01-01" -ToDate "2026-01-01" -MaxRowsPerObject 100000
The script only reads data. It does not change SQL Server or Sage.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
"CodigoEmpresa";"EstadisClave1";"EstadisClave2";"EstadisClave3";"Ejercicio";"Periodo";"Origen";"CodigoZona";"CodigoJefeZona_";"CodigoJefeVenta_";"CodigoComisionista";"CodigoComisionista2_";"CodigoComisionista3_";"CodigoComisionista4_";"CodigoCliente";"CodigoFamilia";"CodigoSubfamilia";"CodigoArticulo";"CodigoColor_";"GrupoTalla_";"UnidadesTalla01_";"UnidadesTalla02_";"UnidadesTalla03_";"UnidadesTalla04_";"UnidadesTalla05_";"UnidadesTalla06_";"UnidadesTalla07_";"UnidadesTalla08_";"UnidadesTalla09_";"UnidadesTalla10_";"UnidadesTalla11_";"UnidadesTalla12_";"UnidadesTalla13_";"UnidadesTalla14_";"UnidadesTalla15_";"UnidadesTalla16_";"UnidadesTalla17_";"UnidadesTalla18_";"UnidadesTalla19_";"UnidadesTalla20_";"UnidadesTalla21_";"UnidadesTalla22_";"UnidadesTalla23_";"UnidadesTalla24_";"UnidadesTalla25_";"UnidadesTalla26_";"UnidadesTalla27_";"UnidadesTalla28_";"UnidadesTalla29_";"UnidadesTalla30_";"UnidadesTalla31_";"UnidadesTalla32_";"UnidadesTalla33_";"UnidadesTalla34_";"UnidadesTalla35_";"UnidadesTalla36_";"UnidadesTalla37_";"UnidadesTalla38_";"UnidadesTalla39_";"UnidadesTalla40_";"ImporteTalla01_";"ImporteTalla02_";"ImporteTalla03_";"ImporteTalla04_";"ImporteTalla05_";"ImporteTalla06_";"ImporteTalla07_";"ImporteTalla08_";"ImporteTalla09_";"ImporteTalla10_";"ImporteTalla11_";"ImporteTalla12_";"ImporteTalla13_";"ImporteTalla14_";"ImporteTalla15_";"ImporteTalla16_";"ImporteTalla17_";"ImporteTalla18_";"ImporteTalla19_";"ImporteTalla20_";"ImporteTalla21_";"ImporteTalla22_";"ImporteTalla23_";"ImporteTalla24_";"ImporteTalla25_";"ImporteTalla26_";"ImporteTalla27_";"ImporteTalla28_";"ImporteTalla29_";"ImporteTalla30_";"ImporteTalla31_";"ImporteTalla32_";"ImporteTalla33_";"ImporteTalla34_";"ImporteTalla35_";"ImporteTalla36_";"ImporteTalla37_";"ImporteTalla38_";"ImporteTalla39_";"ImporteTalla40_";"UnidadesTotalTallas_";"ImporteTotalTallas_"
1 CodigoEmpresa EstadisClave1 EstadisClave2 EstadisClave3 Ejercicio Periodo Origen CodigoZona CodigoJefeZona_ CodigoJefeVenta_ CodigoComisionista CodigoComisionista2_ CodigoComisionista3_ CodigoComisionista4_ CodigoCliente CodigoFamilia CodigoSubfamilia CodigoArticulo CodigoColor_ GrupoTalla_ UnidadesTalla01_ UnidadesTalla02_ UnidadesTalla03_ UnidadesTalla04_ UnidadesTalla05_ UnidadesTalla06_ UnidadesTalla07_ UnidadesTalla08_ UnidadesTalla09_ UnidadesTalla10_ UnidadesTalla11_ UnidadesTalla12_ UnidadesTalla13_ UnidadesTalla14_ UnidadesTalla15_ UnidadesTalla16_ UnidadesTalla17_ UnidadesTalla18_ UnidadesTalla19_ UnidadesTalla20_ UnidadesTalla21_ UnidadesTalla22_ UnidadesTalla23_ UnidadesTalla24_ UnidadesTalla25_ UnidadesTalla26_ UnidadesTalla27_ UnidadesTalla28_ UnidadesTalla29_ UnidadesTalla30_ UnidadesTalla31_ UnidadesTalla32_ UnidadesTalla33_ UnidadesTalla34_ UnidadesTalla35_ UnidadesTalla36_ UnidadesTalla37_ UnidadesTalla38_ UnidadesTalla39_ UnidadesTalla40_ ImporteTalla01_ ImporteTalla02_ ImporteTalla03_ ImporteTalla04_ ImporteTalla05_ ImporteTalla06_ ImporteTalla07_ ImporteTalla08_ ImporteTalla09_ ImporteTalla10_ ImporteTalla11_ ImporteTalla12_ ImporteTalla13_ ImporteTalla14_ ImporteTalla15_ ImporteTalla16_ ImporteTalla17_ ImporteTalla18_ ImporteTalla19_ ImporteTalla20_ ImporteTalla21_ ImporteTalla22_ ImporteTalla23_ ImporteTalla24_ ImporteTalla25_ ImporteTalla26_ ImporteTalla27_ ImporteTalla28_ ImporteTalla29_ ImporteTalla30_ ImporteTalla31_ ImporteTalla32_ ImporteTalla33_ ImporteTalla34_ ImporteTalla35_ ImporteTalla36_ ImporteTalla37_ ImporteTalla38_ ImporteTalla39_ ImporteTalla40_ UnidadesTotalTallas_ ImporteTotalTallas_
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
"oppCoId";"effeForecast";"effeNumber";"invoExercise";"invoSeries";"invoNumber";"effeOrder";"statusDelete";"customerCode";"customer";"effeAmount";"expirationDate";"invoDate";"emissionDate";"accountCode";"counterPart";"comment";"canalCode";"statusRemitted";"remittedType";"remittedDate";"remittedBank";"remittedNumber";"statusRisk";"statusUnpaid";"salesPersonId";"salesPerson";"effectType";"effectClass";"effeId";"invoId"
1 oppCoId effeForecast effeNumber invoExercise invoSeries invoNumber effeOrder statusDelete customerCode customer effeAmount expirationDate invoDate emissionDate accountCode counterPart comment canalCode statusRemitted remittedType remittedDate remittedBank remittedNumber statusRisk statusUnpaid salesPersonId salesPerson effectType effectClass effeId invoId
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
"Database";"Object";"Action";"Rows";"File";"DateColumn";"Error"
"Sage";"dbo.CabeceraAlbaranCliente";"Exported";"1973";"C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.CabeceraAlbaranCliente.csv";"FechaFactura";""
"Sage";"dbo.LineasAlbaranCliente";"Exported";"4814";"C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.LineasAlbaranCliente.csv";"FechaRegistro";""
"Sage";"dbo.EstadisVenta";"Exported";"16976";"C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.EstadisVenta.csv";;""
"Sage";"dbo.EstadisVentaTallas";"Exported";"0";"C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.EstadisVentaTallas.csv";;""
"Sage";"dbo.FacturasTB";"Exported";"3788";"C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.FacturasTB.csv";"FechaFactura";""
"Sage";"dbo.MovimientosFacturas";"Exported";"6517";"C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.MovimientosFacturas.csv";"FechaFactura";""
"Sage";"dbo.Vis_RTDV_EfectosFactura";"Exported";"0";"C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.Vis_RTDV_EfectosFactura.csv";"expirationDate";""
1 Database Object Action Rows File DateColumn Error
2 Sage dbo.CabeceraAlbaranCliente Exported 1973 C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.CabeceraAlbaranCliente.csv FechaFactura
3 Sage dbo.LineasAlbaranCliente Exported 4814 C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.LineasAlbaranCliente.csv FechaRegistro
4 Sage dbo.EstadisVenta Exported 16976 C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.EstadisVenta.csv
5 Sage dbo.EstadisVentaTallas Exported 0 C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.EstadisVentaTallas.csv
6 Sage dbo.FacturasTB Exported 3788 C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.FacturasTB.csv FechaFactura
7 Sage dbo.MovimientosFacturas Exported 6517 C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.MovimientosFacturas.csv FechaFactura
8 Sage dbo.Vis_RTDV_EfectosFactura Exported 0 C:\Users\Administrador\Desktop\Sage_SQL_CSV_Export_20260505_103719\Sage.dbo.Vis_RTDV_EfectosFactura.csv expirationDate
Binary file not shown.
@@ -0,0 +1,134 @@
{
"CapturedAt": "2026-05-05T10:05:13.9281781+02:00",
"ComputerName": "WIN-4BJQJ9S1PVJ",
"UserName": "WIN-4BJQJ9S1PVJ\\Administrador",
"Windows": {
"Caption": "Microsoft Windows Server 2019 Standard",
"Version": "10.0.17763",
"BuildNumber": "17763",
"InstallDate": "\/Date(1601446676000)\/"
},
"SageUninstallEntries": [
{
"DisplayName": "JRE 2.5",
"DisplayVersion": null,
"Publisher": "Sage Logic Control",
"InstallDate": null,
"InstallLocation": null,
"UninstallString": "\"C:\\Windows\\unins000.exe\"",
"PSPath": "Microsoft.PowerShell.Core\\Registry::HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\JRE_is1"
},
{
"DisplayName": "Sage 200c",
"DisplayVersion": "2026.56.000",
"Publisher": "Sage Spain",
"InstallDate": null,
"InstallLocation": null,
"UninstallString": "C:\\Program Files (x86)\\Sage\\Sage 200c\\Setup\\Uninstall\\Sage.Uninstall.exe",
"PSPath": "Microsoft.PowerShell.Core\\Registry::HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Sage 200c"
},
{
"DisplayName": "Sage Renta Componentes",
"DisplayVersion": "1.00.0000",
"Publisher": "Sage Spain",
"InstallDate": "20201021",
"InstallLocation": "C:\\Windows\\SysWOW64\\",
"UninstallString": "MsiExec.exe /X{0ADD979C-205B-4264-B903-6F953F362917}",
"PSPath": "Microsoft.PowerShell.Core\\Registry::HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{0ADD979C-205B-4264-B903-6F953F362917}"
},
{
"DisplayName": "Sage SGE Runtime",
"DisplayVersion": "1.00.0000",
"Publisher": "Sage Spain",
"InstallDate": "20201021",
"InstallLocation": "C:\\Windows\\SysWOW64\\",
"UninstallString": "MsiExec.exe /X{1FFF90A6-3F93-4123-9C3B-54EBBDD22757}",
"PSPath": "Microsoft.PowerShell.Core\\Registry::HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{1FFF90A6-3F93-4123-9C3B-54EBBDD22757}"
},
{
"DisplayName": "Sage Live Update Service",
"DisplayVersion": "1.0.8.0",
"Publisher": "Sage",
"InstallDate": "20230314",
"InstallLocation": "",
"UninstallString": "MsiExec.exe /I{6D538240-299A-47CC-8782-2062AD2F2189}",
"PSPath": "Microsoft.PowerShell.Core\\Registry::HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{6D538240-299A-47CC-8782-2062AD2F2189}"
},
{
"DisplayName": "Sage API OnPremise Service",
"DisplayVersion": "1.2.8.0",
"Publisher": "Sage",
"InstallDate": "20201021",
"InstallLocation": "",
"UninstallString": "MsiExec.exe /I{9881C355-CB1B-4007-AB3A-B12F222318DB}",
"PSPath": "Microsoft.PowerShell.Core\\Registry::HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{9881C355-CB1B-4007-AB3A-B12F222318DB}"
}
],
"SageFileVersions": [
],
"SqlRegistryInstances": [
{
"InstanceName": "MSSQLSERVER",
"InstanceId": "MSSQL15.MSSQLSERVER",
"Edition": "Standard Edition",
"Version": "15.0.2000.5",
"PatchLevel": "15.0.2155.2",
"ProductCode": "{A60B3D8E-5311-4BF1-AF7A-D1AC15F9152E}",
"SQLPath": "C:\\Program Files\\Microsoft SQL Server\\MSSQL15.MSSQLSERVER\\MSSQL",
"SetupPath": "HKLM:\\SOFTWARE\\Microsoft\\Microsoft SQL Server\\MSSQL15.MSSQLSERVER\\Setup"
}
],
"SqlServices": [
{
"Name": "MSSQLFDLauncher",
"DisplayName": "SQL Full-text Filter Daemon Launcher (MSSQLSERVER)",
"State": "Running",
"StartMode": "Manual",
"PathName": "\"C:\\Program Files\\Microsoft SQL Server\\MSSQL15.MSSQLSERVER\\MSSQL\\Binn\\fdlauncher.exe\" -s MSSQL15.MSSQLSERVER"
},
{
"Name": "MSSQLSERVER",
"DisplayName": "SQL Server (MSSQLSERVER)",
"State": "Running",
"StartMode": "Auto",
"PathName": "\"C:\\Program Files\\Microsoft SQL Server\\MSSQL15.MSSQLSERVER\\MSSQL\\Binn\\sqlservr.exe\" -sMSSQLSERVER"
},
{
"Name": "SQLBrowser",
"DisplayName": "SQL Server Browser",
"State": "Stopped",
"StartMode": "Disabled",
"PathName": "\"C:\\Program Files (x86)\\Microsoft SQL Server\\90\\Shared\\sqlbrowser.exe\""
},
{
"Name": "SQLSERVERAGENT",
"DisplayName": "Agente SQL Server (MSSQLSERVER)",
"State": "Running",
"StartMode": "Auto",
"PathName": "\"C:\\Program Files\\Microsoft SQL Server\\MSSQL15.MSSQLSERVER\\MSSQL\\Binn\\SQLAGENT.EXE\" -i MSSQLSERVER"
},
{
"Name": "SQLTELEMETRY",
"DisplayName": "Servicio CEIP de SQL Server (MSSQLSERVER)",
"State": "Running",
"StartMode": "Auto",
"PathName": "\"C:\\Program Files\\Microsoft SQL Server\\MSSQL15.MSSQLSERVER\\MSSQL\\Binn\\sqlceip.exe\" -Service "
},
{
"Name": "SQLWriter",
"DisplayName": "SQL Server VSS Writer",
"State": "Running",
"StartMode": "Auto",
"PathName": "\"C:\\Program Files\\Microsoft SQL Server\\90\\Shared\\sqlwriter.exe\""
}
],
"SqlcmdPath": "C:\\Program Files\\Microsoft SQL Server\\Client SDK\\ODBC\\170\\Tools\\Binn\\SQLCMD.EXE",
"SqlQueryResults": [
{
"Instance": "localhost",
"Success": true,
"Output": "FullVersion|ProductVersion|ProductLevel|Edition|EngineEdition|MachineName|ServerName|InstanceName|Collation\r\n-----------|--------------|------------|-------|-------------|-----------|----------|------------|---------\r\nMicrosoft SQL Server 2019 (RTM-GDR) (KB5068405) - 15.0.2155.2 (X64) \r\n\tOct 7 2025 21:11:52 \r\n\tCopyright (C) 2019 Microsoft Corporation\r\n\tStandard Edition (64-bit) on Windows Server 2019 Standard 10.0 \u003cX64\u003e (Build 17763: ) (Hypervisor)\r\n|15.0.2155.2|RTM|Standard Edition (64-bit)|2|WIN-4BJQJ9S1PVJ|WIN-4BJQJ9S1PVJ|NULL|Latin1_General_CI_AI"
}
]
}
@@ -0,0 +1,123 @@
============================================================
Capture metadata
============================================================
Timestamp: 2026-05-05 10:05:13
Computer: WIN-4BJQJ9S1PVJ
User: WIN-4BJQJ9S1PVJ\Administrador
Output text: C:\Users\Administrador\Desktop\Sage_SQL_Environment_20260505_100511.txt
Output json: C:\Users\Administrador\Desktop\Sage_SQL_Environment_20260505_100511.json
============================================================
Windows / machine
============================================================
Manufacturer: Xen
Model: HVM domU
OS: Microsoft Windows Server 2019 Standard
OS Version: 10.0.17763
OS Build: 17763
Install date: 09/30/2020 08:17:56
============================================================
Sage entries from installed programs
============================================================
DisplayName : JRE 2.5
DisplayVersion :
Publisher : Sage Logic Control
InstallDate :
InstallLocation :
UninstallString : "C:\Windows\unins000.exe"
PSPath : Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\JRE_is1
DisplayName : Sage 200c
DisplayVersion : 2026.56.000
Publisher : Sage Spain
InstallDate :
InstallLocation :
UninstallString : C:\Program Files (x86)\Sage\Sage 200c\Setup\Uninstall\Sage.Uninstall.exe
PSPath : Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Sage 200c
DisplayName : Sage Renta Componentes
DisplayVersion : 1.00.0000
Publisher : Sage Spain
InstallDate : 20201021
InstallLocation : C:\Windows\SysWOW64\
UninstallString : MsiExec.exe /X{0ADD979C-205B-4264-B903-6F953F362917}
PSPath : Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{0ADD979C-205B-4264-B903-6F953F3
62917}
DisplayName : Sage SGE Runtime
DisplayVersion : 1.00.0000
Publisher : Sage Spain
InstallDate : 20201021
InstallLocation : C:\Windows\SysWOW64\
UninstallString : MsiExec.exe /X{1FFF90A6-3F93-4123-9C3B-54EBBDD22757}
PSPath : Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{1FFF90A6-3F93-4123-9C3B-54EBBDD
22757}
DisplayName : Sage Live Update Service
DisplayVersion : 1.0.8.0
Publisher : Sage
InstallDate : 20230314
InstallLocation :
UninstallString : MsiExec.exe /I{6D538240-299A-47CC-8782-2062AD2F2189}
PSPath : Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{6D538240-299A-47CC-8782-2062AD2
F2189}
DisplayName : Sage API OnPremise Service
DisplayVersion : 1.2.8.0
Publisher : Sage
InstallDate : 20201021
InstallLocation :
UninstallString : MsiExec.exe /I{9881C355-CB1B-4007-AB3A-B12F222318DB}
PSPath : Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{9881C355-CB1B-4007-AB3A-B12F222
318DB}
============================================================
Sage file versions
============================================================
Skipped. Re-run with -ScanProgramFiles for file version scan.
============================================================
SQL Server instances from registry
============================================================
InstanceName : MSSQLSERVER
InstanceId : MSSQL15.MSSQLSERVER
Edition : Standard Edition
Version : 15.0.2000.5
PatchLevel : 15.0.2155.2
ProductCode : {A60B3D8E-5311-4BF1-AF7A-D1AC15F9152E}
SQLPath : C:\Program Files\Microsoft SQL Server\MSSQL15.MSSQLSERVER\MSSQL
SetupPath : HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\MSSQL15.MSSQLSERVER\Setup
============================================================
SQL Server services
============================================================
Name DisplayName State StartMode PathName
---- ----------- ----- --------- --------
MSSQLFDLauncher SQL Full-text Filter Daemon Launcher (MSSQLSERVER) Running Manual "C:\Program Files\Microsoft SQL Server\MSSQL15.MSSQLSERVER\MSSQL\Binn\fdlauncher....
MSSQLSERVER SQL Server (MSSQLSERVER) Running Auto "C:\Program Files\Microsoft SQL Server\MSSQL15.MSSQLSERVER\MSSQL\Binn\sqlservr.ex...
SQLBrowser SQL Server Browser Stopped Disabled "C:\Program Files (x86)\Microsoft SQL Server\90\Shared\sqlbrowser.exe"
SQLSERVERAGENT Agente SQL Server (MSSQLSERVER) Running Auto "C:\Program Files\Microsoft SQL Server\MSSQL15.MSSQLSERVER\MSSQL\Binn\SQLAGENT.EX...
SQLTELEMETRY Servicio CEIP de SQL Server (MSSQLSERVER) Running Auto "C:\Program Files\Microsoft SQL Server\MSSQL15.MSSQLSERVER\MSSQL\Binn\sqlceip.exe...
SQLWriter SQL Server VSS Writer Running Auto "C:\Program Files\Microsoft SQL Server\90\Shared\sqlwriter.exe"
============================================================
SQL Server live query
============================================================
sqlcmd path: C:\Program Files\Microsoft SQL Server\Client SDK\ODBC\170\Tools\Binn\SQLCMD.EXE
Instance: localhost
Success: True
FullVersion|ProductVersion|ProductLevel|Edition|EngineEdition|MachineName|ServerName|InstanceName|Collation
-----------|--------------|------------|-------|-------------|-----------|----------|------------|---------
Microsoft SQL Server 2019 (RTM-GDR) (KB5068405) - 15.0.2155.2 (X64)
Oct 7 2025 21:11:52
Copyright (C) 2019 Microsoft Corporation
Standard Edition (64-bit) on Windows Server 2019 Standard 10.0 <X64> (Build 17763: ) (Hypervisor)
|15.0.2155.2|RTM|Standard Edition (64-bit)|2|WIN-4BJQJ9S1PVJ|WIN-4BJQJ9S1PVJ|NULL|Latin1_General_CI_AI
@@ -0,0 +1,11 @@
namespace TrafagSalesExporter.Security;
public sealed class AdminAccessOptions
{
public const string SectionName = "AdminAccess";
public bool Enabled { get; set; } = true;
public string Username { get; set; } = "admin";
public string PasswordHash { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}

Some files were not shown because too many files have changed in this diff Show More